1pub mod bundle;
2pub(crate) mod client_cache;
3pub mod config;
4mod header_policy;
5pub mod health;
6pub mod registry;
7pub(crate) mod rest_match;
8pub(crate) mod ssrf;
9pub mod static_config;
10pub mod static_dispatch;
11pub mod static_endpoint;
12pub(crate) mod tls_reload;
13use crate::config::parse_ok_status_code_range;
14pub use bundle::HttpBundle;
15pub use bundle::HttpStaticBundle;
16pub(crate) use client_cache::{PINNED_CLIENT_MAX_ENTRIES, PINNED_CLIENT_TTL, PinnedClientCache};
17pub use config::HttpConfig;
18pub use health::HttpHealthCheck;
19pub use registry::HttpRouteRegistry;
20pub use static_config::HttpStaticConfig;
21pub use static_endpoint::{HttpStaticComponent, HttpStaticConsumer, HttpStaticEndpoint};
22
23use std::collections::HashMap;
24use std::future::Future;
25use std::pin::Pin;
26
27use std::sync::{Arc, Mutex, OnceLock};
28use std::task::{Context, Poll};
29use std::time::Duration;
30
31use tokio::sync::OnceCell;
32use tower::Layer;
33use tower::Service;
34use tracing::debug;
35
36use axum::body::BodyDataStream;
37use camel_api::component_metadata::ComponentMetadata;
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 bridge_endpoint: bool,
124 pub connection_close: bool,
125 pub skip_request_headers: Vec<String>,
126 pub skip_response_headers: Vec<String>,
127 pub follow_redirects: bool,
128 pub max_redirects: usize,
129}
130
131#[derive(Clone, PartialEq)]
132pub enum HttpAuth {
133 None,
134 Basic { username: String, password: String },
135 Bearer { token: String },
136}
137
138impl std::fmt::Debug for HttpAuth {
139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140 match self {
141 HttpAuth::None => f.write_str("None"),
142 HttpAuth::Basic { username, .. } => f
143 .debug_struct("Basic")
144 .field("username", username)
145 .field("password", &"***")
146 .finish(),
147 HttpAuth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
148 }
149 }
150}
151
152const HTTP_CAMEL_OPTIONS: &[&str] = &[
154 "httpMethod",
155 "throwExceptionOnFailure",
156 "okStatusCodeRange",
157 "followRedirects",
158 "maxRedirects",
159 "connectTimeout",
160 "responseTimeout",
161 "allowInternal",
162 "blockedHosts",
163 "maxBodySize",
164 "readTimeout",
165 "maxResponseBytes",
166 "authMethod",
167 "authUsername",
168 "authPassword",
169 "authBearerToken",
170 "userAgent",
171 "cookieHandling",
172 "bridgeEndpoint",
173 "connectionClose",
174 "skipRequestHeaders",
175 "skipResponseHeaders",
176];
177
178impl UriConfig for HttpEndpointConfig {
179 fn scheme() -> &'static str {
181 "http"
182 }
183
184 fn from_uri(uri: &str) -> Result<Self, CamelError> {
185 let parts = parse_uri(uri)?;
186 Self::from_components(parts)
187 }
188
189 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
190 if parts.scheme != "http" && parts.scheme != "https" {
192 return Err(CamelError::InvalidUri(format!(
193 "expected scheme 'http' or 'https', got '{}'",
194 parts.scheme
195 )));
196 }
197
198 let base_url = format!("{}:{}", parts.scheme, parts.path);
201
202 let http_method = parts.params.get("httpMethod").cloned();
203
204 let throw_exception_on_failure = match parts.params.get("throwExceptionOnFailure") {
205 Some(v) => parse_bool_param_http(v).map_err(|e| {
206 CamelError::InvalidUri(format!("invalid value for throwExceptionOnFailure: {e}"))
207 })?,
208 None => true,
209 };
210
211 let ok_status_code_range = match parts.params.get("okStatusCodeRange") {
213 Some(v) => parse_ok_status_code_range(v)?,
214 None => (200, 299),
215 };
216
217 let response_timeout = match parts.params.get("responseTimeout") {
218 Some(v) => Some(v.parse::<u64>().map(Duration::from_millis).map_err(|e| {
219 CamelError::InvalidUri(format!("invalid value for responseTimeout: {e}"))
220 })?),
221 None => None,
222 };
223
224 let allow_internal = match parts.params.get("allowInternal") {
226 Some(v) => parse_bool_param_http(v).map_err(|e| {
227 CamelError::InvalidUri(format!("invalid value for allowInternal: {e}"))
228 })?,
229 None => false, };
231
232 let blocked_hosts = parts
234 .params
235 .get("blockedHosts")
236 .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
237 .unwrap_or_default();
238
239 let max_body_size = match parts.params.get("maxBodySize") {
240 Some(v) => v.parse::<usize>().map_err(|e| {
241 CamelError::InvalidUri(format!("invalid value for maxBodySize: {e}"))
242 })?,
243 None => 10 * 1024 * 1024, };
245
246 let read_timeout_ms = match parts.params.get("readTimeout") {
247 Some(v) => v.parse::<u64>().map_err(|e| {
248 CamelError::InvalidUri(format!("invalid value for readTimeout: {e}"))
249 })?,
250 None => 30_000, };
252
253 let max_response_bytes = match parts.params.get("maxResponseBytes") {
254 Some(v) => v.parse::<usize>().map_err(|e| {
255 CamelError::InvalidUri(format!("invalid value for maxResponseBytes: {e}"))
256 })?,
257 None => 10 * 1024 * 1024, };
259
260 let auth = parse_auth_from_params(&parts.params)?;
261
262 let user_agent = parts.params.get("userAgent").cloned();
263
264 if parts.params.contains_key("cookieHandling") {
265 return Err(CamelError::InvalidUri(
266 "cookieHandling is not supported".into(),
267 ));
268 }
269
270 let bridge_endpoint = match parts.params.get("bridgeEndpoint") {
271 Some(v) => parse_bool_param_http(v).map_err(|e| {
272 CamelError::InvalidUri(format!("invalid value for bridgeEndpoint: {e}"))
273 })?,
274 None => false,
275 };
276
277 let connection_close = match parts.params.get("connectionClose") {
278 Some(v) => parse_bool_param_http(v).map_err(|e| {
279 CamelError::InvalidUri(format!("invalid value for connectionClose: {e}"))
280 })?,
281 None => false,
282 };
283
284 let skip_request_headers = parts
285 .params
286 .get("skipRequestHeaders")
287 .map(|v| {
288 v.split(',')
289 .map(str::trim)
290 .filter(|s| !s.is_empty())
291 .map(|s| s.to_ascii_lowercase())
292 .collect::<Vec<_>>()
293 })
294 .unwrap_or_default();
295
296 let skip_response_headers = parts
297 .params
298 .get("skipResponseHeaders")
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 follow_redirects = match parts.params.get("followRedirects") {
309 Some(v) => parse_bool_param_http(v).map_err(|e| {
310 CamelError::InvalidUri(format!("invalid value for followRedirects: {e}"))
311 })?,
312 None => false,
313 };
314
315 let max_redirects = match parts.params.get("maxRedirects") {
316 Some(v) => v.parse::<usize>().map_err(|e| {
317 CamelError::InvalidUri(format!("invalid value for maxRedirects: {e}"))
318 })?,
319 None => 10,
320 };
321
322 let query_params: HashMap<String, String> = parts
324 .params
325 .into_iter()
326 .filter(|(k, _)| !HTTP_CAMEL_OPTIONS.contains(&k.as_str()))
327 .collect();
328
329 Ok(Self {
330 base_url,
331 http_method,
332 throw_exception_on_failure,
333 ok_status_code_range,
334 response_timeout,
335 query_params,
336 allow_internal,
337 blocked_hosts,
338 max_body_size,
339 read_timeout_ms,
340 max_response_bytes,
341 auth,
342 token_provider: None,
343 user_agent,
344 bridge_endpoint,
345 connection_close,
346 skip_request_headers,
347 skip_response_headers,
348 follow_redirects,
349 max_redirects,
350 })
351 }
352}
353
354#[derive(Debug, Clone, UriConfig)]
361#[allow(dead_code)]
362#[uri_scheme = "http"]
363#[uri_config(
364 skip_impl,
365 metadata(
366 scheme = "http",
367 description = "HTTP client and server component",
368 producer,
369 consumer,
370 streaming
371 ),
372 crate = "camel_component_api"
373)]
374struct HttpEndpointUriConfig {
375 #[allow(dead_code)]
376 _base_url: String,
377
378 #[uri_param(
379 name = "httpMethod",
380 desc = "HTTP method. Defaults to CamelHttpMethod header or POST/GET"
381 )]
382 http_method: Option<String>,
383
384 #[uri_param(
385 name = "throwExceptionOnFailure",
386 default = "true",
387 desc = "Throw on non-2xx status"
388 )]
389 throw_exception_on_failure: bool,
390
391 #[uri_param(
392 name = "okStatusCodeRange",
393 default = "200-299",
394 desc = "Success status code range"
395 )]
396 ok_status_code_range: String,
397
398 #[uri_param(name = "responseTimeout", desc = "Response timeout in milliseconds")]
399 response_timeout: Option<u64>,
400
401 #[uri_param(
402 name = "allowInternal",
403 default = "false",
404 desc = "Allow private/internal network destinations (SSRF)"
405 )]
406 allow_internal: bool,
407
408 #[uri_param(name = "blockedHosts", desc = "Comma-separated blocked host list")]
409 blocked_hosts: Option<String>,
410
411 #[uri_param(
412 name = "maxBodySize",
413 default = "10485760",
414 desc = "Max request/response body bytes"
415 )]
416 max_body_size: u64,
417
418 #[uri_param(name = "readTimeout", desc = "Socket read timeout in milliseconds")]
419 read_timeout: Option<u64>,
420
421 #[uri_param(name = "maxResponseBytes", desc = "Max response body bytes")]
422 max_response_bytes: Option<u64>,
423
424 #[uri_param(
425 name = "authMethod",
426 kind = "enum:Basic,Bearer",
427 desc = "Authentication method"
428 )]
429 auth_method: Option<String>,
430
431 #[uri_param(name = "authUsername", secret, desc = "Basic auth username")]
432 auth_username: Option<String>,
433
434 #[uri_param(name = "authPassword", secret, desc = "Basic auth password")]
435 auth_password: Option<String>,
436
437 #[uri_param(name = "authBearerToken", secret, desc = "Bearer auth token")]
438 auth_bearer_token: Option<String>,
439
440 #[uri_param(name = "userAgent", desc = "User-Agent header")]
441 user_agent: Option<String>,
442
443 #[uri_param(
444 name = "bridgeEndpoint",
445 default = "false",
446 desc = "Bridge endpoint mode"
447 )]
448 bridge_endpoint: bool,
449
450 #[uri_param(
451 name = "connectionClose",
452 default = "false",
453 desc = "Send Connection: close"
454 )]
455 connection_close: bool,
456
457 #[uri_param(
458 name = "skipRequestHeaders",
459 desc = "Comma-separated request headers to skip"
460 )]
461 skip_request_headers: Option<String>,
462
463 #[uri_param(
464 name = "skipResponseHeaders",
465 desc = "Comma-separated response headers to skip"
466 )]
467 skip_response_headers: Option<String>,
468
469 #[uri_param(
470 name = "followRedirects",
471 default = "false",
472 desc = "Follow HTTP redirects"
473 )]
474 follow_redirects: bool,
475
476 #[uri_param(name = "maxRedirects", default = "10", desc = "Max redirect hops")]
477 max_redirects: u64,
478}
479
480impl HttpEndpointConfig {
481 pub fn metadata() -> ComponentMetadata {
484 HttpEndpointUriConfig::metadata()
485 }
486
487 pub fn uri_options() -> Vec<camel_api::component_metadata::UriOption> {
489 HttpEndpointUriConfig::uri_options()
490 }
491}
492
493fn parse_auth_from_params(params: &HashMap<String, String>) -> Result<HttpAuth, CamelError> {
494 let Some(method) = params.get("authMethod") else {
495 return Ok(HttpAuth::None);
496 };
497
498 if method.eq_ignore_ascii_case("none") {
499 return Ok(HttpAuth::None);
500 }
501
502 if method.eq_ignore_ascii_case("basic") {
503 let username = params.get("authUsername").cloned().ok_or_else(|| {
504 CamelError::InvalidUri("authUsername is required for authMethod=Basic".to_string())
505 })?;
506 let password = params.get("authPassword").cloned().ok_or_else(|| {
507 CamelError::InvalidUri("authPassword is required for authMethod=Basic".to_string())
508 })?;
509 return Ok(HttpAuth::Basic { username, password });
510 }
511
512 if method.eq_ignore_ascii_case("bearer") {
513 let token = params.get("authBearerToken").cloned().ok_or_else(|| {
514 CamelError::InvalidUri("authBearerToken is required for authMethod=Bearer".to_string())
515 })?;
516 return Ok(HttpAuth::Bearer { token });
517 }
518
519 Err(CamelError::InvalidUri(format!(
520 "invalid value for authMethod: {method} (expected None, Basic, or Bearer)"
521 )))
522}
523
524fn parse_bool_param_http(value: &str) -> Result<bool, CamelError> {
525 match value.to_ascii_lowercase().as_str() {
526 "true" | "1" | "yes" => Ok(true),
527 "false" | "0" | "no" => Ok(false),
528 _ => Err(CamelError::InvalidUri(format!(
529 "invalid boolean value: '{value}'"
530 ))),
531 }
532}
533
534impl HttpEndpointConfig {
535 pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
536 let parts = parse_uri(uri)?;
537 let mut endpoint = Self::from_components(parts.clone())?;
538 if endpoint.response_timeout.is_none() {
539 endpoint.response_timeout = Some(Duration::from_millis(config.response_timeout_ms));
540 }
541 if !parts.params.contains_key("allowInternal") {
542 endpoint.allow_internal = config.allow_internal;
543 }
544 if !parts.params.contains_key("blockedHosts") {
545 endpoint.blocked_hosts = config.blocked_hosts.clone();
546 }
547 if !parts.params.contains_key("maxBodySize") {
548 endpoint.max_body_size = config.max_body_size;
549 }
550 if !parts.params.contains_key("readTimeout") {
551 endpoint.read_timeout_ms = config.read_timeout_ms;
552 }
553 if !parts.params.contains_key("maxResponseBytes") {
554 endpoint.max_response_bytes = config.max_response_bytes;
555 }
556 if !parts.params.contains_key("okStatusCodeRange")
557 && let Some(range) = &config.ok_status_code_range
558 {
559 endpoint.ok_status_code_range = parse_ok_status_code_range(range)?;
560 }
561 if !parts.params.contains_key("followRedirects") {
562 endpoint.follow_redirects = config.follow_redirects;
563 }
564 if !parts.params.contains_key("maxRedirects") {
565 endpoint.max_redirects = config.max_redirects.unwrap_or(10);
566 }
567
568 Ok(endpoint)
569 }
570}
571
572#[derive(Debug, Clone)]
578pub struct HttpServerConfig {
579 pub scheme: String,
581 pub host: String,
583 pub port: u16,
585 pub path: String,
587 pub max_request_body: usize,
589 pub max_response_body: usize,
591 pub max_inflight_requests: usize,
593 pub method: Option<String>,
600 pub tls_config: Option<crate::config::ServerTlsConfig>,
603}
604
605impl UriConfig for HttpServerConfig {
606 fn scheme() -> &'static str {
608 "http"
609 }
610
611 fn from_uri(uri: &str) -> Result<Self, CamelError> {
612 let parts = parse_uri(uri)?;
613 Self::from_components(parts)
614 }
615
616 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
617 if parts.scheme != "http" && parts.scheme != "https" {
619 return Err(CamelError::InvalidUri(format!(
620 "expected scheme 'http' or 'https', got '{}'",
621 parts.scheme
622 )));
623 }
624
625 let authority_and_path = parts.path.trim_start_matches('/');
628
629 let (authority, path_suffix) = if let Some(idx) = authority_and_path.find('/') {
631 (&authority_and_path[..idx], &authority_and_path[idx..])
632 } else {
633 (authority_and_path, "/")
634 };
635
636 let path = if path_suffix.is_empty() {
637 "/"
638 } else {
639 path_suffix
640 }
641 .to_string();
642
643 let (host, port) = if let Some(colon) = authority.rfind(':') {
645 let port_str = &authority[colon + 1..];
646 match port_str.parse::<u16>() {
647 Ok(p) => (authority[..colon].to_string(), p),
648 Err(_) => {
649 return Err(CamelError::InvalidUri(format!(
650 "invalid port '{}' in authority",
651 port_str
652 )));
653 }
654 }
655 } else {
656 let default_port = if parts.scheme == "https" { 443 } else { 80 };
658 (authority.to_string(), default_port)
659 };
660
661 let max_request_body = parts
662 .params
663 .get("maxRequestBody")
664 .and_then(|v| v.parse::<usize>().ok())
665 .unwrap_or(2 * 1024 * 1024); let max_response_body = parts
668 .params
669 .get("maxResponseBody")
670 .and_then(|v| v.parse::<usize>().ok())
671 .unwrap_or(10 * 1024 * 1024); let max_inflight_requests = parts
674 .params
675 .get("maxInflightRequests")
676 .and_then(|v| v.parse::<usize>().ok())
677 .unwrap_or(1024);
678
679 let method = parts.params.get("httpMethod").map(|m| m.to_uppercase());
685
686 Ok(Self {
687 scheme: parts.scheme,
688 host,
689 port,
690 path,
691 max_request_body,
692 max_response_body,
693 max_inflight_requests,
694 method,
695 tls_config: {
696 let cert = parts.params.get("tlsCert").cloned();
697 let key = parts.params.get("tlsKey").cloned();
698 match (cert, key) {
699 (Some(c), Some(k)) => Some(crate::config::ServerTlsConfig {
700 cert_path: c,
701 key_path: k,
702 }),
703 (None, None) => None,
704 _ => None, }
706 },
707 })
708 }
709}
710
711impl HttpServerConfig {
712 pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
713 let parts = parse_uri(uri)?;
714 let mut server = Self::from_components(parts.clone())?;
715 if !parts.params.contains_key("maxRequestBody") {
716 server.max_request_body = config.max_request_body;
717 }
718 if !parts.params.contains_key("maxResponseBody") {
719 server.max_response_body = config.max_body_size;
721 }
722 Ok(server)
723 }
724}
725
726pub enum HttpReplyBody {
734 Bytes(bytes::Bytes),
735 Stream(BoxStream<'static, Result<bytes::Bytes, CamelError>>),
736}
737
738pub struct RequestEnvelope {
743 pub method: String,
744 pub path: String,
745 pub query: String,
746 pub headers: http::HeaderMap,
747 pub body: StreamBody,
748 pub path_params: std::collections::HashMap<String, String>,
754 pub reply_tx: tokio::sync::oneshot::Sender<HttpReply>,
755}
756
757pub struct HttpReply {
761 pub status: u16,
762 pub headers: Vec<(String, String)>,
763 pub body: HttpReplyBody,
764}
765
766type ServerKey = (String, u16);
771
772struct ServerHandle {
774 registry: HttpRouteRegistry,
775 max_request_body: usize,
776 max_response_body: usize,
777 max_inflight_requests: usize,
778 is_tls: bool,
779 tls_cert_path: Option<String>,
780 tls_key_path: Option<String>,
781 monitor_task: tokio::task::JoinHandle<()>,
784 tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
787 tls_source: Option<ServerTlsSource>,
788}
789
790pub struct ServerRegistry {
792 inner: Mutex<HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>>,
793}
794
795impl ServerRegistry {
796 pub fn global() -> &'static Self {
798 static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
799 INSTANCE.get_or_init(|| ServerRegistry {
800 inner: Mutex::new(HashMap::new()),
801 })
802 }
803
804 #[allow(clippy::too_many_arguments)]
807 pub async fn get_or_spawn(
808 &'static self,
809 host: &str,
810 port: u16,
811 max_request_body: usize,
812 max_response_body: usize,
813 max_inflight_requests: usize,
814 runtime: Arc<dyn RuntimeObservability>,
815 route_id: String,
816 tls_config: Option<crate::config::ServerTlsConfig>,
817 ) -> Result<HttpRouteRegistry, CamelError> {
818 let host_owned = host.to_string();
819
820 let cell = {
821 let mut guard = self.inner.lock().map_err(|_| {
822 CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
823 })?;
824 let key = (host.to_string(), port);
825 if let Some(existing) = guard.get(&key)
829 && let Some(handle) = existing.get()
830 && handle.monitor_task.is_finished()
831 {
832 if handle.is_tls {
835 let scheme = if handle.is_tls { "https" } else { "http" };
836 camel_component_api::tls_source::TlsReloadRegistry::global()
837 .unregister(scheme, host, port);
838 }
839 guard.remove(&key);
840 }
841 guard
842 .entry(key)
843 .or_insert_with(|| Arc::new(OnceCell::new()))
844 .clone()
845 };
846
847 if let Some(existing) = cell.get()
848 && existing.max_request_body != max_request_body
849 {
850 return Err(CamelError::EndpointCreationFailed(format!(
851 "incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
852 existing.max_request_body, max_request_body
853 )));
854 }
855
856 if let Some(existing) = cell.get()
857 && existing.max_response_body != max_response_body
858 {
859 return Err(CamelError::EndpointCreationFailed(format!(
860 "incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
861 existing.max_response_body, max_response_body
862 )));
863 }
864
865 if let Some(existing) = cell.get()
866 && existing.max_inflight_requests != max_inflight_requests
867 {
868 return Err(CamelError::EndpointCreationFailed(format!(
869 "incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
870 existing.max_inflight_requests, max_inflight_requests
871 )));
872 }
873
874 if let Some(existing) = cell.get()
876 && existing.is_tls != tls_config.is_some()
877 {
878 return Err(CamelError::EndpointCreationFailed(format!(
879 "incompatible TLS mode for shared server (host={host}, port={port}): existing is_tls={}, new has_tls={}",
880 existing.is_tls,
881 tls_config.is_some()
882 )));
883 }
884
885 if let (Some(existing), Some(new_tls)) = (cell.get(), &tls_config)
887 && (existing.tls_cert_path.as_deref() != Some(&new_tls.cert_path)
888 || existing.tls_key_path.as_deref() != Some(&new_tls.key_path))
889 {
890 return Err(CamelError::EndpointCreationFailed(format!(
891 "incompatible TLS cert/key for shared server (host={host}, port={port}): routes on the same TLS port must use the same cert and key"
892 )));
893 }
894
895 let handle = cell
896 .get_or_try_init(|| {
897 let rt = Arc::clone(&runtime);
898 let rid = route_id.clone();
899 async move {
900 let addr = format!("{host_owned}:{port}");
901 let listener = tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
902 CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
903 })?;
904 let registry = HttpRouteRegistry::new();
905 let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
906 let tls_rustls_cfg: Option<axum_server::tls_rustls::RustlsConfig>;
909 let tls_source: Option<ServerTlsSource>;
910 let server_task = if let Some(ref tls) = tls_config {
911 let rustls_config = load_tls_config(&tls.cert_path, &tls.key_path)?;
912 let source = ServerTlsSource {
913 cert_path: std::path::PathBuf::from(&tls.cert_path),
914 key_path: std::path::PathBuf::from(&tls.key_path),
915 client_ca_path: None,
916 };
917 let rustls_cfg = axum_server::tls_rustls::RustlsConfig::from_config(
921 std::sync::Arc::new(rustls_config),
922 );
923 tls_rustls_cfg = Some(rustls_cfg.clone());
924 tls_source = Some(source);
925 let std_listener = listener.into_std().map_err(|e| {
927 CamelError::EndpointCreationFailed(format!(
928 "TLS listener conversion: {e}"
929 ))
930 })?;
931 tokio::spawn(run_axum_server_tls(
932 std_listener,
933 rustls_cfg,
934 registry.clone(),
935 max_request_body,
936 max_response_body,
937 Arc::clone(&inflight),
938 Arc::clone(&rt),
939 rid.clone(),
940 ))
941 } else {
942 tls_rustls_cfg = None;
943 tls_source = None;
944 tokio::spawn(run_axum_server(
945 listener,
946 registry.clone(),
947 max_request_body,
948 max_response_body,
949 Arc::clone(&inflight),
950 Arc::clone(&rt),
951 rid.clone(),
952 ))
953 };
954 let addr_for_monitor = format!("{host_owned}:{port}");
955 let monitor_task = tokio::spawn(monitor_axum_task(
956 server_task,
957 addr_for_monitor,
958 Arc::clone(&rt),
959 rid,
960 ));
961 let handle = ServerHandle {
962 registry,
963 max_request_body,
964 max_response_body,
965 max_inflight_requests,
966 is_tls: tls_config.is_some(),
967 tls_cert_path: tls_config.as_ref().map(|t| t.cert_path.clone()),
968 tls_key_path: tls_config.as_ref().map(|t| t.key_path.clone()),
969 monitor_task,
970 tls_config: tls_rustls_cfg,
971 tls_source,
972 };
973 if let (Some(tls_cfg), Some(source)) =
978 (handle.tls_config.as_ref(), handle.tls_source.as_ref())
979 {
980 let handler = Arc::new(crate::tls_reload::HttpReloadHandler::new(
981 tls_cfg.clone(),
982 source.clone(),
983 host_owned.clone(),
984 port,
985 ));
986 camel_component_api::tls_source::TlsReloadRegistry::global()
987 .register(handler);
988 }
989 Ok::<ServerHandle, CamelError>(handle)
990 }
991 })
992 .await?;
993
994 Ok(handle.registry.clone())
995 }
996
997 pub async fn unregister(&self, host: &str, port: u16) {
1001 debug!(
1002 host = host,
1003 port = port,
1004 "consumer unregistered from HTTP server"
1005 );
1006 }
1007
1008 #[cfg(test)]
1015 pub fn reset() {
1016 let instance = Self::global();
1017 let mut guard = instance
1018 .inner
1019 .lock()
1020 .expect("ServerRegistry lock poisoned during test reset");
1021 guard.clear();
1022 }
1023}
1024
1025use axum::{
1030 Router,
1031 body::Body as AxumBody,
1032 extract::{Request, State},
1033 http::{Response, StatusCode},
1034 response::IntoResponse,
1035};
1036
1037#[derive(Clone)]
1038pub(crate) struct AppState {
1039 registry: HttpRouteRegistry,
1040 max_request_body: usize,
1041 max_response_body: usize,
1042 inflight: Arc<tokio::sync::Semaphore>,
1043}
1044
1045async fn run_axum_server(
1046 listener: tokio::net::TcpListener,
1047 registry: HttpRouteRegistry,
1048 max_request_body: usize,
1049 max_response_body: usize,
1050 inflight: Arc<tokio::sync::Semaphore>,
1051 runtime: Arc<dyn RuntimeObservability>,
1052 route_id: String,
1053) {
1054 let state = AppState {
1055 registry,
1056 max_request_body,
1057 max_response_body,
1058 inflight,
1059 };
1060 let app = Router::new().fallback(dispatch_handler).with_state(state);
1061
1062 axum::serve(listener, app).await.unwrap_or_else(|e| {
1063 runtime
1064 .metrics()
1065 .increment_errors(&route_id, "e:http:accept");
1066 tracing::error!(error = %e, "Axum server error");
1068 });
1069}
1070
1071#[allow(clippy::too_many_arguments)]
1072async fn run_axum_server_tls(
1073 listener: std::net::TcpListener,
1074 tls_cfg: axum_server::tls_rustls::RustlsConfig,
1075 registry: HttpRouteRegistry,
1076 max_request_body: usize,
1077 max_response_body: usize,
1078 inflight: Arc<tokio::sync::Semaphore>,
1079 runtime: Arc<dyn RuntimeObservability>,
1080 route_id: String,
1081) {
1082 let state = AppState {
1083 registry,
1084 max_request_body,
1085 max_response_body,
1086 inflight,
1087 };
1088 let app = Router::new().fallback(dispatch_handler).with_state(state);
1089
1090 axum_server::from_tcp_rustls(listener, tls_cfg)
1094 .serve(app.into_make_service())
1095 .await
1096 .unwrap_or_else(|e| {
1097 runtime
1098 .metrics()
1099 .increment_errors(&route_id, "e:http:accept-tls");
1100 tracing::error!(error = %e, "Axum TLS server error");
1102 });
1103}
1104
1105async fn monitor_axum_task(
1113 handle: tokio::task::JoinHandle<()>,
1114 addr: String,
1115 runtime: Arc<dyn RuntimeObservability>,
1116 route_id: String,
1117) {
1118 match handle.await {
1119 Ok(()) => {
1120 }
1122 Err(join_err) => {
1123 runtime
1124 .metrics()
1125 .increment_errors(&route_id, "e:http:server-task-exited");
1126 tracing::error!(
1128 addr = %addr,
1129 error = %join_err,
1130 "Axum server task exited unexpectedly — all routes on this port are now dead"
1131 );
1132 }
1133 }
1134}
1135
1136fn load_tls_config(
1139 cert_path: &str,
1140 key_path: &str,
1141) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
1142 use std::fs::File;
1143 use std::io::BufReader;
1144
1145 let cert_file = File::open(cert_path)
1146 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
1147 let key_file = File::open(key_path)
1148 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
1149
1150 let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
1151 .collect::<Result<Vec<_>, _>>()
1152 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
1153
1154 let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
1155 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
1156 .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
1157
1158 tokio_rustls::rustls::ServerConfig::builder()
1159 .with_no_client_auth()
1160 .with_single_cert(certs, key)
1161 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
1162}
1163
1164async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
1165 let path = req.uri().path().to_owned();
1166 let method = req.method().to_string();
1167
1168 let api_sender = {
1185 let inner = state.registry.inner.read().await;
1186 inner.api_routes.get(&path).cloned()
1187 }; let (rest_sender, path_params) = if api_sender.is_some() {
1190 (None, Default::default())
1192 } else {
1193 let inner = state.registry.inner.read().await;
1194 match rest_match::match_endpoint(&method, &path, &inner.rest_endpoints) {
1195 rest_match::MatchOutcome::Found(m) => (Some(m.payload), m.path_params),
1196 rest_match::MatchOutcome::Ambiguous => {
1197 tracing::warn!(
1203 method = %method,
1204 path = %path,
1205 "ambiguous REST template match — returning 500"
1206 );
1207 return Response::builder()
1208 .status(StatusCode::INTERNAL_SERVER_ERROR)
1209 .body(AxumBody::from("Internal Server Error"))
1210 .expect("infallible"); }
1212 rest_match::MatchOutcome::NotFound => (None, Default::default()),
1213 }
1214 }; let sender = api_sender.or(rest_sender);
1217
1218 if let Some(sender) = sender {
1219 let query = req.uri().query().unwrap_or("").to_string();
1220 let headers = req.headers().clone();
1221
1222 let content_length: Option<u64> = headers
1224 .get(http::header::CONTENT_LENGTH)
1225 .and_then(|v| v.to_str().ok())
1226 .and_then(|s| s.parse().ok());
1227
1228 if let Some(len) = content_length
1229 && len > state.max_request_body as u64
1230 {
1231 return Response::builder()
1232 .status(StatusCode::PAYLOAD_TOO_LARGE)
1233 .body(AxumBody::from("Request body exceeds configured limit"))
1234 .expect("infallible"); }
1236
1237 let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
1238 Ok(permit) => permit,
1239 Err(_) => {
1240 return Response::builder()
1241 .status(StatusCode::SERVICE_UNAVAILABLE)
1242 .body(AxumBody::from("Service Unavailable"))
1243 .expect("infallible"); }
1245 };
1246
1247 let content_type = headers
1249 .get(http::header::CONTENT_TYPE)
1250 .and_then(|v| v.to_str().ok())
1251 .map(|s| s.to_string());
1252
1253 let data_stream: BodyDataStream = req.into_body().into_data_stream();
1254 let mapped_stream = data_stream.map_err(|e| CamelError::Io(e.to_string()));
1255 let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(mapped_stream);
1256
1257 let stream_body = StreamBody {
1258 stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
1259 metadata: StreamMetadata {
1260 size_hint: content_length,
1261 content_type,
1262 origin: None,
1263 },
1264 };
1265
1266 let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
1267 let envelope = RequestEnvelope {
1268 method,
1269 path,
1270 query,
1271 headers,
1272 body: stream_body,
1273 path_params,
1274 reply_tx,
1275 };
1276
1277 if sender.send(envelope).await.is_err() {
1278 return Response::builder()
1279 .status(StatusCode::SERVICE_UNAVAILABLE)
1280 .body(AxumBody::from("Consumer unavailable"))
1281 .expect("infallible"); }
1283
1284 match reply_rx.await {
1285 Ok(reply) => {
1286 let reply = match reply.body {
1287 HttpReplyBody::Bytes(b)
1288 if exceeds_max_response_body(b.len(), state.max_response_body) =>
1289 {
1290 HttpReply {
1291 status: 500,
1292 headers: vec![],
1293 body: HttpReplyBody::Bytes(bytes::Bytes::from(
1294 "Response body exceeds configured limit",
1295 )),
1296 }
1297 }
1298 _ => reply,
1299 };
1300
1301 let status =
1302 StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1303 let mut builder = Response::builder().status(status);
1304 for (k, v) in &reply.headers {
1305 builder = builder.header(k.as_str(), v.as_str());
1306 }
1307 match reply.body {
1308 HttpReplyBody::Bytes(b) => {
1309 builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
1310 Response::builder()
1311 .status(StatusCode::INTERNAL_SERVER_ERROR)
1312 .body(AxumBody::from("Invalid response headers from consumer"))
1313 .expect("infallible") })
1315 }
1316 HttpReplyBody::Stream(stream) => builder
1317 .body(AxumBody::from_stream(stream))
1318 .unwrap_or_else(|_| {
1319 Response::builder()
1320 .status(StatusCode::INTERNAL_SERVER_ERROR)
1321 .body(AxumBody::from("Invalid response headers from consumer"))
1322 .expect("infallible") }),
1324 }
1325 }
1326 Err(_) => Response::builder()
1327 .status(StatusCode::INTERNAL_SERVER_ERROR)
1328 .body(AxumBody::from("Pipeline error"))
1329 .expect("infallible"), }
1331 } else {
1332 static_dispatch::dispatch_static(&state, req, &path).await
1334 }
1335}
1336
1337fn exceeds_max_response_body(len: usize, max: usize) -> bool {
1338 len > max
1339}
1340
1341fn title_case_header(name: &str) -> String {
1342 name.split('-')
1343 .map(|part| {
1344 let mut chars = part.chars();
1345 match chars.next() {
1346 None => String::new(),
1347 Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
1348 }
1349 })
1350 .collect::<Vec<_>>()
1351 .join("-")
1352}
1353
1354pub(crate) struct HttpKernelAuth {
1369 pub(crate) plan: camel_api::security_policy::RouteSecurityPlan,
1370 pub(crate) providers: Arc<camel_auth::ProviderRegistry>,
1371}
1372
1373impl HttpKernelAuth {
1374 pub(crate) fn from_security_context(
1379 ctx: &camel_component_api::SecurityContext,
1380 ) -> Option<Self> {
1381 Some(Self {
1382 plan: ctx.plan.clone()?,
1383 providers: ctx.providers.clone()?,
1384 })
1385 }
1386}
1387
1388fn envelope_channel_capacity(max_inflight_requests: usize) -> usize {
1402 max_inflight_requests.max(1)
1403}
1404
1405pub struct HttpConsumer {
1406 config: HttpServerConfig,
1407 runtime: Arc<dyn RuntimeObservability>,
1409 kernel: Option<Arc<HttpKernelAuth>>,
1413}
1414
1415impl HttpConsumer {
1416 pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
1417 Self {
1418 config,
1419 runtime,
1420 kernel: None,
1421 }
1422 }
1423}
1424
1425#[async_trait::async_trait]
1426impl Consumer for HttpConsumer {
1427 async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
1428 use camel_component_api::{Body, Exchange, Message};
1429
1430 let registry = ServerRegistry::global()
1431 .get_or_spawn(
1432 &self.config.host,
1433 self.config.port,
1434 self.config.max_request_body,
1435 self.config.max_response_body,
1436 self.config.max_inflight_requests,
1437 self.runtime.clone(),
1438 ctx.route_id().to_string(),
1439 self.config.tls_config.clone(),
1440 )
1441 .await?;
1442
1443 let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(
1447 envelope_channel_capacity(self.config.max_inflight_requests),
1448 );
1449 if let Some(method) = self.config.method.clone() {
1456 let segments = rest_match::parse_path_template(&self.config.path);
1457 registry
1458 .register_rest_endpoint(method, segments, env_tx)
1459 .await;
1460 } else {
1461 registry
1462 .register_api_route(self.config.path.clone(), env_tx)
1463 .await;
1464 }
1465
1466 ctx.mark_ready();
1475
1476 let path = self.config.path.clone();
1477 let registry_for_cleanup = registry.clone();
1478 let cancel_token = ctx.cancel_token();
1479 let kernel = self.kernel.clone();
1480 loop {
1481 tokio::select! {
1482 _ = ctx.cancelled() => {
1483 break;
1484 }
1485 envelope = env_rx.recv() => {
1486 let Some(envelope) = envelope else { break; };
1487
1488 let mut msg = Message::default();
1490
1491 msg.set_header("CamelHttpMethod",
1493 serde_json::Value::String(envelope.method.clone()));
1494 msg.set_header("CamelHttpPath",
1495 serde_json::Value::String(envelope.path.clone()));
1496 msg.set_header("CamelHttpQuery",
1497 serde_json::Value::String(envelope.query.clone()));
1498
1499 for (param_name, param_value) in &envelope.path_params {
1506 msg.set_header(
1507 format!("CamelHttpPath_{param_name}"),
1508 serde_json::Value::String(param_value.clone()),
1509 );
1510 }
1511
1512 for (k, v) in &envelope.headers {
1514 if let Ok(val_str) = v.to_str() {
1515 msg.set_header(
1516 title_case_header(k.as_str()),
1517 serde_json::Value::String(val_str.to_string()),
1518 );
1519 }
1520 }
1521
1522 msg.body = Body::Stream(envelope.body);
1525
1526 #[allow(unused_mut)]
1527 let mut exchange = Exchange::new(msg);
1528
1529 #[cfg(feature = "otel")]
1531 {
1532 let headers: HashMap<String, String> = envelope
1533 .headers
1534 .iter()
1535 .filter_map(|(k, v)| {
1536 Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1537 })
1538 .collect();
1539 camel_otel::extract_into_exchange(&mut exchange, &headers);
1540 }
1541
1542 let reply_tx = envelope.reply_tx;
1543 let sender = ctx.sender().clone();
1544 let path_clone = path.clone();
1545 let cancel = cancel_token.clone();
1546 let auth_headers = envelope.headers.clone();
1550 let auth_uri: http::Uri = {
1551 let full = if envelope.query.is_empty() {
1552 envelope.path.clone()
1553 } else {
1554 format!("{}?{}", envelope.path, envelope.query)
1555 };
1556 full.parse().unwrap_or_default()
1560 };
1561 let kernel = kernel.clone();
1562
1563 tokio::spawn(async move {
1583 if cancel.is_cancelled() {
1591 let _ = reply_tx.send(HttpReply {
1592 status: 503,
1593 headers: vec![],
1594 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
1595 });
1596 return;
1597 }
1598
1599 if let Some(kernel) = kernel.as_ref()
1608 && !matches!(
1609 kernel.plan.access_mode,
1610 camel_api::security_policy::AccessMode::Public
1611 )
1612 {
1613 let principal = match camel_auth::extract_token_multi(
1614 &auth_headers,
1615 &auth_uri,
1616 &kernel.plan.credential_sources,
1617 ) {
1618 Some(extracted) => {
1619 match camel_auth::kernel_authenticate(
1620 &kernel.plan,
1621 &kernel.providers,
1622 &extracted,
1623 )
1624 .await
1625 {
1626 Ok(principal) => principal,
1627 Err(e) => {
1628 tracing::warn!(
1630 path = %path_clone,
1631 error = %e,
1632 "HTTP request authentication failed"
1633 );
1634 let _ = reply_tx.send(pipeline_error_to_reply(
1635 e,
1636 &path_clone,
1637 ));
1638 return;
1639 }
1640 }
1641 }
1642 None => {
1643 tracing::warn!(
1645 path = %path_clone,
1646 "HTTP request rejected: no credential found in any source"
1647 );
1648 let _ = reply_tx.send(pipeline_error_to_reply(
1649 CamelError::Unauthenticated(
1650 "no credential found in any source".to_string(),
1651 ),
1652 &path_clone,
1653 ));
1654 return;
1655 }
1656 };
1657 camel_auth::install_carrier(&mut exchange, &principal);
1658 }
1659
1660 let (tx, rx) = tokio::sync::oneshot::channel();
1662 let envelope = camel_component_api::consumer::ExchangeEnvelope {
1663 exchange,
1664 reply_tx: Some(tx),
1665 };
1666
1667 let result = match sender.send(envelope).await {
1668 Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
1669 Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
1670 }
1671 .and_then(|r| r);
1672
1673 let reply = match result {
1674 Ok(out) => {
1675 let status = out
1676 .input
1677 .header("CamelHttpResponseCode")
1678 .and_then(|v| {
1679 let raw = v.as_u64()
1680 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
1681 let code = raw as u16;
1682 (100..1000).contains(&code).then_some(code)
1683 })
1684 .unwrap_or(200);
1685
1686 let user_content_type = out
1687 .input
1688 .header("Content-Type")
1689 .and_then(|v| v.as_str().map(|s| s.to_string()));
1690
1691 let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
1692 Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
1693 Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
1694 Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
1695 Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
1696 v.to_string().into_bytes(),
1697 )), Some("application/json".to_string())),
1698 Body::Stream(s) => {
1699 let ct = s.metadata.content_type.clone();
1700 match s.stream.lock().await.take() {
1701 Some(stream) => (
1702 HttpReplyBody::Stream(stream),
1703 ct,
1704 ),
1705 None => {
1706 tracing::error!(
1708 "Body::Stream already consumed before HTTP reply — returning 500"
1709 );
1710 let error_reply = HttpReply {
1711 status: 500,
1712 headers: vec![],
1713 body: HttpReplyBody::Bytes(bytes::Bytes::new()),
1714 };
1715 if reply_tx.send(error_reply).is_err() {
1716 debug!("reply_tx dropped before error reply could be sent");
1717 }
1718 return;
1719 }
1720 }
1721 }
1722 _ => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
1724 };
1725
1726 let resp_headers = select_response_headers(
1727 &out.input.headers,
1728 user_content_type,
1729 inferred_content_type,
1730 );
1731
1732 HttpReply {
1733 status,
1734 headers: resp_headers,
1735 body: reply_body,
1736 }
1737 }
1738 Err(e) => {
1739 pipeline_error_to_reply(e, &path_clone)
1740 }
1741 };
1742
1743 let _ = reply_tx.send(reply);
1745 });
1746 }
1747 }
1748 }
1749
1750 if let Some(method) = &self.config.method {
1755 registry_for_cleanup
1756 .unregister_rest_endpoint(method, &path)
1757 .await;
1758 } else {
1759 registry_for_cleanup.unregister_api_route(&path).await;
1760 }
1761
1762 ServerRegistry::global()
1766 .unregister(&self.config.host, self.config.port)
1767 .await;
1768
1769 Ok(())
1770 }
1771
1772 async fn stop(&mut self) -> Result<(), CamelError> {
1773 Ok(())
1774 }
1775
1776 fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
1777 camel_component_api::ConcurrencyModel::Concurrent { max: None }
1778 }
1779
1780 fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
1787 camel_component_api::ConsumerStartupMode::Explicit
1788 }
1789
1790 fn set_security_context(&mut self, ctx: camel_component_api::SecurityContext) {
1793 self.kernel = HttpKernelAuth::from_security_context(&ctx).map(Arc::new);
1794 }
1795}
1796
1797pub struct HttpComponent {
1802 config: HttpConfig,
1803}
1804
1805pub(crate) fn build_client(
1806 config: &HttpConfig,
1807 resolve_override: Option<(&str, &[std::net::SocketAddr])>,
1808) -> reqwest::Client {
1809 let mut builder = reqwest::Client::builder()
1810 .no_proxy() .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
1812 .pool_max_idle_per_host(config.pool_max_idle_per_host)
1813 .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
1814
1815 builder = builder.redirect(reqwest::redirect::Policy::none());
1819
1820 if let Some((host, addrs)) = resolve_override {
1821 builder = builder.resolve_to_addrs(host, addrs);
1822 }
1823
1824 if let Some(tls) = &config.tls
1825 && tls.enabled
1826 {
1827 if tls.insecure || !tls.verify_peer {
1828 tracing::warn!("HTTP TLS verification disabled — connections are vulnerable to MitM");
1830 builder = builder.danger_accept_invalid_certs(true);
1831 }
1832
1833 if let Some(ca_path) = &tls.ca_cert_path
1834 && let Ok(ca_bytes) = std::fs::read(ca_path)
1835 {
1836 let cert = reqwest::Certificate::from_pem(&ca_bytes)
1837 .or_else(|_| reqwest::Certificate::from_der(&ca_bytes));
1838 if let Ok(ca_cert) = cert {
1839 builder = builder.add_root_certificate(ca_cert);
1840 }
1841 }
1842
1843 if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path)
1844 && let (Ok(cert_bytes), Ok(key_bytes)) =
1845 (std::fs::read(cert_path), std::fs::read(key_path))
1846 {
1847 let mut identity_pem = cert_bytes;
1848 identity_pem.extend_from_slice(&key_bytes);
1849 if let Ok(identity) = reqwest::Identity::from_pem(&identity_pem) {
1850 builder = builder.identity(identity);
1851 }
1852 }
1853 }
1854
1855 builder
1856 .build()
1857 .expect("reqwest::Client::build() with valid config should not fail") }
1859
1860impl HttpComponent {
1861 pub fn new() -> Self {
1862 let config = HttpConfig::default();
1863 Self { config }
1864 }
1865
1866 pub fn with_config(config: HttpConfig) -> Self {
1867 Self { config }
1868 }
1869
1870 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
1871 match config {
1872 Some(cfg) => Self::with_config(cfg),
1873 None => Self::new(),
1874 }
1875 }
1876}
1877
1878impl Default for HttpComponent {
1879 fn default() -> Self {
1880 Self::new()
1881 }
1882}
1883
1884impl Component for HttpComponent {
1885 fn scheme(&self) -> &str {
1886 "http"
1887 }
1888
1889 fn metadata(&self) -> ComponentMetadata {
1890 HttpEndpointConfig::metadata()
1891 }
1892
1893 fn create_endpoint(
1894 &self,
1895 uri: &str,
1896 ctx: &dyn camel_component_api::ComponentContext,
1897 ) -> Result<Box<dyn Endpoint>, CamelError> {
1898 self.config.validate()?;
1899 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
1900 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
1901 let client = build_client(&self.config, None);
1902 let pinned_cache = std::sync::Arc::new(PinnedClientCache::new(
1903 PINNED_CLIENT_TTL,
1904 PINNED_CLIENT_MAX_ENTRIES,
1905 ));
1906 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
1907 server_config.host.clone(),
1908 server_config.port,
1909 )));
1910 Ok(Box::new(HttpEndpoint {
1911 uri: uri.to_string(),
1912 config,
1913 server_config,
1914 client,
1915 pinned_cache,
1916 http_config: self.config.clone(),
1917 }))
1918 }
1919}
1920
1921pub struct HttpsComponent {
1922 config: HttpConfig,
1923}
1924
1925impl HttpsComponent {
1926 pub fn new() -> Self {
1927 let config = HttpConfig::default();
1928 Self { config }
1929 }
1930
1931 pub fn with_config(config: HttpConfig) -> Self {
1932 Self { config }
1933 }
1934
1935 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
1936 match config {
1937 Some(cfg) => Self::with_config(cfg),
1938 None => Self::new(),
1939 }
1940 }
1941}
1942
1943impl Default for HttpsComponent {
1944 fn default() -> Self {
1945 Self::new()
1946 }
1947}
1948
1949impl Component for HttpsComponent {
1950 fn scheme(&self) -> &str {
1951 "https"
1952 }
1953
1954 fn metadata(&self) -> ComponentMetadata {
1955 let mut meta = HttpEndpointConfig::metadata();
1958 meta.scheme = "https".to_string();
1959 meta.description = "HTTPS client and server component (TLS over HTTP)".to_string();
1960 meta
1961 }
1962
1963 fn create_endpoint(
1964 &self,
1965 uri: &str,
1966 ctx: &dyn camel_component_api::ComponentContext,
1967 ) -> Result<Box<dyn Endpoint>, CamelError> {
1968 self.config.validate()?;
1969 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
1970 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
1971 let client = build_client(&self.config, None);
1972 let pinned_cache = std::sync::Arc::new(PinnedClientCache::new(
1973 PINNED_CLIENT_TTL,
1974 PINNED_CLIENT_MAX_ENTRIES,
1975 ));
1976 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
1977 server_config.host.clone(),
1978 server_config.port,
1979 )));
1980 Ok(Box::new(HttpEndpoint {
1981 uri: uri.to_string(),
1982 config,
1983 server_config,
1984 client,
1985 pinned_cache,
1986 http_config: self.config.clone(),
1987 }))
1988 }
1989}
1990
1991struct HttpEndpoint {
1996 uri: String,
1997 config: HttpEndpointConfig,
1998 server_config: HttpServerConfig,
1999 client: reqwest::Client,
2000 pinned_cache: std::sync::Arc<PinnedClientCache>,
2001 http_config: HttpConfig,
2002}
2003
2004impl Endpoint for HttpEndpoint {
2005 fn uri(&self) -> &str {
2006 &self.uri
2007 }
2008
2009 fn create_consumer(
2010 &self,
2011 rt: Arc<dyn camel_component_api::RuntimeObservability>,
2012 ) -> Result<Box<dyn Consumer>, CamelError> {
2013 let scheme_is_https = self.server_config.scheme == "https";
2016 let has_tls = self.server_config.tls_config.is_some();
2017
2018 if scheme_is_https && !has_tls {
2019 return Err(CamelError::EndpointCreationFailed(
2020 "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
2021 ));
2022 }
2023 if !scheme_is_https && has_tls {
2024 return Err(CamelError::EndpointCreationFailed(
2025 "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
2026 ));
2027 }
2028 Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
2029 }
2030
2031 fn create_producer(
2032 &self,
2033 _rt: Arc<dyn camel_component_api::RuntimeObservability>,
2034 _ctx: &ProducerContext,
2035 ) -> Result<BoxProcessor, CamelError> {
2036 let producer = HttpProducer {
2037 config: Arc::new(self.config.clone()),
2038 client: self.client.clone(),
2039 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2040 http_config: Arc::new(self.http_config.clone()),
2041 };
2042 if let Some(ref provider) = self.config.token_provider {
2043 let layer = BearerTokenLayer::new(Arc::clone(provider));
2044 Ok(BoxProcessor::new(layer.layer(producer)))
2045 } else {
2046 Ok(BoxProcessor::new(producer))
2047 }
2048 }
2049}
2050
2051#[derive(Clone)]
2056struct HttpProducer {
2057 config: Arc<HttpEndpointConfig>,
2058 client: reqwest::Client,
2059 pinned_cache: std::sync::Arc<PinnedClientCache>,
2060 http_config: Arc<HttpConfig>,
2061}
2062
2063impl HttpProducer {
2064 fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
2065 if let Some(ref method) = config.http_method {
2066 return method.to_uppercase();
2067 }
2068 if let Some(method) = exchange
2069 .input
2070 .header("CamelHttpMethod")
2071 .and_then(|v| v.as_str())
2072 {
2073 return method.to_uppercase();
2074 }
2075 if !exchange.input.body.is_empty() {
2076 return "POST".to_string();
2077 }
2078 "GET".to_string()
2079 }
2080
2081 fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
2082 if config.bridge_endpoint {
2088 let url = config.base_url.clone();
2089 if config.query_params.is_empty() {
2090 return url;
2091 }
2092 let mut parsed = url::Url::parse(&url).expect("base URL must be valid"); for (k, v) in &config.query_params {
2094 parsed.query_pairs_mut().append_pair(k, v);
2095 }
2096 return parsed.to_string();
2097 }
2098
2099 if let Some(uri) = exchange
2100 .input
2101 .header("CamelHttpUri")
2102 .and_then(|v| v.as_str())
2103 {
2104 let mut url = uri.to_string();
2105 if let Some(path) = exchange
2106 .input
2107 .header("CamelHttpPath")
2108 .and_then(|v| v.as_str())
2109 {
2110 if !url.ends_with('/') && !path.starts_with('/') {
2111 url.push('/');
2112 }
2113 url.push_str(path);
2114 }
2115 if let Some(query) = exchange
2116 .input
2117 .header("CamelHttpQuery")
2118 .and_then(|v| v.as_str())
2119 {
2120 url.push('?');
2121 url.push_str(query);
2122 }
2123 return url;
2124 }
2125
2126 let mut url = config.base_url.clone();
2127
2128 if let Some(path) = exchange
2129 .input
2130 .header("CamelHttpPath")
2131 .and_then(|v| v.as_str())
2132 {
2133 if !url.ends_with('/') && !path.starts_with('/') {
2134 url.push('/');
2135 }
2136 url.push_str(path);
2137 }
2138
2139 if let Some(query) = exchange
2140 .input
2141 .header("CamelHttpQuery")
2142 .and_then(|v| v.as_str())
2143 {
2144 url.push('?');
2145 url.push_str(query);
2146 } else if !config.query_params.is_empty() {
2147 let mut parsed = url::Url::parse(&url).expect("base URL must be valid"); for (k, v) in &config.query_params {
2149 parsed.query_pairs_mut().append_pair(k, v);
2150 }
2151 url = parsed.to_string();
2152 }
2153
2154 url
2155 }
2156
2157 fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
2158 status >= range.0 && status <= range.1
2159 }
2160
2161 fn is_entity_enclosing(method: &str) -> bool {
2166 matches!(method, "POST" | "PUT" | "PATCH")
2167 }
2168}
2169
2170impl Service<Exchange> for HttpProducer {
2171 type Response = Exchange;
2172 type Error = CamelError;
2173 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
2174
2175 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
2176 Poll::Ready(Ok(()))
2177 }
2178
2179 fn call(&mut self, mut exchange: Exchange) -> Self::Future {
2180 let config = self.config.clone();
2181 let shared_client = self.client.clone();
2182 let pinned_cache = std::sync::Arc::clone(&self.pinned_cache);
2183 let http_config = self.http_config.clone();
2184
2185 Box::pin(async move {
2186 let method_str = HttpProducer::resolve_method(&exchange, &config);
2187 let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
2192 let url = HttpProducer::resolve_url(&exchange, &config);
2193
2194 ssrf::validate_url_for_ssrf(&url, &config)?;
2196
2197 let resolved = ssrf::resolve_initial_url_for_ssrf(&url, config.allow_internal).await?;
2205 let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
2206 pinned_cache
2207 .get_or_build(host.as_str(), addrs, || {
2208 build_client(&http_config, Some((host.as_str(), addrs)))
2209 })
2210 .await
2211 } else {
2212 shared_client.clone()
2213 };
2214
2215 debug!(
2216 correlation_id = %exchange.correlation_id(),
2217 method = %method_str,
2218 url = %url,
2219 "HTTP request"
2220 );
2221
2222 let method = method_str.parse::<reqwest::Method>().map_err(|e| {
2223 CamelError::ProcessorError(format!("Invalid HTTP method '{}': {}", method_str, e))
2224 })?;
2225
2226 let mut collected_headers: Vec<(
2228 reqwest::header::HeaderName,
2229 reqwest::header::HeaderValue,
2230 )> = Vec::new();
2231
2232 if let Some(user_agent) = &config.user_agent
2233 && !config.bridge_endpoint
2234 && let Ok(val) = reqwest::header::HeaderValue::from_str(user_agent)
2235 {
2236 collected_headers.push((reqwest::header::USER_AGENT, val));
2237 }
2238
2239 #[cfg(feature = "otel")]
2241 let should_inject_otel = !config.bridge_endpoint;
2242 #[cfg(feature = "otel")]
2243 if should_inject_otel {
2244 let mut otel_headers = HashMap::new();
2245 camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
2246 for (k, v) in otel_headers {
2247 if let (Ok(name), Ok(val)) = (
2248 reqwest::header::HeaderName::from_bytes(k.as_bytes()),
2249 reqwest::header::HeaderValue::from_str(&v),
2250 ) {
2251 collected_headers.push((name, val));
2252 }
2253 }
2254 }
2255
2256 let conn_tokens = header_policy::connection_tokens(
2257 exchange
2258 .input
2259 .headers
2260 .iter()
2261 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
2262 .filter_map(|(_, v)| v.as_str()),
2263 );
2264
2265 for (key, value) in &exchange.input.headers {
2266 if !key.starts_with("Camel")
2267 && !config
2268 .skip_request_headers
2269 .iter()
2270 .any(|h| h.eq_ignore_ascii_case(key))
2271 && !header_policy::excluded_outbound(key, &conn_tokens)
2272 && let Some(val_str) = value.as_str()
2273 && let (Ok(name), Ok(val)) = (
2274 reqwest::header::HeaderName::from_bytes(key.as_bytes()),
2275 reqwest::header::HeaderValue::from_str(val_str),
2276 )
2277 {
2278 collected_headers.push((name, val));
2279 }
2280 }
2281
2282 if !config.bridge_endpoint {
2284 match &config.auth {
2285 HttpAuth::None => {}
2286 HttpAuth::Basic { username, password } => {
2287 use base64::Engine;
2288 let credentials = format!("{username}:{password}");
2290 let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
2291 if let Ok(val) =
2292 reqwest::header::HeaderValue::from_str(&format!("Basic {encoded}"))
2293 {
2294 collected_headers.push((reqwest::header::AUTHORIZATION, val));
2295 }
2296 }
2297 HttpAuth::Bearer { token } => {
2298 let bearer = format!("Bearer {token}");
2300 if let Ok(val) = reqwest::header::HeaderValue::from_str(&bearer) {
2301 collected_headers.push((reqwest::header::AUTHORIZATION, val));
2302 }
2303 }
2304 }
2305
2306 if config.connection_close
2307 && let Ok(val) = reqwest::header::HeaderValue::from_str("close")
2308 {
2309 collected_headers.push((reqwest::header::CONNECTION, val));
2310 }
2311 }
2312
2313 let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
2315 let materialized_body: Option<Vec<u8>> = if is_stream_body {
2316 if suppress_body {
2317 std::mem::take(&mut exchange.input.body);
2325 tracing::warn!(
2327 correlation_id = %exchange.correlation_id(),
2328 method = %method_str,
2329 "dropping request body for non-entity-enclosing HTTP method"
2330 );
2331 }
2332 None } else {
2334 let body = std::mem::take(&mut exchange.input.body);
2335 let bytes = body.into_bytes(config.max_body_size).await?;
2336 if bytes.is_empty() {
2337 None
2339 } else if suppress_body {
2340 tracing::warn!(
2342 correlation_id = %exchange.correlation_id(),
2343 method = %method_str,
2344 "dropping request body for non-entity-enclosing HTTP method"
2345 );
2346 None
2347 } else {
2348 Some(bytes.to_vec())
2349 }
2350 };
2351
2352 let response = if config.follow_redirects && !is_stream_body {
2353 ssrf::send_with_ssrf_safe_redirects(
2359 &client,
2360 &shared_client,
2361 &pinned_cache,
2362 &http_config,
2363 &config,
2364 method,
2365 &url,
2366 collected_headers,
2367 materialized_body,
2368 config.max_redirects,
2369 config.response_timeout,
2370 )
2371 .await?
2372 } else {
2373 let mut request = client.request(method, &url);
2375
2376 if let Some(timeout) = config.response_timeout {
2377 request = request.timeout(timeout);
2378 }
2379
2380 for (name, value) in &collected_headers {
2381 request = request.header(name, value);
2382 }
2383
2384 if is_stream_body {
2385 if let Body::Stream(ref s) = exchange.input.body {
2386 let mut stream_lock = s.stream.lock().await;
2387 if let Some(stream) = stream_lock.take() {
2388 request = request.body(reqwest::Body::wrap_stream(stream));
2389 } else {
2390 return Err(CamelError::AlreadyConsumed);
2391 }
2392 }
2393 } else if let Some(ref body_bytes) = materialized_body {
2394 request = request.body(body_bytes.clone());
2395 }
2396
2397 request
2398 .send()
2399 .await
2400 .map_err(|e| CamelError::ProcessorError(format!("HTTP request failed: {e}")))?
2401 };
2402
2403 let status_code = response.status().as_u16();
2404 let status_text = response
2405 .status()
2406 .canonical_reason()
2407 .unwrap_or("Unknown")
2408 .to_string();
2409
2410 for (key, value) in response.headers() {
2411 if config
2412 .skip_response_headers
2413 .iter()
2414 .any(|h| h.eq_ignore_ascii_case(key.as_str()))
2415 {
2416 continue;
2417 }
2418 if let Ok(val_str) = value.to_str() {
2419 exchange.input.set_header(
2420 title_case_header(key.as_str()),
2421 serde_json::Value::String(val_str.to_string()),
2422 );
2423 }
2424 }
2425
2426 exchange.input.set_header(
2427 "CamelHttpResponseCode",
2428 serde_json::Value::Number(status_code.into()),
2429 );
2430 exchange.input.set_header(
2431 "CamelHttpResponseText",
2432 serde_json::Value::String(status_text.clone()),
2433 );
2434
2435 let read_timeout = Duration::from_millis(config.read_timeout_ms);
2437 let response_body = tokio::time::timeout(read_timeout, async {
2438 if let Some(content_len) = response.content_length()
2440 && content_len > config.max_response_bytes as u64
2441 {
2442 return Err(CamelError::ProcessorError(format!(
2443 "Response body too large: {} bytes exceeds limit of {} bytes",
2444 content_len, config.max_response_bytes
2445 )));
2446 }
2447 use futures::TryStreamExt;
2449 let mut stream = response.bytes_stream();
2450 let mut total: usize = 0;
2451 let mut collected = Vec::new();
2452 while let Some(chunk) = stream.try_next().await.map_err(|e| {
2453 CamelError::ProcessorError(format!("Failed to read response body: {e}"))
2454 })? {
2455 total += chunk.len();
2456 if total > config.max_response_bytes {
2457 return Err(CamelError::ProcessorError(format!(
2458 "Response body too large: {} bytes exceeds limit of {} bytes",
2459 total, config.max_response_bytes
2460 )));
2461 }
2462 collected.push(chunk);
2463 }
2464 let mut result = bytes::BytesMut::with_capacity(total);
2465 for chunk in collected {
2466 result.extend_from_slice(&chunk);
2467 }
2468 Ok::<bytes::Bytes, CamelError>(result.freeze())
2469 })
2470 .await
2471 .map_err(|_| {
2472 CamelError::ProcessorError(format!(
2473 "Read timeout after {}ms",
2474 config.read_timeout_ms
2475 ))
2476 })??;
2477
2478 if config.throw_exception_on_failure
2479 && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
2480 {
2481 return Err(CamelError::HttpOperationFailed {
2482 method: method_str,
2483 url,
2484 status_code,
2485 status_text,
2486 response_body: Some(String::from_utf8_lossy(&response_body).to_string()),
2487 });
2488 }
2489
2490 if !response_body.is_empty() {
2491 exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
2492 }
2493
2494 debug!(
2495 correlation_id = %exchange.correlation_id(),
2496 status = status_code,
2497 url = %url,
2498 "HTTP response"
2499 );
2500 Ok(exchange)
2501 })
2502 }
2503}
2504
2505#[cfg(test)]
2515pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
2516
2517fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
2525 match e {
2526 CamelError::Unauthenticated(msg) => {
2527 tracing::warn!(error = %msg, path = %path, "Authentication failed");
2528 HttpReply {
2529 status: 401,
2530 headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
2531 body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
2532 }
2533 }
2534 CamelError::Unauthorized(msg) => {
2535 tracing::warn!(error = %msg, path = %path, "Authorization failed");
2536 HttpReply {
2537 status: 403,
2538 headers: vec![],
2539 body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
2540 }
2541 }
2542 CamelError::TypeConversionFailed(msg) => {
2543 tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
2544 let body = serde_json::to_string(&serde_json::json!({
2545 "error": "bad_request",
2546 "message": msg,
2547 }))
2548 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
2550 status: 400,
2551 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
2552 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
2553 }
2554 }
2555 CamelError::ValidationError(msg) => {
2556 tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
2557 let body = serde_json::to_string(&serde_json::json!({
2558 "error": "validation_error",
2559 "message": msg,
2560 }))
2561 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
2563 status: 400,
2564 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
2565 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
2566 }
2567 }
2568 CamelError::ConsumerStopping => {
2569 tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
2570 HttpReply {
2571 status: 503,
2572 headers: vec![],
2573 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
2574 }
2575 }
2576 e => {
2577 tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
2579 HttpReply {
2580 status: 500,
2581 headers: vec![],
2582 body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
2583 }
2584 }
2585 }
2586}
2587
2588fn select_response_headers(
2598 headers: &HashMap<String, serde_json::Value>,
2599 user_content_type: Option<String>,
2600 inferred_content_type: Option<String>,
2601) -> Vec<(String, String)> {
2602 let conn_tokens = header_policy::connection_tokens(
2603 headers
2604 .iter()
2605 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
2606 .filter_map(|(_, v)| v.as_str()),
2607 );
2608 let mut selected: Vec<(String, String)> = headers
2609 .iter()
2610 .filter(|(k, _)| !k.starts_with("Camel"))
2611 .filter(|(k, _)| !header_policy::excluded_response(k, &conn_tokens))
2612 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
2613 .collect();
2614 if let Some(ct) = user_content_type.or(inferred_content_type) {
2615 selected.push(("Content-Type".to_string(), ct));
2616 }
2617 selected
2618}
2619
2620#[cfg(test)]
2621mod tests {
2622 use camel_component_api::test_support::{NoopRuntimeObservability, PanicRuntimeObservability};
2623 fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2624 std::sync::Arc::new(PanicRuntimeObservability)
2625 }
2626 fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2627 std::sync::Arc::new(PanicRuntimeObservability)
2628 }
2629 fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2630 std::sync::Arc::new(NoopRuntimeObservability)
2631 }
2632
2633 use super::*;
2634 use crate::rest_match::PathSegment;
2635 use camel_component_api::{Message, NoOpComponentContext};
2636 use std::sync::Arc;
2637 use std::time::Duration;
2638
2639 fn test_producer_ctx() -> ProducerContext {
2640 ProducerContext::new()
2641 }
2642
2643 #[test]
2644 fn test_http_config_defaults() {
2645 let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
2646 assert_eq!(config.base_url, "http://localhost:8080/api");
2647 assert!(config.http_method.is_none());
2648 assert!(config.throw_exception_on_failure);
2649 assert_eq!(config.ok_status_code_range, (200, 299));
2650 assert!(config.response_timeout.is_none());
2651 assert!(matches!(config.auth, HttpAuth::None));
2652 assert!(!config.bridge_endpoint);
2653 assert!(!config.connection_close);
2654 }
2655
2656 #[test]
2657 fn test_http_config_scheme() {
2658 assert_eq!(HttpEndpointConfig::scheme(), "http");
2660 }
2661
2662 #[test]
2663 fn test_http_config_from_components() {
2664 let components = camel_component_api::UriComponents {
2666 scheme: "https".to_string(),
2667 path: "//api.example.com/v1".to_string(),
2668 params: std::collections::HashMap::from([(
2669 "httpMethod".to_string(),
2670 "POST".to_string(),
2671 )]),
2672 };
2673 let config = HttpEndpointConfig::from_components(components).unwrap();
2674 assert_eq!(config.base_url, "https://api.example.com/v1");
2675 assert_eq!(config.http_method, Some("POST".to_string()));
2676 }
2677
2678 #[test]
2679 fn test_http_config_with_options() {
2680 let config = HttpEndpointConfig::from_uri(
2681 "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
2682 ).unwrap();
2683 assert_eq!(config.base_url, "https://api.example.com/v1");
2684 assert_eq!(config.http_method, Some("PUT".to_string()));
2685 assert!(!config.throw_exception_on_failure);
2686 assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
2687 }
2688
2689 #[test]
2690 fn test_http_endpoint_config_auth_and_headers_options() {
2691 let config = HttpEndpointConfig::from_uri(
2692 "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
2693 )
2694 .unwrap();
2695
2696 assert!(matches!(
2697 config.auth,
2698 HttpAuth::Basic { username, password } if username == "u" && password == "p"
2699 ));
2700 assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
2701 assert!(config.bridge_endpoint);
2702 assert!(config.connection_close);
2703 assert_eq!(
2704 config.skip_request_headers,
2705 vec!["authorization".to_string(), "x-secret".to_string()]
2706 );
2707 assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
2708 }
2709
2710 #[test]
2711 fn test_http_endpoint_config_bearer_auth() {
2712 let config = HttpEndpointConfig::from_uri(
2713 "http://localhost/api?authMethod=Bearer&authBearerToken=t",
2714 )
2715 .unwrap();
2716 assert!(matches!(
2717 config.auth,
2718 HttpAuth::Bearer { token } if token == "t"
2719 ));
2720 }
2721
2722 #[test]
2723 fn rejects_cookie_handling_inmemory() {
2724 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
2725 match result {
2726 Err(CamelError::InvalidUri(msg)) => {
2727 assert!(
2728 msg.contains("cookieHandling is not supported"),
2729 "expected rejection message, got: {msg}"
2730 );
2731 }
2732 other => panic!("expected InvalidUri error, got: {other:?}"),
2733 }
2734 }
2735
2736 #[test]
2737 fn rejects_cookie_handling_disabled() {
2738 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
2739 match result {
2740 Err(CamelError::InvalidUri(msg)) => {
2741 assert!(
2742 msg.contains("cookieHandling is not supported"),
2743 "expected rejection message, got: {msg}"
2744 );
2745 }
2746 other => panic!("expected InvalidUri error, got: {other:?}"),
2747 }
2748 }
2749
2750 #[test]
2751 fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
2752 let config = HttpConfig::default()
2753 .with_response_timeout_ms(999)
2754 .with_allow_internal(true)
2755 .with_blocked_hosts(vec!["evil.com".to_string()])
2756 .with_max_body_size(12345);
2757 let endpoint =
2758 HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
2759 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
2760 assert!(endpoint.allow_internal);
2761 assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
2762 assert_eq!(endpoint.max_body_size, 12345);
2763 }
2764
2765 #[test]
2766 fn test_from_uri_with_defaults_uri_overrides_config() {
2767 let config = HttpConfig::default()
2768 .with_response_timeout_ms(999)
2769 .with_allow_internal(true)
2770 .with_blocked_hosts(vec!["evil.com".to_string()])
2771 .with_max_body_size(12345);
2772 let endpoint = HttpEndpointConfig::from_uri_with_defaults(
2773 "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
2774 &config,
2775 )
2776 .unwrap();
2777 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
2778 assert!(!endpoint.allow_internal);
2779 assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
2780 assert_eq!(endpoint.max_body_size, 99);
2781 }
2782
2783 #[test]
2784 fn test_http_config_ok_status_range() {
2785 let config =
2786 HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
2787 assert_eq!(config.ok_status_code_range, (200, 204));
2788 }
2789
2790 #[test]
2791 fn test_http_config_wrong_scheme() {
2792 let result = HttpEndpointConfig::from_uri("file:/tmp");
2793 assert!(result.is_err());
2794 }
2795
2796 #[test]
2797 fn test_http_component_scheme() {
2798 let component = HttpComponent::new();
2799 assert_eq!(component.scheme(), "http");
2800 }
2801
2802 #[test]
2803 fn test_https_component_scheme() {
2804 let component = HttpsComponent::new();
2805 assert_eq!(component.scheme(), "https");
2806 }
2807
2808 #[test]
2809 fn test_http_endpoint_creates_consumer() {
2810 let component = HttpComponent::new();
2811 let ctx = NoOpComponentContext;
2812 let endpoint = component
2813 .create_endpoint("http://0.0.0.0:19100/test", &ctx)
2814 .unwrap();
2815 assert!(endpoint.create_consumer(rt()).is_ok());
2816 }
2817
2818 #[test]
2819 fn test_https_endpoint_creates_consumer_errors_without_tls() {
2820 let component = HttpsComponent::new();
2821 let ctx = NoOpComponentContext;
2822 let endpoint = component
2823 .create_endpoint("https://0.0.0.0:8443/test", &ctx)
2824 .unwrap();
2825 assert!(endpoint.create_consumer(rt()).is_err());
2827 }
2828
2829 #[test]
2830 fn test_http_endpoint_creates_producer() {
2831 let ctx = test_producer_ctx();
2832 let component = HttpComponent::new();
2833 let endpoint_ctx = NoOpComponentContext;
2834 let endpoint = component
2835 .create_endpoint("http://localhost/api", &endpoint_ctx)
2836 .unwrap();
2837 assert!(endpoint.create_producer(rt(), &ctx).is_ok());
2838 }
2839
2840 #[tokio::test]
2845 async fn test_producer_with_token_provider() {
2846 use camel_auth::oauth2::TokenProvider;
2847 use tower::ServiceExt;
2848
2849 let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
2850 Arc::new(std::sync::Mutex::new(None));
2851 let captured_clone = Arc::clone(&captured_auth);
2852
2853 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2854 let port = listener.local_addr().unwrap().port();
2855
2856 let _handle = tokio::spawn(async move {
2857 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2858 if let Ok((mut stream, _)) = listener.accept().await {
2859 let mut buf = vec![0u8; 8192];
2860 let n = stream.read(&mut buf).await.unwrap_or(0);
2861 let request = String::from_utf8_lossy(&buf[..n]).to_string();
2862 let auth = request
2863 .lines()
2864 .find(|l| l.to_lowercase().starts_with("authorization:"))
2865 .map(|l| {
2866 l.split(':')
2867 .nth(1)
2868 .map(|s| s.trim().to_string())
2869 .unwrap_or_default()
2870 });
2871 *captured_clone.lock().unwrap() = auth;
2872 let body = r#"{"echo":"ok"}"#;
2873 let resp = format!(
2874 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
2875 body.len(),
2876 body
2877 );
2878 let _ = stream.write_all(resp.as_bytes()).await;
2879 }
2880 });
2881
2882 #[derive(Debug)]
2883 struct StaticProvider;
2884 #[async_trait::async_trait]
2885 impl TokenProvider for StaticProvider {
2886 async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
2887 Ok("injected-token".into())
2888 }
2889 }
2890
2891 let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
2892 let ctx = test_producer_ctx();
2893 let component = HttpComponent::new();
2894 let endpoint_ctx = NoOpComponentContext;
2895 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
2896 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2897
2898 let exchange = Exchange::new(Message::new("hello"));
2899
2900 let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
2901 let mut layered = layer.layer(producer);
2902 let result = layered.ready().await.unwrap().call(exchange).await;
2903 assert!(result.is_ok(), "producer call failed: {:?}", result);
2904
2905 tokio::time::sleep(Duration::from_millis(100)).await;
2906 let auth = captured_auth.lock().unwrap().take();
2907 assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
2908 }
2909
2910 async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
2911 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2912 let addr = listener.local_addr().unwrap();
2913 let url = format!("http://127.0.0.1:{}", addr.port());
2914
2915 let handle = tokio::spawn(async move {
2916 loop {
2917 if let Ok((mut stream, _)) = listener.accept().await {
2918 tokio::spawn(async move {
2919 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2920 let mut buf = vec![0u8; 4096];
2921 let n = stream.read(&mut buf).await.unwrap_or(0);
2922 let request = String::from_utf8_lossy(&buf[..n]).to_string();
2923
2924 let method = request.split_whitespace().next().unwrap_or("GET");
2925
2926 let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
2927 let response = format!(
2928 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
2929 body.len(),
2930 body
2931 );
2932 let _ = stream.write_all(response.as_bytes()).await;
2933 });
2934 }
2935 }
2936 });
2937
2938 (url, handle)
2939 }
2940
2941 async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
2942 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2943 let addr = listener.local_addr().unwrap();
2944 let url = format!("http://127.0.0.1:{}", addr.port());
2945
2946 let handle = tokio::spawn(async move {
2947 loop {
2948 if let Ok((mut stream, _)) = listener.accept().await {
2949 let status = status;
2950 tokio::spawn(async move {
2951 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2952 let mut buf = vec![0u8; 4096];
2953 let _ = stream.read(&mut buf).await;
2954
2955 let status_text = match status {
2956 404 => "Not Found",
2957 500 => "Internal Server Error",
2958 _ => "Error",
2959 };
2960 let body = "error body";
2961 let response = format!(
2962 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
2963 status,
2964 status_text,
2965 body.len(),
2966 body
2967 );
2968 let _ = stream.write_all(response.as_bytes()).await;
2969 });
2970 }
2971 }
2972 });
2973
2974 (url, handle)
2975 }
2976
2977 async fn start_request_capturing_server() -> (
2978 String,
2979 Arc<std::sync::Mutex<Option<String>>>,
2980 tokio::task::JoinHandle<()>,
2981 ) {
2982 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2983 let port = listener.local_addr().unwrap().port();
2984 let url = format!("http://127.0.0.1:{port}");
2985 let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
2986 let captured_clone = Arc::clone(&captured);
2987 let handle = tokio::spawn(async move {
2988 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2989 if let Ok((mut stream, _)) = listener.accept().await {
2990 let mut buf = vec![0u8; 16384];
2991 let n = stream.read(&mut buf).await.unwrap_or(0);
2992 let request = String::from_utf8_lossy(&buf[..n]).to_string();
2993 if request.contains("\r\n\r\n") {
2994 *captured_clone.lock().unwrap() = Some(request);
2995 }
2996 let body = r#"{"echo":"ok"}"#;
2997 let resp = format!(
2998 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
2999 body.len(),
3000 body
3001 );
3002 let _ = stream.write_all(resp.as_bytes()).await;
3003 }
3004 });
3005 (url, captured, handle)
3006 }
3007
3008 #[tokio::test]
3009 async fn test_http_producer_get_request() {
3010 use tower::ServiceExt;
3011
3012 let (url, _handle) = start_test_server().await;
3013 let ctx = test_producer_ctx();
3014
3015 let component = HttpComponent::new();
3016 let endpoint_ctx = NoOpComponentContext;
3017 let endpoint = component
3018 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
3019 .unwrap();
3020 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3021
3022 let exchange = Exchange::new(Message::default());
3023 let result = producer.oneshot(exchange).await.unwrap();
3024
3025 let status = result
3026 .input
3027 .header("CamelHttpResponseCode")
3028 .and_then(|v| v.as_u64())
3029 .unwrap();
3030 assert_eq!(status, 200);
3031
3032 assert!(!result.input.body.is_empty());
3033 }
3034
3035 #[tokio::test]
3036 async fn producer_excludes_host_and_framing() {
3037 use tower::ServiceExt;
3038
3039 let (url, captured, _handle) = start_request_capturing_server().await;
3040 let ctx = test_producer_ctx();
3041 let component = HttpComponent::new();
3042 let endpoint_ctx = NoOpComponentContext;
3043 let endpoint = component
3044 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
3045 .unwrap();
3046 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3047
3048 let mut exchange = Exchange::new(Message::default());
3049 exchange.input.set_header("Host", "localhost");
3050 exchange.input.set_header("Content-Length", "42");
3051 exchange.input.set_header("Connection", "keep-alive");
3052 exchange.input.set_header("Upgrade", "h2c");
3053
3054 let result = producer.oneshot(exchange).await;
3055 assert!(result.is_ok(), "producer call failed: {:?}", result);
3056
3057 tokio::time::sleep(Duration::from_millis(100)).await;
3058 let request = captured
3059 .lock()
3060 .unwrap()
3061 .take()
3062 .expect("no outbound request captured");
3063 let lower = request.to_ascii_lowercase();
3064 assert!(
3065 !lower.contains("\r\nhost: localhost"),
3066 "forwarded Host: localhost must be stripped\n{request}"
3067 );
3068 assert!(
3069 !lower.contains("content-length: 42"),
3070 "exchange Content-Length must not be copied\n{request}"
3071 );
3072 assert!(
3073 !lower.lines().any(|l| l.starts_with("connection:")),
3074 "Connection header must not be forwarded\n{request}"
3075 );
3076 assert!(
3077 !lower.lines().any(|l| l.starts_with("upgrade:")),
3078 "Upgrade header must not be forwarded\n{request}"
3079 );
3080 let host_header = lower
3081 .lines()
3082 .find(|l| l.starts_with("host:"))
3083 .map(|l| l.split_once(':').map(|(_, v)| v).unwrap_or("").trim())
3084 .expect("outbound Host header must be set by reqwest");
3085 assert!(
3086 host_header.starts_with("127.0.0.1:"),
3087 "outbound Host '{host_header}' must match the capture-server address"
3088 );
3089 }
3090
3091 #[tokio::test]
3092 async fn producer_forwards_request_only_headers() {
3093 use tower::ServiceExt;
3094
3095 let (url, captured, _handle) = start_request_capturing_server().await;
3096 let ctx = test_producer_ctx();
3097 let component = HttpComponent::new();
3098 let endpoint_ctx = NoOpComponentContext;
3099 let endpoint = component
3100 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
3101 .unwrap();
3102 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3103
3104 let mut exchange = Exchange::new(Message::default());
3105 exchange.input.set_header("Accept", "application/json");
3106 exchange.input.set_header("User-Agent", "myclient/1.0");
3107
3108 let result = producer.oneshot(exchange).await;
3109 assert!(result.is_ok(), "producer call failed: {:?}", result);
3110
3111 tokio::time::sleep(Duration::from_millis(100)).await;
3112 let request = captured
3113 .lock()
3114 .unwrap()
3115 .take()
3116 .expect("no outbound request captured");
3117 let lower = request.to_ascii_lowercase();
3118 assert!(
3119 lower.contains("accept: application/json"),
3120 "request-only Accept header must be forwarded\n{request}"
3121 );
3122 assert!(
3123 lower.contains("user-agent: myclient/1.0"),
3124 "request-only User-Agent header must be forwarded\n{request}"
3125 );
3126 }
3127
3128 #[tokio::test]
3129 async fn producer_honours_skip_request_headers() {
3130 use tower::ServiceExt;
3131
3132 let (url, captured, _handle) = start_request_capturing_server().await;
3133 let ctx = test_producer_ctx();
3134 let component = HttpComponent::new();
3135 let endpoint_ctx = NoOpComponentContext;
3136 let endpoint = component
3137 .create_endpoint(
3138 &format!("{url}/api/test?allowInternal=true&skipRequestHeaders=Authorization"),
3139 &endpoint_ctx,
3140 )
3141 .unwrap();
3142 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3143
3144 let mut exchange = Exchange::new(Message::default());
3145 exchange.input.set_header("Authorization", "Bearer x");
3146
3147 let result = producer.oneshot(exchange).await;
3148 assert!(result.is_ok(), "producer call failed: {:?}", result);
3149
3150 tokio::time::sleep(Duration::from_millis(100)).await;
3151 let request = captured
3152 .lock()
3153 .unwrap()
3154 .take()
3155 .expect("no outbound request captured");
3156 assert!(
3157 !request.to_ascii_lowercase().contains("authorization"),
3158 "Authorization must be stripped by skipRequestHeaders\n{request}"
3159 );
3160 }
3161
3162 #[tokio::test]
3163 async fn test_http_producer_post_with_body() {
3164 use tower::ServiceExt;
3165
3166 let (url, _handle) = start_test_server().await;
3167 let ctx = test_producer_ctx();
3168
3169 let component = HttpComponent::new();
3170 let endpoint_ctx = NoOpComponentContext;
3171 let endpoint = component
3172 .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
3173 .unwrap();
3174 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3175
3176 let exchange = Exchange::new(Message::new("request body"));
3177 let result = producer.oneshot(exchange).await.unwrap();
3178
3179 let status = result
3180 .input
3181 .header("CamelHttpResponseCode")
3182 .and_then(|v| v.as_u64())
3183 .unwrap();
3184 assert_eq!(status, 200);
3185 }
3186
3187 #[tokio::test]
3188 async fn test_http_producer_method_from_header() {
3189 use tower::ServiceExt;
3190
3191 let (url, _handle) = start_test_server().await;
3192 let ctx = test_producer_ctx();
3193
3194 let component = HttpComponent::new();
3195 let endpoint_ctx = NoOpComponentContext;
3196 let endpoint = component
3197 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
3198 .unwrap();
3199 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3200
3201 let mut exchange = Exchange::new(Message::default());
3202 exchange.input.set_header(
3203 "CamelHttpMethod",
3204 serde_json::Value::String("DELETE".to_string()),
3205 );
3206
3207 let result = producer.oneshot(exchange).await.unwrap();
3208 let status = result
3209 .input
3210 .header("CamelHttpResponseCode")
3211 .and_then(|v| v.as_u64())
3212 .unwrap();
3213 assert_eq!(status, 200);
3214 }
3215
3216 #[tokio::test]
3217 async fn test_http_producer_forced_method() {
3218 use tower::ServiceExt;
3219
3220 let (url, _handle) = start_test_server().await;
3221 let ctx = test_producer_ctx();
3222
3223 let component = HttpComponent::new();
3224 let endpoint_ctx = NoOpComponentContext;
3225 let endpoint = component
3226 .create_endpoint(
3227 &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
3228 &endpoint_ctx,
3229 )
3230 .unwrap();
3231 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3232
3233 let exchange = Exchange::new(Message::default());
3234 let result = producer.oneshot(exchange).await.unwrap();
3235
3236 let status = result
3237 .input
3238 .header("CamelHttpResponseCode")
3239 .and_then(|v| v.as_u64())
3240 .unwrap();
3241 assert_eq!(status, 200);
3242 }
3243
3244 #[tokio::test]
3245 async fn test_http_producer_throw_exception_on_failure() {
3246 use tower::ServiceExt;
3247
3248 let (url, _handle) = start_status_server(404).await;
3249 let ctx = test_producer_ctx();
3250
3251 let component = HttpComponent::new();
3252 let endpoint_ctx = NoOpComponentContext;
3253 let endpoint = component
3254 .create_endpoint(
3255 &format!("{url}/not-found?allowInternal=true"),
3256 &endpoint_ctx,
3257 )
3258 .unwrap();
3259 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3260
3261 let exchange = Exchange::new(Message::default());
3262 let result = producer.oneshot(exchange).await;
3263 assert!(result.is_err());
3264
3265 match result.unwrap_err() {
3266 CamelError::HttpOperationFailed { status_code, .. } => {
3267 assert_eq!(status_code, 404);
3268 }
3269 e => panic!("Expected HttpOperationFailed, got: {e}"),
3270 }
3271 }
3272
3273 #[tokio::test]
3274 async fn test_http_producer_no_throw_on_failure() {
3275 use tower::ServiceExt;
3276
3277 let (url, _handle) = start_status_server(500).await;
3278 let ctx = test_producer_ctx();
3279
3280 let component = HttpComponent::new();
3281 let endpoint_ctx = NoOpComponentContext;
3282 let endpoint = component
3283 .create_endpoint(
3284 &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
3285 &endpoint_ctx,
3286 )
3287 .unwrap();
3288 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3289
3290 let exchange = Exchange::new(Message::default());
3291 let result = producer.oneshot(exchange).await.unwrap();
3292
3293 let status = result
3294 .input
3295 .header("CamelHttpResponseCode")
3296 .and_then(|v| v.as_u64())
3297 .unwrap();
3298 assert_eq!(status, 500);
3299 }
3300
3301 #[tokio::test]
3302 async fn test_http_producer_uri_override() {
3303 use tower::ServiceExt;
3304
3305 let (url, _handle) = start_test_server().await;
3306 let ctx = test_producer_ctx();
3307
3308 let component = HttpComponent::new();
3309 let endpoint_ctx = NoOpComponentContext;
3310 let endpoint = component
3311 .create_endpoint(
3312 "http://localhost:1/does-not-exist?allowInternal=true",
3313 &endpoint_ctx,
3314 )
3315 .unwrap();
3316 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3317
3318 let mut exchange = Exchange::new(Message::default());
3319 exchange.input.set_header(
3320 "CamelHttpUri",
3321 serde_json::Value::String(format!("{url}/api")),
3322 );
3323
3324 let result = producer.oneshot(exchange).await.unwrap();
3325 let status = result
3326 .input
3327 .header("CamelHttpResponseCode")
3328 .and_then(|v| v.as_u64())
3329 .unwrap();
3330 assert_eq!(status, 200);
3331 }
3332
3333 #[tokio::test]
3334 async fn test_http_producer_response_headers_mapped() {
3335 use tower::ServiceExt;
3336
3337 let (url, _handle) = start_test_server().await;
3338 let ctx = test_producer_ctx();
3339
3340 let component = HttpComponent::new();
3341 let endpoint_ctx = NoOpComponentContext;
3342 let endpoint = component
3343 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
3344 .unwrap();
3345 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3346
3347 let exchange = Exchange::new(Message::default());
3348 let result = producer.oneshot(exchange).await.unwrap();
3349
3350 assert!(
3351 result.input.header("Content-Type").is_some(),
3352 "Response should have Content-Type header"
3353 );
3354 assert!(result.input.header("CamelHttpResponseText").is_some());
3355 }
3356
3357 async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
3362 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3363 let addr = listener.local_addr().unwrap();
3364 let url = format!("http://127.0.0.1:{}", addr.port());
3365
3366 let handle = tokio::spawn(async move {
3367 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3368 loop {
3369 if let Ok((mut stream, _)) = listener.accept().await {
3370 tokio::spawn(async move {
3371 let mut buf = vec![0u8; 4096];
3372 let n = stream.read(&mut buf).await.unwrap_or(0);
3373 let request = String::from_utf8_lossy(&buf[..n]).to_string();
3374
3375 if request.contains("GET /final") {
3377 let body = r#"{"status":"final"}"#;
3378 let response = format!(
3379 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
3380 body.len(),
3381 body
3382 );
3383 let _ = stream.write_all(response.as_bytes()).await;
3384 } else {
3385 let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\n\r\n";
3387 let _ = stream.write_all(response.as_bytes()).await;
3388 }
3389 });
3390 }
3391 }
3392 });
3393
3394 (url, handle)
3395 }
3396
3397 struct CapturedRequest {
3398 method: String,
3399 path: String,
3400 body: Vec<u8>,
3401 content_length: Option<String>,
3402 transfer_encoding: Option<String>,
3403 }
3404
3405 async fn capture_request(stream: &mut tokio::net::TcpStream) -> Option<CapturedRequest> {
3411 use tokio::io::AsyncReadExt;
3412
3413 let mut buf: Vec<u8> = Vec::new();
3415 let mut chunk = [0u8; 4096];
3416 let head_end: usize;
3417 loop {
3418 let n = stream.read(&mut chunk).await.unwrap_or(0);
3419 if n == 0 {
3420 return None;
3421 }
3422 buf.extend_from_slice(&chunk[..n]);
3423 if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
3424 head_end = pos + 4;
3425 break;
3426 }
3427 }
3428
3429 let head = String::from_utf8_lossy(&buf[..head_end]);
3431 let mut lines = head.split("\r\n");
3432 let request_line = lines.next().unwrap_or("");
3433 let mut parts = request_line.split_whitespace();
3434 let method = parts.next().unwrap_or("").to_string();
3435 let path = parts.next().unwrap_or("").to_string();
3436
3437 let mut content_length: Option<String> = None;
3438 let mut transfer_encoding: Option<String> = None;
3439 for line in lines {
3440 if let Some((name, value)) = line.split_once(':') {
3441 let name = name.trim().to_ascii_lowercase();
3442 let value = value.trim().to_string();
3443 if name == "content-length" {
3444 content_length = Some(value);
3445 } else if name == "transfer-encoding" {
3446 transfer_encoding = Some(value);
3447 }
3448 }
3449 }
3450
3451 let body_len: usize = content_length
3453 .as_deref()
3454 .and_then(|v| v.parse::<usize>().ok())
3455 .unwrap_or(0);
3456
3457 let mut body: Vec<u8> = buf[head_end..].to_vec();
3458 while body.len() < body_len {
3459 let n = stream.read(&mut chunk).await.unwrap_or(0);
3460 if n == 0 {
3461 break;
3462 }
3463 body.extend_from_slice(&chunk[..n]);
3464 }
3465 body.truncate(body_len);
3466
3467 Some(CapturedRequest {
3468 method,
3469 path,
3470 body,
3471 content_length,
3472 transfer_encoding,
3473 })
3474 }
3475
3476 async fn start_capture_server() -> (
3481 String,
3482 tokio::task::JoinHandle<()>,
3483 Arc<Mutex<Vec<CapturedRequest>>>,
3484 ) {
3485 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3486 let addr = listener.local_addr().unwrap();
3487 let url = format!("http://127.0.0.1:{}", addr.port());
3488
3489 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
3490 let captured_for_return = Arc::clone(&captured);
3491
3492 let handle = tokio::spawn(async move {
3493 use tokio::io::AsyncWriteExt;
3494 loop {
3495 if let Ok((mut stream, _)) = listener.accept().await {
3496 let captured = Arc::clone(&captured);
3497 tokio::spawn(async move {
3498 let Some(req) = capture_request(&mut stream).await else {
3499 return;
3500 };
3501 captured.lock().unwrap().push(req);
3502
3503 let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
3506 let _ = stream.write_all(response.as_bytes()).await;
3507 });
3508 }
3509 }
3510 });
3511
3512 (url, handle, captured_for_return)
3513 }
3514
3515 async fn start_redirect_capture_server() -> (
3521 String,
3522 tokio::task::JoinHandle<()>,
3523 Arc<Mutex<Vec<CapturedRequest>>>,
3524 ) {
3525 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3526 let addr = listener.local_addr().unwrap();
3527 let url = format!("http://127.0.0.1:{}", addr.port());
3528
3529 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
3530 let captured_for_return = Arc::clone(&captured);
3531
3532 let handle = tokio::spawn(async move {
3533 use tokio::io::AsyncWriteExt;
3534 loop {
3535 if let Ok((mut stream, _)) = listener.accept().await {
3536 let captured = Arc::clone(&captured);
3537 tokio::spawn(async move {
3538 let Some(req) = capture_request(&mut stream).await else {
3539 return;
3540 };
3541 let path = req.path.clone();
3542 captured.lock().unwrap().push(req);
3543
3544 let (status_line, location) = match path.as_str() {
3545 "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
3546 "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
3547 "/final" => ("HTTP/1.1 200 OK", None),
3548 _ => ("HTTP/1.1 404 Not Found", None),
3549 };
3550
3551 let response = match location {
3552 Some(loc) => format!(
3553 "{status_line}\r\nLocation: {loc}\r\nContent-Length: 0\r\n\r\n"
3554 ),
3555 None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
3556 };
3557 let _ = stream.write_all(response.as_bytes()).await;
3558 });
3559 }
3560 }
3561 });
3562
3563 (url, handle, captured_for_return)
3564 }
3565
3566 #[tokio::test]
3567 async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
3568 use tower::ServiceExt;
3569
3570 let (url, _handle, captured) = start_capture_server().await;
3571 let ctx = test_producer_ctx();
3572
3573 let component = HttpComponent::with_config(HttpConfig::default());
3574 let endpoint_ctx = NoOpComponentContext;
3575 let endpoint = component
3576 .create_endpoint(
3577 &format!("{url}?httpMethod=GET&allowInternal=true"),
3578 &endpoint_ctx,
3579 )
3580 .unwrap();
3581 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3582
3583 let mut exchange = Exchange::new(Message::default());
3584 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3585
3586 let result = producer.oneshot(exchange).await.unwrap();
3587
3588 let status = result
3589 .input
3590 .header("CamelHttpResponseCode")
3591 .and_then(|v| v.as_u64())
3592 .unwrap();
3593 assert_eq!(status, 200);
3594
3595 let captured = captured.lock().unwrap();
3596 assert_eq!(captured.len(), 1, "expected exactly one captured request");
3597 let req = &captured[0];
3598 assert_eq!(req.method, "GET");
3599 assert_eq!(req.path, "/");
3602 assert!(req.body.is_empty(), "GET must not carry a body");
3603 assert!(
3604 req.content_length.is_none(),
3605 "suppressed request must not carry Content-Length"
3606 );
3607 assert!(
3608 req.transfer_encoding.is_none(),
3609 "suppressed request must not carry Transfer-Encoding"
3610 );
3611
3612 assert!(
3614 result.input.body.is_empty(),
3615 "exchange body must be consumed"
3616 );
3617 }
3618
3619 #[tokio::test]
3620 async fn test_head_with_body_suppressed_via_header() {
3621 use tower::ServiceExt;
3622
3623 let (url, _handle, captured) = start_capture_server().await;
3624 let ctx = test_producer_ctx();
3625
3626 let component = HttpComponent::with_config(HttpConfig::default());
3627 let endpoint_ctx = NoOpComponentContext;
3628 let endpoint = component
3629 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
3630 .unwrap();
3631 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3632
3633 let mut exchange = Exchange::new(Message::default());
3634 exchange.input.set_header(
3635 "CamelHttpMethod",
3636 serde_json::Value::String("HEAD".to_string()),
3637 );
3638 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3639
3640 let result = producer.oneshot(exchange).await.unwrap();
3641 let status = result
3642 .input
3643 .header("CamelHttpResponseCode")
3644 .and_then(|v| v.as_u64())
3645 .unwrap();
3646 assert_eq!(status, 200);
3647
3648 let captured = captured.lock().unwrap();
3649 assert_eq!(captured.len(), 1);
3650 let req = &captured[0];
3651 assert_eq!(req.method, "HEAD");
3652 assert!(req.body.is_empty(), "HEAD must not carry a body");
3653 }
3654
3655 #[tokio::test]
3656 async fn test_delete_options_trace_with_body_suppressed() {
3657 use tower::ServiceExt;
3658
3659 let (url, _handle, captured) = start_capture_server().await;
3660 let ctx = test_producer_ctx();
3661 let component = HttpComponent::with_config(HttpConfig::default());
3662 let endpoint_ctx = NoOpComponentContext;
3663
3664 for method in ["DELETE", "OPTIONS", "TRACE"] {
3665 let endpoint = component
3666 .create_endpoint(
3667 &format!("{url}?httpMethod={method}&allowInternal=true"),
3668 &endpoint_ctx,
3669 )
3670 .unwrap();
3671 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3672
3673 let mut exchange = Exchange::new(Message::default());
3674 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3675
3676 let result = producer.oneshot(exchange).await.unwrap();
3677 let status = result
3678 .input
3679 .header("CamelHttpResponseCode")
3680 .and_then(|v| v.as_u64())
3681 .unwrap();
3682 assert_eq!(status, 200, "method {method} should succeed");
3683 }
3684
3685 let captured = captured.lock().unwrap();
3686 assert_eq!(captured.len(), 3, "expected three captured requests");
3687 for method in ["DELETE", "OPTIONS", "TRACE"] {
3688 let req = captured
3689 .iter()
3690 .find(|r| r.method == method)
3691 .unwrap_or_else(|| panic!("missing captured request for {method}"));
3692 assert!(req.body.is_empty(), "{} must not carry a body", req.method);
3693 }
3694 }
3695
3696 #[tokio::test]
3697 async fn test_post_put_patch_with_body_still_sent() {
3698 use tower::ServiceExt;
3699
3700 let (url, _handle, captured) = start_capture_server().await;
3701 let ctx = test_producer_ctx();
3702 let component = HttpComponent::with_config(HttpConfig::default());
3703 let endpoint_ctx = NoOpComponentContext;
3704
3705 for method in ["POST", "PUT", "PATCH"] {
3706 let endpoint = component
3707 .create_endpoint(
3708 &format!("{url}?httpMethod={method}&allowInternal=true"),
3709 &endpoint_ctx,
3710 )
3711 .unwrap();
3712 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3713
3714 let payload = format!("body-for-{method}");
3715 let mut exchange = Exchange::new(Message::default());
3716 exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
3717
3718 let result = producer.oneshot(exchange).await.unwrap();
3719 let status = result
3720 .input
3721 .header("CamelHttpResponseCode")
3722 .and_then(|v| v.as_u64())
3723 .unwrap();
3724 assert_eq!(status, 200, "method {method} should succeed");
3725 }
3726
3727 let captured = captured.lock().unwrap();
3728 assert_eq!(captured.len(), 3, "expected three captured requests");
3729 for method in ["POST", "PUT", "PATCH"] {
3730 let req = captured
3731 .iter()
3732 .find(|r| r.method == method)
3733 .unwrap_or_else(|| panic!("missing captured request for {method}"));
3734 let expected = format!("body-for-{method}");
3735 assert!(!req.body.is_empty(), "{method} must still carry its body");
3736 assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
3737 }
3738 }
3739
3740 #[tokio::test]
3744 async fn test_stream_body_under_get_not_attached() {
3745 use tower::ServiceExt;
3746
3747 let (url, _handle, captured) = start_capture_server().await;
3748 let ctx = test_producer_ctx();
3749
3750 let component = HttpComponent::with_config(HttpConfig::default());
3751 let endpoint_ctx = NoOpComponentContext;
3752 let endpoint = component
3753 .create_endpoint(
3754 &format!("{url}?httpMethod=GET&allowInternal=true"),
3755 &endpoint_ctx,
3756 )
3757 .unwrap();
3758 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3759
3760 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
3761 vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
3762 let stream = Box::pin(futures::stream::iter(chunks));
3763 let mut exchange = Exchange::new(Message::default());
3764 exchange.input.body = Body::Stream(StreamBody {
3765 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
3766 metadata: StreamMetadata::default(),
3767 });
3768
3769 let result = producer.oneshot(exchange).await.unwrap();
3770
3771 let status = result
3772 .input
3773 .header("CamelHttpResponseCode")
3774 .and_then(|v| v.as_u64())
3775 .unwrap();
3776 assert_eq!(status, 200);
3777
3778 let captured = captured.lock().unwrap();
3779 assert_eq!(captured.len(), 1, "expected exactly one captured request");
3780 assert!(
3781 captured[0].body.is_empty(),
3782 "GET must not carry a stream body"
3783 );
3784 assert!(
3785 captured[0].transfer_encoding.is_none(),
3786 "suppressed request must not carry Transfer-Encoding"
3787 );
3788 assert!(
3789 captured[0].content_length.is_none(),
3790 "suppressed request must not carry Content-Length"
3791 );
3792 assert!(
3793 result.input.body.is_empty(),
3794 "exchange body must be consumed to Empty, not left as a stream"
3795 );
3796 }
3797
3798 #[tokio::test]
3802 async fn test_redirect_hops_never_replay_suppressed_body() {
3803 use tower::ServiceExt;
3804
3805 let (url, _handle, captured) = start_redirect_capture_server().await;
3806 let ctx = test_producer_ctx();
3807
3808 let component =
3809 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3810 let endpoint_ctx = NoOpComponentContext;
3811
3812 for path in ["/hop307", "/hop308"] {
3813 let endpoint = component
3814 .create_endpoint(
3815 &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
3816 &endpoint_ctx,
3817 )
3818 .unwrap();
3819 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3820
3821 let mut exchange = Exchange::new(Message::default());
3822 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3823
3824 let result = producer.oneshot(exchange).await.unwrap();
3825 let status = result
3826 .input
3827 .header("CamelHttpResponseCode")
3828 .and_then(|v| v.as_u64())
3829 .unwrap();
3830 assert_eq!(
3831 status, 200,
3832 "redirect chain for {path} should end at /final"
3833 );
3834 }
3835
3836 let captured = captured.lock().unwrap();
3838 assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
3839 for req in captured.iter() {
3840 assert!(
3841 req.body.is_empty(),
3842 "hop {} {} must not carry a body",
3843 req.method,
3844 req.path
3845 );
3846 }
3847 }
3848
3849 #[tracing_test::traced_test]
3858 #[tokio::test]
3859 async fn test_suppressed_body_logs_exactly_one_warn() {
3860 use tower::ServiceExt;
3861
3862 let (url, _handle, _captured) = start_capture_server().await;
3863 let ctx = test_producer_ctx();
3864
3865 let component = HttpComponent::with_config(HttpConfig::default());
3866 let endpoint_ctx = NoOpComponentContext;
3867 let endpoint = component
3868 .create_endpoint(
3869 &format!("{url}?httpMethod=GET&allowInternal=true"),
3870 &endpoint_ctx,
3871 )
3872 .unwrap();
3873 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3874
3875 let mut exchange = Exchange::new(Message::default());
3876 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3877 let correlation_id = exchange.correlation_id().to_string();
3878
3879 let result = producer.oneshot(exchange).await.unwrap();
3880 let status = result
3881 .input
3882 .header("CamelHttpResponseCode")
3883 .and_then(|v| v.as_u64())
3884 .unwrap();
3885 assert_eq!(status, 200);
3886
3887 logs_assert(|lines: &[&str]| {
3888 let hits = lines
3889 .iter()
3890 .filter(|l| {
3891 l.contains("dropping request body")
3892 && l.contains("method=GET")
3893 && l.contains(&format!("correlation_id={correlation_id}"))
3894 })
3895 .count();
3896 match hits {
3897 1 => Ok(()),
3898 n => Err(format!("expected exactly one body-drop warn, found {n}")),
3899 }
3900 });
3901 }
3902
3903 #[tracing_test::traced_test]
3904 #[tokio::test]
3905 async fn test_empty_body_get_emits_no_warn() {
3906 use tower::ServiceExt;
3907
3908 let (url, _handle, _captured) = start_capture_server().await;
3909 let ctx = test_producer_ctx();
3910
3911 let component = HttpComponent::with_config(HttpConfig::default());
3912 let endpoint_ctx = NoOpComponentContext;
3913 let endpoint = component
3914 .create_endpoint(
3915 &format!("{url}?httpMethod=GET&allowInternal=true"),
3916 &endpoint_ctx,
3917 )
3918 .unwrap();
3919 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3920
3921 let exchange = Exchange::new(Message::default());
3922 let result = producer.oneshot(exchange).await.unwrap();
3923 let status = result
3924 .input
3925 .header("CamelHttpResponseCode")
3926 .and_then(|v| v.as_u64())
3927 .unwrap();
3928 assert_eq!(status, 200);
3929
3930 logs_assert(|lines: &[&str]| {
3931 let hits = lines
3932 .iter()
3933 .filter(|l| l.contains("dropping request body"))
3934 .count();
3935 match hits {
3936 0 => Ok(()),
3937 n => Err(format!("expected no body-drop warn, found {n}")),
3938 }
3939 });
3940 }
3941
3942 #[tokio::test]
3943 async fn test_follow_redirects_false_does_not_follow() {
3944 use tower::ServiceExt;
3945
3946 let (url, _handle) = start_redirect_server().await;
3947 let ctx = test_producer_ctx();
3948
3949 let component =
3950 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
3951 let endpoint_ctx = NoOpComponentContext;
3952 let endpoint = component
3953 .create_endpoint(
3954 &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
3955 &endpoint_ctx,
3956 )
3957 .unwrap();
3958 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3959
3960 let exchange = Exchange::new(Message::default());
3961 let result = producer.oneshot(exchange).await.unwrap();
3962
3963 let status = result
3965 .input
3966 .header("CamelHttpResponseCode")
3967 .and_then(|v| v.as_u64())
3968 .unwrap();
3969 assert_eq!(
3970 status, 302,
3971 "Should NOT follow redirect when followRedirects=false"
3972 );
3973 }
3974
3975 #[tokio::test]
3976 async fn test_follow_redirects_true_follows_redirect() {
3977 use tower::ServiceExt;
3978
3979 let (url, _handle) = start_redirect_server().await;
3980 let ctx = test_producer_ctx();
3981
3982 let component =
3983 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3984 let endpoint_ctx = NoOpComponentContext;
3985 let endpoint = component
3986 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
3987 .unwrap();
3988 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3989
3990 let exchange = Exchange::new(Message::default());
3991 let result = producer.oneshot(exchange).await.unwrap();
3992
3993 let status = result
3995 .input
3996 .header("CamelHttpResponseCode")
3997 .and_then(|v| v.as_u64())
3998 .unwrap();
3999 assert_eq!(
4000 status, 200,
4001 "Should follow redirect when followRedirects=true"
4002 );
4003 }
4004
4005 #[tokio::test]
4008 async fn test_redirect_to_private_ip_is_ssrf_blocked() {
4009 use tower::ServiceExt;
4010
4011 let (url, _handle) = start_redirect_server().await;
4013 let ctx = test_producer_ctx();
4014
4015 let component =
4016 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4017 let endpoint_ctx = NoOpComponentContext;
4018 let endpoint = component
4019 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
4020 .unwrap();
4021 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4022
4023 let exchange = Exchange::new(Message::default());
4024 let result = producer.oneshot(exchange).await;
4025
4026 assert!(
4028 result.is_ok(),
4029 "Redirect should succeed with allowInternal=true, got: {:?}",
4030 result
4031 );
4032 let exchange = result.unwrap();
4033 let status = exchange
4034 .input
4035 .header("CamelHttpResponseCode")
4036 .and_then(|v| v.as_u64())
4037 .unwrap();
4038 assert_eq!(status, 200, "Should follow redirect to /final");
4039 }
4040
4041 #[tokio::test]
4043 async fn test_redirect_to_private_ip_allowed_when_configured() {
4044 use tower::ServiceExt;
4045
4046 let (url, _handle) = start_redirect_server().await;
4048 let ctx = test_producer_ctx();
4049
4050 let component =
4051 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4052 let endpoint_ctx = NoOpComponentContext;
4053 let endpoint = component
4054 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
4055 .unwrap();
4056 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4057
4058 let exchange = Exchange::new(Message::default());
4059 let result = producer.oneshot(exchange).await.unwrap();
4060
4061 let status = result
4062 .input
4063 .header("CamelHttpResponseCode")
4064 .and_then(|v| v.as_u64())
4065 .unwrap();
4066 assert_eq!(
4067 status, 200,
4068 "Should follow redirect to private IP when allowInternal=true"
4069 );
4070 }
4071
4072 #[tokio::test]
4075 async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
4076 use tower::ServiceExt;
4077
4078 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4080 let addr = listener.local_addr().unwrap();
4081 let url = format!("http://127.0.0.1:{}", addr.port());
4082
4083 let handle = tokio::spawn(async move {
4084 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4085 loop {
4086 if let Ok((mut stream, _)) = listener.accept().await {
4087 tokio::spawn(async move {
4088 let mut buf = vec![0u8; 4096];
4089 let _ = stream.read(&mut buf).await;
4090 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";
4092 let _ = stream.write_all(response.as_bytes()).await;
4093 });
4094 }
4095 }
4096 });
4097
4098 let ctx = test_producer_ctx();
4099 let component =
4100 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4101 let endpoint_ctx = NoOpComponentContext;
4102 let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
4104 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4105
4106 let exchange = Exchange::new(Message::default());
4107 let result = producer.oneshot(exchange).await;
4108
4109 assert!(
4111 result.is_err(),
4112 "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
4113 );
4114 let err = result.unwrap_err().to_string();
4115 assert!(
4116 err.contains("blocked IP")
4117 || err.contains("private IP")
4118 || err.contains("SSRF")
4119 || err.contains("not allowed"),
4120 "Error should mention SSRF/IP blocking, got: {err}"
4121 );
4122
4123 handle.abort();
4124 }
4125
4126 #[tokio::test]
4128 async fn test_too_many_redirects_returns_error() {
4129 use tower::ServiceExt;
4130
4131 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4133 let addr = listener.local_addr().unwrap();
4134 let url = format!("http://127.0.0.1:{}", addr.port());
4135
4136 let handle = tokio::spawn(async move {
4137 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4138 loop {
4139 if let Ok((mut stream, _)) = listener.accept().await {
4140 tokio::spawn(async move {
4141 let mut buf = vec![0u8; 4096];
4142 let _ = stream.read(&mut buf).await;
4143 let response =
4145 "HTTP/1.1 302 Found\r\nLocation: /loop\r\nContent-Length: 0\r\n\r\n";
4146 let _ = stream.write_all(response.as_bytes()).await;
4147 });
4148 }
4149 }
4150 });
4151
4152 let ctx = test_producer_ctx();
4153 let component =
4154 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4155 let endpoint_ctx = NoOpComponentContext;
4156 let endpoint = component
4157 .create_endpoint(
4158 &format!("{url}?allowInternal=true&maxRedirects=2"),
4159 &endpoint_ctx,
4160 )
4161 .unwrap();
4162 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4163
4164 let exchange = Exchange::new(Message::default());
4165 let result = producer.oneshot(exchange).await;
4166
4167 match result {
4175 Err(e) => {
4176 let msg = e.to_string();
4178 assert!(
4179 msg.contains("HTTP operation failed") || msg.contains("302"),
4180 "expected redirect-after-exhaustion error, got: {msg}"
4181 );
4182 }
4183 Ok(ex) => {
4184 let response_code = ex
4185 .input
4186 .header("CamelHttpResponseCode")
4187 .and_then(|v| v.as_u64());
4188 assert_eq!(
4189 response_code,
4190 Some(302),
4191 "expected 302 after exhausting redirects"
4192 );
4193 }
4194 }
4195
4196 handle.abort();
4197 }
4198
4199 #[tokio::test]
4200 async fn test_query_params_forwarded_to_http_request() {
4201 use tower::ServiceExt;
4202
4203 let (url, _handle) = start_test_server().await;
4204 let ctx = test_producer_ctx();
4205
4206 let component = HttpComponent::new();
4207 let endpoint_ctx = NoOpComponentContext;
4208 let endpoint = component
4210 .create_endpoint(
4211 &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
4212 &endpoint_ctx,
4213 )
4214 .unwrap();
4215 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4216
4217 let exchange = Exchange::new(Message::default());
4218 let result = producer.oneshot(exchange).await.unwrap();
4219
4220 let status = result
4223 .input
4224 .header("CamelHttpResponseCode")
4225 .and_then(|v| v.as_u64())
4226 .unwrap();
4227 assert_eq!(status, 200);
4228 }
4229
4230 #[tokio::test]
4231 async fn test_non_camel_query_params_are_forwarded() {
4232 let config = HttpEndpointConfig::from_uri(
4235 "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
4236 )
4237 .unwrap();
4238
4239 assert!(
4241 config.query_params.contains_key("apiKey"),
4242 "apiKey should be preserved"
4243 );
4244 assert!(
4245 config.query_params.contains_key("token"),
4246 "token should be preserved"
4247 );
4248 assert_eq!(config.query_params.get("apiKey").unwrap(), "secret123");
4249 assert_eq!(config.query_params.get("token").unwrap(), "abc456");
4250
4251 assert!(
4253 !config.query_params.contains_key("httpMethod"),
4254 "httpMethod should not be forwarded"
4255 );
4256 }
4257
4258 #[test]
4259 fn test_query_params_are_url_encoded_when_resolving_url() {
4260 let config =
4261 HttpEndpointConfig::from_uri("http://example.com/api?q=hello world&tag=a+b").unwrap();
4262 let exchange = Exchange::new(Message::default());
4263
4264 let url = HttpProducer::resolve_url(&exchange, &config);
4265
4266 assert!(url.contains("q=hello+world"), "url was: {url}");
4267 assert!(url.contains("tag=a%2Bb"), "url was: {url}");
4268 }
4269
4270 async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
4275 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4276 let addr = listener.local_addr().unwrap();
4277 let url = format!("http://127.0.0.1:{}", addr.port());
4278
4279 let handle = tokio::spawn(async move {
4280 loop {
4281 if let Ok((mut stream, _)) = listener.accept().await {
4282 let delay = delay_ms;
4283 tokio::spawn(async move {
4284 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4285 let mut buf = vec![0u8; 4096];
4286 let _ = stream.read(&mut buf).await;
4287 let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
4289 let _ = stream.write_all(headers.as_bytes()).await;
4290 tokio::time::sleep(Duration::from_millis(delay)).await;
4292 let body = r#"{"status":"slow"}"#;
4293 let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
4294 let _ = stream.write_all(chunk.as_bytes()).await;
4295 });
4296 }
4297 }
4298 });
4299
4300 (url, handle)
4301 }
4302
4303 #[tokio::test]
4304 async fn test_http_producer_timeout() {
4305 use tower::ServiceExt;
4306
4307 let (url, _handle) = start_slow_server(500).await;
4309 let ctx = test_producer_ctx();
4310
4311 let component = HttpComponent::with_config(
4312 HttpConfig::default()
4313 .with_read_timeout_ms(100)
4314 .with_response_timeout_ms(30_000), );
4316 let endpoint_ctx = NoOpComponentContext;
4317 let endpoint = component
4318 .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
4319 .unwrap();
4320 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4321
4322 let exchange = Exchange::new(Message::default());
4323 let result = producer.oneshot(exchange).await;
4324
4325 assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
4326 let err = result.unwrap_err().to_string();
4327 assert!(
4328 err.contains("Read timeout") || err.contains("timeout"),
4329 "Error should mention timeout, got: {}",
4330 err
4331 );
4332 }
4333
4334 #[tokio::test]
4335 async fn test_http_producer_no_timeout_when_fast() {
4336 use tower::ServiceExt;
4337
4338 let (url, _handle) = start_test_server().await;
4339 let ctx = test_producer_ctx();
4340
4341 let component =
4342 HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
4343 let endpoint_ctx = NoOpComponentContext;
4344 let endpoint = component
4345 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4346 .unwrap();
4347 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4348
4349 let exchange = Exchange::new(Message::default());
4350 let result = producer.oneshot(exchange).await.unwrap();
4351
4352 let status = result
4353 .input
4354 .header("CamelHttpResponseCode")
4355 .and_then(|v| v.as_u64())
4356 .unwrap();
4357 assert_eq!(status, 200);
4358 }
4359
4360 #[tokio::test]
4365 async fn test_http_producer_blocks_metadata_endpoint() {
4366 use tower::ServiceExt;
4367
4368 let ctx = test_producer_ctx();
4369 let component = HttpComponent::new();
4370 let endpoint_ctx = NoOpComponentContext;
4371 let endpoint = component
4372 .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
4373 .unwrap();
4374 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4375
4376 let mut exchange = Exchange::new(Message::default());
4377 exchange.input.set_header(
4378 "CamelHttpUri",
4379 serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
4380 );
4381
4382 let result = producer.oneshot(exchange).await;
4383 assert!(result.is_err(), "Should block AWS metadata endpoint");
4384
4385 let err = result.unwrap_err();
4386 assert!(
4387 err.to_string().contains("Private IP"),
4388 "Error should mention private IP blocking, got: {}",
4389 err
4390 );
4391 }
4392
4393 #[test]
4394 fn test_ssrf_config_defaults() {
4395 let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
4396 assert!(
4397 !config.allow_internal,
4398 "Private IPs should be blocked by default"
4399 );
4400 assert!(
4401 config.blocked_hosts.is_empty(),
4402 "Blocked hosts should be empty by default"
4403 );
4404 }
4405
4406 #[test]
4407 fn test_ssrf_config_allow_internal() {
4408 let config =
4409 HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
4410 assert!(
4411 config.allow_internal,
4412 "Private IPs should be allowed when explicitly set"
4413 );
4414 }
4415
4416 #[test]
4417 fn test_ssrf_config_blocked_hosts() {
4418 let config = HttpEndpointConfig::from_uri(
4419 "http://example.com/api?blockedHosts=evil.com,malware.net",
4420 )
4421 .unwrap();
4422 assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
4423 }
4424
4425 #[tokio::test]
4426 async fn test_http_producer_blocks_localhost() {
4427 use tower::ServiceExt;
4428
4429 let ctx = test_producer_ctx();
4430 let component = HttpComponent::new();
4431 let endpoint_ctx = NoOpComponentContext;
4432 let endpoint = component
4433 .create_endpoint("http://example.com/api", &endpoint_ctx)
4434 .unwrap();
4435 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4436
4437 let mut exchange = Exchange::new(Message::default());
4438 exchange.input.set_header(
4439 "CamelHttpUri",
4440 serde_json::Value::String("http://localhost:8080/internal".to_string()),
4441 );
4442
4443 let result = producer.oneshot(exchange).await;
4444 assert!(result.is_err(), "Should block localhost");
4445 }
4446
4447 #[tokio::test]
4448 async fn test_http_producer_blocks_loopback_ip() {
4449 use tower::ServiceExt;
4450
4451 let ctx = test_producer_ctx();
4452 let component = HttpComponent::new();
4453 let endpoint_ctx = NoOpComponentContext;
4454 let endpoint = component
4455 .create_endpoint("http://example.com/api", &endpoint_ctx)
4456 .unwrap();
4457 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4458
4459 let mut exchange = Exchange::new(Message::default());
4460 exchange.input.set_header(
4461 "CamelHttpUri",
4462 serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
4463 );
4464
4465 let result = producer.oneshot(exchange).await;
4466 assert!(result.is_err(), "Should block loopback IP");
4467 }
4468
4469 #[tokio::test]
4470 async fn test_http_producer_allows_private_ip_when_enabled() {
4471 use tower::ServiceExt;
4472
4473 let ctx = test_producer_ctx();
4474 let component = HttpComponent::new();
4475 let endpoint_ctx = NoOpComponentContext;
4476 let endpoint = component
4479 .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
4480 .unwrap();
4481 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4482
4483 let exchange = Exchange::new(Message::default());
4484
4485 let result = producer.oneshot(exchange).await;
4488 if let Err(ref e) = result {
4490 let err_str = e.to_string();
4491 assert!(
4492 !err_str.contains("Private IP") && !err_str.contains("not allowed"),
4493 "Should not be SSRF error, got: {}",
4494 err_str
4495 );
4496 }
4497 }
4498
4499 #[test]
4504 fn test_http_server_config_parse() {
4505 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
4506 assert_eq!(cfg.host, "0.0.0.0");
4507 assert_eq!(cfg.port, 8080);
4508 assert_eq!(cfg.path, "/orders");
4509 assert_eq!(cfg.max_inflight_requests, 1024);
4510 }
4511
4512 #[test]
4513 fn test_http_server_config_scheme() {
4514 assert_eq!(HttpServerConfig::scheme(), "http");
4516 }
4517
4518 #[test]
4519 fn test_http_server_config_from_components() {
4520 let components = camel_component_api::UriComponents {
4522 scheme: "https".to_string(),
4523 path: "//0.0.0.0:8443/api".to_string(),
4524 params: std::collections::HashMap::from([
4525 ("maxRequestBody".to_string(), "5242880".to_string()),
4526 ("maxInflightRequests".to_string(), "7".to_string()),
4527 ]),
4528 };
4529 let cfg = HttpServerConfig::from_components(components).unwrap();
4530 assert_eq!(cfg.host, "0.0.0.0");
4531 assert_eq!(cfg.port, 8443);
4532 assert_eq!(cfg.path, "/api");
4533 assert_eq!(cfg.max_request_body, 5242880);
4534 assert_eq!(cfg.max_inflight_requests, 7);
4535 }
4536
4537 #[test]
4538 fn test_http_server_config_default_path() {
4539 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
4540 assert_eq!(cfg.path, "/");
4541 }
4542
4543 #[test]
4544 fn test_http_server_config_wrong_scheme() {
4545 assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
4546 }
4547
4548 #[test]
4549 fn test_http_server_config_invalid_port() {
4550 assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
4551 }
4552
4553 #[test]
4554 fn test_http_server_config_default_port_by_scheme() {
4555 let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
4557 assert_eq!(cfg_http.port, 80);
4558
4559 let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
4561 assert_eq!(cfg_https.port, 443);
4562 }
4563
4564 #[test]
4565 fn test_request_envelope_and_reply_are_send() {
4566 fn assert_send<T: Send>() {}
4567 assert_send::<RequestEnvelope>();
4568 assert_send::<HttpReply>();
4569 }
4570
4571 #[test]
4576 fn test_server_registry_global_is_singleton() {
4577 let r1 = ServerRegistry::global();
4578 let r2 = ServerRegistry::global();
4579 assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
4580 }
4581
4582 #[allow(clippy::await_holding_lock)]
4583 #[tokio::test]
4584 async fn test_concurrent_get_or_spawn_returns_same_registry() {
4585 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4586 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4587 let port = listener.local_addr().unwrap().port();
4588 drop(listener);
4589
4590 let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
4591 Arc::new(std::sync::Mutex::new(Vec::new()));
4592
4593 let mut handles = Vec::new();
4594 for _ in 0..4 {
4595 let results = results.clone();
4596 handles.push(tokio::spawn(async move {
4597 let registry = ServerRegistry::global()
4598 .get_or_spawn(
4599 "127.0.0.1",
4600 port,
4601 2 * 1024 * 1024,
4602 10 * 1024 * 1024,
4603 1024,
4604 test_rt(),
4605 "test-route".into(),
4606 None,
4607 )
4608 .await
4609 .unwrap();
4610 results.lock().unwrap().push(registry);
4611 }));
4612 }
4613
4614 for h in handles {
4615 h.await.unwrap();
4616 }
4617
4618 let registries = results.lock().unwrap();
4619 assert_eq!(registries.len(), 4);
4620 for i in 1..registries.len() {
4621 assert!(
4622 Arc::ptr_eq(®istries[0].inner, ®istries[i].inner),
4623 "all concurrent callers should get same route registry"
4624 );
4625 }
4626 }
4627
4628 #[test]
4629 fn test_server_registry_distinguishes_host_and_port() {
4630 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4631 let rt = tokio::runtime::Runtime::new().expect("runtime");
4632 rt.block_on(async {
4633 let registry = ServerRegistry::global();
4634 let d1 = registry
4638 .get_or_spawn(
4639 "127.0.0.1",
4640 0,
4641 1024 * 1024,
4642 10 * 1024 * 1024,
4643 1024,
4644 test_rt(),
4645 "test-route-1".into(),
4646 None,
4647 )
4648 .await;
4649 let d2 = registry
4650 .get_or_spawn(
4651 "0.0.0.0",
4652 0,
4653 1024 * 1024,
4654 10 * 1024 * 1024,
4655 1024,
4656 test_rt(),
4657 "test-route-2".into(),
4658 None,
4659 )
4660 .await;
4661 assert!(d1.is_ok());
4662 assert!(d2.is_ok());
4663 assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
4664 });
4665 }
4666
4667 #[allow(clippy::await_holding_lock)]
4668 #[tokio::test]
4669 async fn test_shared_server_max_request_body_policy_is_deterministic() {
4670 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4671 let registry = ServerRegistry::global();
4672 let d1 = registry
4674 .get_or_spawn(
4675 "127.0.0.1",
4676 9991,
4677 1024 * 1024,
4678 10 * 1024 * 1024,
4679 1024,
4680 test_rt(),
4681 "test-route".into(),
4682 None,
4683 )
4684 .await;
4685 assert!(d1.is_ok());
4686
4687 let d2 = registry
4690 .get_or_spawn(
4691 "127.0.0.1",
4692 9991,
4693 2 * 1024 * 1024,
4694 10 * 1024 * 1024,
4695 1024,
4696 test_rt(),
4697 "test-route-2".into(),
4698 None,
4699 )
4700 .await;
4701 assert!(d2.is_err());
4702 let err = d2.unwrap_err();
4703 assert!(
4704 err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
4705 "Expected incompatible maxRequestBody error, got: {}",
4706 err
4707 );
4708 }
4709
4710 #[test]
4711 fn test_server_registry_reset_clears_entries() {
4712 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4713 let rt = tokio::runtime::Runtime::new().expect("runtime");
4714 rt.block_on(async {
4715 let d1 = ServerRegistry::global()
4717 .get_or_spawn(
4718 "127.0.0.1",
4719 9992,
4720 1024 * 1024,
4721 10 * 1024 * 1024,
4722 1024,
4723 test_rt(),
4724 "test-route".into(),
4725 None,
4726 )
4727 .await;
4728 assert!(d1.is_ok());
4729
4730 let guard = ServerRegistry::global().inner.lock().expect("lock");
4732 assert!(guard.contains_key(&("127.0.0.1".to_string(), 9992)));
4733 drop(guard);
4734
4735 ServerRegistry::reset();
4737
4738 let guard = ServerRegistry::global().inner.lock().expect("lock");
4740 assert!(
4741 guard.is_empty(),
4742 "registry should be empty after reset, has {} entries",
4743 guard.len()
4744 );
4745 });
4746 }
4747
4748 #[tokio::test]
4749 async fn registry_rejects_tls_on_plain_port() {
4750 ServerRegistry::reset();
4751 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
4752
4753 let _r1 = ServerRegistry::global()
4755 .get_or_spawn(
4756 "127.0.0.1",
4757 0,
4758 1024,
4759 1024,
4760 16,
4761 Arc::clone(&rt),
4762 "route-1".into(),
4763 None, )
4765 .await;
4766
4767 let result = ServerRegistry::global()
4769 .get_or_spawn(
4770 "127.0.0.1",
4771 0,
4772 1024,
4773 1024,
4774 16,
4775 Arc::clone(&rt),
4776 "route-2".into(),
4777 Some(crate::config::ServerTlsConfig {
4778 cert_path: "/x.pem".into(),
4779 key_path: "/y.pem".into(),
4780 }),
4781 )
4782 .await;
4783 assert!(result.is_err(), "must reject TLS on plain port");
4784 }
4785
4786 #[allow(clippy::await_holding_lock)]
4791 #[tokio::test]
4792 async fn test_unregister_last_http_route_keeps_server_alive() {
4793 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4794 ServerRegistry::reset();
4795 let registry = ServerRegistry::global();
4796
4797 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4798 let port = listener.local_addr().unwrap().port();
4799 drop(listener); let rt = test_rt();
4801
4802 let _r1 = registry
4805 .get_or_spawn(
4806 "127.0.0.1",
4807 port,
4808 1024 * 1024,
4809 10 * 1024 * 1024,
4810 16,
4811 rt.clone(),
4812 "test-route-1".into(),
4813 None,
4814 )
4815 .await
4816 .unwrap();
4817 let _r2 = registry
4818 .get_or_spawn(
4819 "127.0.0.1",
4820 port,
4821 1024 * 1024,
4822 10 * 1024 * 1024,
4823 16,
4824 rt,
4825 "test-route-2".into(),
4826 None,
4827 )
4828 .await
4829 .unwrap();
4830
4831 let key = ("127.0.0.1".to_string(), port);
4832 let cell = {
4833 let guard = registry.inner.lock().expect("lock");
4834 guard.get(&key).expect("entry should exist").clone()
4835 };
4836
4837 registry.unregister("127.0.0.1", port).await;
4839 {
4840 let handle = cell
4841 .get()
4842 .expect("handle should still exist after first unregister");
4843 assert!(
4844 !handle.monitor_task.is_finished(),
4845 "monitor task should still be alive after first unregister"
4846 );
4847 }
4848
4849 registry.unregister("127.0.0.1", port).await;
4851 tokio::time::sleep(Duration::from_millis(20)).await;
4852 {
4853 let handle = cell
4854 .get()
4855 .expect("handle should still exist after last unregister");
4856 assert!(
4857 !handle.monitor_task.is_finished(),
4858 "monitor task should still be alive — server is process-lifetime"
4859 );
4860 }
4861
4862 {
4864 let guard = registry.inner.lock().expect("lock");
4865 assert!(
4866 guard.get(&key).is_some(),
4867 "entry should remain in registry — server kept alive for restart"
4868 );
4869 }
4870 }
4871
4872 #[tokio::test]
4877 async fn test_dispatch_handler_returns_404_for_unknown_path() {
4878 let registry = HttpRouteRegistry::new();
4879 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4881 let port = listener.local_addr().unwrap().port();
4882 tokio::spawn(run_axum_server(
4883 listener,
4884 registry,
4885 2 * 1024 * 1024,
4886 10 * 1024 * 1024,
4887 Arc::new(tokio::sync::Semaphore::new(1024)),
4888 test_rt(),
4889 "test-route".into(),
4890 ));
4891
4892 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4894
4895 let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
4896 .await
4897 .unwrap();
4898 assert_eq!(resp.status().as_u16(), 404);
4899 }
4900
4901 #[tokio::test]
4906 async fn test_http_consumer_start_registers_path() {
4907 use camel_component_api::ConsumerContext;
4908
4909 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4911 let port = listener.local_addr().unwrap().port();
4912 drop(listener); let consumer_cfg = HttpServerConfig {
4915 scheme: "http".to_string(),
4916 host: "127.0.0.1".to_string(),
4917 port,
4918 path: "/ping".to_string(),
4919 max_request_body: 2 * 1024 * 1024,
4920 max_response_body: 10 * 1024 * 1024,
4921 max_inflight_requests: 1024,
4922 method: None,
4923 tls_config: None,
4924 };
4925 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4926
4927 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
4928 let token = tokio_util::sync::CancellationToken::new();
4929 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4930
4931 tokio::spawn(async move {
4932 consumer.start(ctx).await.unwrap();
4933 });
4934
4935 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4936
4937 let client = reqwest::Client::new();
4938 let resp_future = client
4939 .post(format!("http://127.0.0.1:{port}/ping"))
4940 .body("hello world")
4941 .send();
4942
4943 let (http_result, _) = tokio::join!(resp_future, async {
4944 if let Some(mut envelope) = rx.recv().await {
4945 envelope.exchange.input.set_header(
4947 "CamelHttpResponseCode",
4948 serde_json::Value::Number(201.into()),
4949 );
4950 if let Some(reply_tx) = envelope.reply_tx {
4951 let _ = reply_tx.send(Ok(envelope.exchange));
4952 }
4953 }
4954 });
4955
4956 let resp = http_result.unwrap();
4957 assert_eq!(resp.status().as_u16(), 201);
4958
4959 token.cancel();
4960 }
4961
4962 #[test]
4966 fn test_envelope_channel_capacity_follows_max_inflight() {
4967 assert_eq!(envelope_channel_capacity(0), 1);
4968 assert_eq!(envelope_channel_capacity(1), 1);
4969 assert_eq!(envelope_channel_capacity(7), 7);
4970 assert_eq!(envelope_channel_capacity(64), 64);
4971 assert_eq!(envelope_channel_capacity(1024), 1024);
4972 }
4973
4974 #[tokio::test]
4978 async fn test_http_consumer_start_with_zero_max_inflight_rejects_503() {
4979 use camel_component_api::ConsumerContext;
4980
4981 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4982 let port = listener.local_addr().unwrap().port();
4983 drop(listener);
4984
4985 let consumer_cfg = HttpServerConfig {
4986 scheme: "http".to_string(),
4987 host: "127.0.0.1".to_string(),
4988 port,
4989 path: "/ping".to_string(),
4990 max_request_body: 2 * 1024 * 1024,
4991 max_response_body: 10 * 1024 * 1024,
4992 max_inflight_requests: 0,
4993 method: None,
4994 tls_config: None,
4995 };
4996 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4997
4998 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
4999 let token = tokio_util::sync::CancellationToken::new();
5000 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5001
5002 let start_handle = tokio::spawn(async move {
5003 consumer.start(ctx).await.unwrap();
5004 });
5005
5006 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5007
5008 let client = reqwest::Client::new();
5009 let resp = client
5010 .post(format!("http://127.0.0.1:{port}/ping"))
5011 .body("hello world")
5012 .send()
5013 .await
5014 .unwrap();
5015 assert_eq!(resp.status().as_u16(), 503);
5016
5017 token.cancel();
5018 let _ = start_handle.await;
5019 }
5020
5021 #[test]
5024 fn test_http_consumer_startup_mode_is_explicit() {
5025 use camel_component_api::ConsumerStartupMode;
5026 let consumer_cfg = HttpServerConfig {
5027 scheme: "http".to_string(),
5028 host: "127.0.0.1".to_string(),
5029 port: 0,
5030 path: "/x".to_string(),
5031 max_request_body: 2 * 1024 * 1024,
5032 max_response_body: 10 * 1024 * 1024,
5033 max_inflight_requests: 1024,
5034 method: None,
5035 tls_config: None,
5036 };
5037 let consumer = HttpConsumer::new(consumer_cfg, test_rt());
5038 assert_eq!(
5039 consumer.startup_mode(),
5040 ConsumerStartupMode::Explicit,
5041 "HttpConsumer must opt into Explicit startup"
5042 );
5043 }
5044
5045 #[allow(clippy::await_holding_lock)]
5051 #[tokio::test]
5052 async fn test_http_consumer_emits_mark_ready_after_bind() {
5053 use camel_component_api::{ConsumerContext, StartupSignal};
5054
5055 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5056
5057 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5058 let port = listener.local_addr().unwrap().port();
5059 drop(listener);
5060
5061 let consumer_cfg = HttpServerConfig {
5062 scheme: "http".to_string(),
5063 host: "127.0.0.1".to_string(),
5064 port,
5065 path: "/ready-probe".to_string(),
5066 max_request_body: 2 * 1024 * 1024,
5067 max_response_body: 10 * 1024 * 1024,
5068 max_inflight_requests: 1024,
5069 method: None,
5070 tls_config: None,
5071 };
5072 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5073
5074 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
5075 let token = tokio_util::sync::CancellationToken::new();
5076 let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
5077
5078 let (signal, startup_rx) = StartupSignal::pair();
5080 let ctx = ctx.with_startup(signal);
5081
5082 tokio::spawn(async move {
5085 let _ = consumer.start(ctx).await;
5086 });
5087
5088 let result =
5093 tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
5094 .await
5095 .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
5096 assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
5097
5098 token.cancel();
5100 }
5101
5102 #[tokio::test]
5103 async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
5104 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5105
5106 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5107 let port = listener.local_addr().unwrap().port();
5108 drop(listener);
5109
5110 let consumer_cfg = HttpServerConfig {
5111 scheme: "http".to_string(),
5112 host: "127.0.0.1".to_string(),
5113 port,
5114 path: "/saturation".to_string(),
5115 max_request_body: 2 * 1024 * 1024,
5116 max_response_body: 10 * 1024 * 1024,
5117 max_inflight_requests: 1,
5118 method: None,
5119 tls_config: None,
5120 };
5121 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5122
5123 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5124 let token = tokio_util::sync::CancellationToken::new();
5125 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5126 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5127 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5128
5129 let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
5130 let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
5131
5132 tokio::spawn(async move {
5133 let mut first_seen_tx = Some(first_seen_tx);
5134 let mut unblock_first_rx = Some(unblock_first_rx);
5135
5136 while let Some(envelope) = rx.recv().await {
5137 if let Some(tx) = first_seen_tx.take() {
5138 let _ = tx.send(());
5139 if let Some(rx_unblock) = unblock_first_rx.take() {
5140 let _ = rx_unblock.await;
5141 }
5142 }
5143
5144 if let Some(reply_tx) = envelope.reply_tx {
5145 let _ = reply_tx.send(Ok(envelope.exchange));
5146 }
5147 }
5148 });
5149
5150 let client = reqwest::Client::new();
5151 let first_req = {
5152 let client = client.clone();
5153 async move {
5154 client
5155 .get(format!("http://127.0.0.1:{port}/saturation"))
5156 .send()
5157 .await
5158 .unwrap()
5159 }
5160 };
5161
5162 let first_handle = tokio::spawn(first_req);
5163 first_seen_rx.await.unwrap();
5164
5165 let second_resp = client
5166 .get(format!("http://127.0.0.1:{port}/saturation"))
5167 .send()
5168 .await
5169 .unwrap();
5170
5171 assert_eq!(second_resp.status().as_u16(), 503);
5172
5173 let _ = unblock_first_tx.send(());
5174 let first_resp = first_handle.await.unwrap();
5175 assert_eq!(first_resp.status().as_u16(), 200);
5176
5177 token.cancel();
5178 }
5179
5180 #[tokio::test]
5181 #[allow(clippy::await_holding_lock)]
5182 async fn test_http_consumer_enforces_max_response_body_for_bytes() {
5183 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5184
5185 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5186
5187 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5188 let port = listener.local_addr().unwrap().port();
5189 drop(listener);
5190
5191 let consumer_cfg = HttpServerConfig {
5192 scheme: "http".to_string(),
5193 host: "127.0.0.1".to_string(),
5194 port,
5195 path: "/limit-bytes".to_string(),
5196 max_request_body: 2 * 1024 * 1024,
5197 max_response_body: 16,
5198 max_inflight_requests: 1024,
5199 method: None,
5200 tls_config: None,
5201 };
5202 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5203
5204 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5205 let token = tokio_util::sync::CancellationToken::new();
5206 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5207 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5208 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5209
5210 let client = reqwest::Client::new();
5211 let send_fut = client
5212 .get(format!("http://127.0.0.1:{port}/limit-bytes"))
5213 .send();
5214
5215 let (http_result, _) = tokio::join!(send_fut, async {
5216 if let Some(mut envelope) = rx.recv().await {
5217 envelope.exchange.input.body =
5218 camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
5219 if let Some(reply_tx) = envelope.reply_tx {
5220 let _ = reply_tx.send(Ok(envelope.exchange));
5221 }
5222 }
5223 });
5224
5225 let resp = http_result.unwrap();
5226 assert_eq!(resp.status().as_u16(), 500);
5227 let body = resp.text().await.unwrap();
5228 assert_eq!(body, "Response body exceeds configured limit");
5229 token.cancel();
5230 }
5231
5232 #[tokio::test]
5233 #[allow(clippy::await_holding_lock)]
5234 async fn test_http_consumer_enforces_max_response_body_for_json() {
5235 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5236
5237 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5238
5239 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5240 let port = listener.local_addr().unwrap().port();
5241 drop(listener);
5242
5243 let consumer_cfg = HttpServerConfig {
5244 scheme: "http".to_string(),
5245 host: "127.0.0.1".to_string(),
5246 port,
5247 path: "/limit-json".to_string(),
5248 max_request_body: 2 * 1024 * 1024,
5249 max_response_body: 16,
5250 max_inflight_requests: 1024,
5251 method: None,
5252 tls_config: None,
5253 };
5254 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5255
5256 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5257 let token = tokio_util::sync::CancellationToken::new();
5258 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5259 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5260 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5261
5262 let client = reqwest::Client::new();
5263 let send_fut = client
5264 .get(format!("http://127.0.0.1:{port}/limit-json"))
5265 .send();
5266
5267 let (http_result, _) = tokio::join!(send_fut, async {
5268 if let Some(mut envelope) = rx.recv().await {
5269 envelope.exchange.input.body = camel_component_api::Body::Json(
5270 serde_json::json!({"message":"this response is bigger than sixteen"}),
5271 );
5272 if let Some(reply_tx) = envelope.reply_tx {
5273 let _ = reply_tx.send(Ok(envelope.exchange));
5274 }
5275 }
5276 });
5277
5278 let resp = http_result.unwrap();
5279 assert_eq!(resp.status().as_u16(), 500);
5280 let body = resp.text().await.unwrap();
5281 assert_eq!(body, "Response body exceeds configured limit");
5282 token.cancel();
5283 }
5284
5285 #[tokio::test]
5286 #[allow(clippy::await_holding_lock)]
5287 async fn test_http_consumer_enforces_max_response_body_for_xml() {
5288 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5289
5290 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5291
5292 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5293 let port = listener.local_addr().unwrap().port();
5294 drop(listener);
5295
5296 let consumer_cfg = HttpServerConfig {
5297 scheme: "http".to_string(),
5298 host: "127.0.0.1".to_string(),
5299 port,
5300 path: "/limit-xml".to_string(),
5301 max_request_body: 2 * 1024 * 1024,
5302 max_response_body: 16,
5303 max_inflight_requests: 1024,
5304 method: None,
5305 tls_config: None,
5306 };
5307 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5308
5309 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5310 let token = tokio_util::sync::CancellationToken::new();
5311 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5312 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5313 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5314
5315 let client = reqwest::Client::new();
5316 let send_fut = client
5317 .get(format!("http://127.0.0.1:{port}/limit-xml"))
5318 .send();
5319
5320 let (http_result, _) = tokio::join!(send_fut, async {
5321 if let Some(mut envelope) = rx.recv().await {
5322 envelope.exchange.input.body = camel_component_api::Body::Xml(
5323 "<root><value>way-too-large</value></root>".into(),
5324 );
5325 if let Some(reply_tx) = envelope.reply_tx {
5326 let _ = reply_tx.send(Ok(envelope.exchange));
5327 }
5328 }
5329 });
5330
5331 let resp = http_result.unwrap();
5332 assert_eq!(resp.status().as_u16(), 500);
5333 let body = resp.text().await.unwrap();
5334 assert_eq!(body, "Response body exceeds configured limit");
5335 token.cancel();
5336 }
5337
5338 #[tokio::test]
5339 #[allow(clippy::await_holding_lock)]
5340 async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
5341 use camel_component_api::{
5342 CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
5343 };
5344 use futures::stream;
5345
5346 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5347
5348 let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
5349 let port = listener.local_addr().unwrap().port();
5350 drop(listener);
5351
5352 let consumer_cfg = HttpServerConfig {
5353 scheme: "http".to_string(),
5354 host: "0.0.0.0".to_string(),
5355 port,
5356 path: "/limit-stream".to_string(),
5357 max_request_body: 2 * 1024 * 1024,
5358 max_response_body: 16,
5359 max_inflight_requests: 1024,
5360 method: None,
5361 tls_config: None,
5362 };
5363 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5364
5365 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5366 let token = tokio_util::sync::CancellationToken::new();
5367 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5368 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5369 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5370
5371 let client = reqwest::Client::new();
5372 let send_fut = client
5373 .get(format!("http://127.0.0.1:{port}/limit-stream"))
5374 .send();
5375
5376 let (http_result, _) = tokio::join!(send_fut, async {
5377 if let Some(mut envelope) = rx.recv().await {
5378 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
5379 vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
5380 let stream = Box::pin(stream::iter(chunks));
5381 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
5382 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
5383 metadata: StreamMetadata {
5384 size_hint: Some(32),
5385 content_type: Some("application/octet-stream".into()),
5386 origin: None,
5387 },
5388 });
5389 if let Some(reply_tx) = envelope.reply_tx {
5390 let _ = reply_tx.send(Ok(envelope.exchange));
5391 }
5392 }
5393 });
5394
5395 let resp = http_result.unwrap();
5396 assert_eq!(resp.status().as_u16(), 200);
5397 let body = resp.bytes().await.unwrap();
5398 assert_eq!(body.len(), 32);
5399 token.cancel();
5400 }
5401
5402 #[tokio::test]
5407 #[allow(clippy::await_holding_lock)]
5408 async fn test_integration_single_consumer_round_trip() {
5409 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5410
5411 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5415
5416 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5418 let port = listener.local_addr().unwrap().port();
5419 drop(listener); let component = HttpComponent::new();
5422 let endpoint_ctx = NoOpComponentContext;
5423 let endpoint = component
5424 .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
5425 .unwrap();
5426 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5427
5428 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5429 let token = tokio_util::sync::CancellationToken::new();
5430 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5431
5432 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5433 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5434
5435 let client = reqwest::Client::new();
5436 let send_fut = client
5437 .post(format!("http://127.0.0.1:{port}/echo"))
5438 .header("Content-Type", "text/plain")
5439 .body("ping")
5440 .send();
5441
5442 let (http_result, _) = tokio::join!(send_fut, async {
5443 if let Some(mut envelope) = rx.recv().await {
5444 assert_eq!(
5445 envelope.exchange.input.header("CamelHttpMethod"),
5446 Some(&serde_json::Value::String("POST".into()))
5447 );
5448 assert_eq!(
5449 envelope.exchange.input.header("CamelHttpPath"),
5450 Some(&serde_json::Value::String("/echo".into()))
5451 );
5452 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
5453 if let Some(reply_tx) = envelope.reply_tx {
5454 let _ = reply_tx.send(Ok(envelope.exchange));
5455 }
5456 }
5457 });
5458
5459 let resp = http_result.unwrap();
5460 assert_eq!(resp.status().as_u16(), 200);
5461 let body = resp.text().await.unwrap();
5462 assert_eq!(body, "pong");
5463
5464 token.cancel();
5465 }
5466
5467 #[tokio::test]
5468 #[allow(clippy::await_holding_lock)]
5469 async fn test_integration_two_consumers_shared_port() {
5470 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5471
5472 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5473
5474 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5476 let port = listener.local_addr().unwrap().port();
5477 drop(listener);
5478
5479 let component = HttpComponent::new();
5480 let endpoint_ctx = NoOpComponentContext;
5481
5482 let endpoint_a = component
5484 .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
5485 .unwrap();
5486 let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
5487
5488 let endpoint_b = component
5490 .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
5491 .unwrap();
5492 let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
5493
5494 let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5495 let token_a = tokio_util::sync::CancellationToken::new();
5496 let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
5497
5498 let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5499 let token_b = tokio_util::sync::CancellationToken::new();
5500 let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
5501
5502 tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
5503 tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
5504 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5505
5506 let client = reqwest::Client::new();
5507
5508 let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
5510 let (resp_hello, _) = tokio::join!(fut_hello, async {
5511 if let Some(mut envelope) = rx_a.recv().await {
5512 envelope.exchange.input.body =
5513 camel_component_api::Body::Text("hello-response".to_string());
5514 if let Some(reply_tx) = envelope.reply_tx {
5515 let _ = reply_tx.send(Ok(envelope.exchange));
5516 }
5517 }
5518 });
5519
5520 let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
5522 let (resp_world, _) = tokio::join!(fut_world, async {
5523 if let Some(mut envelope) = rx_b.recv().await {
5524 envelope.exchange.input.body =
5525 camel_component_api::Body::Text("world-response".to_string());
5526 if let Some(reply_tx) = envelope.reply_tx {
5527 let _ = reply_tx.send(Ok(envelope.exchange));
5528 }
5529 }
5530 });
5531
5532 let body_a = resp_hello.unwrap().text().await.unwrap();
5533 let body_b = resp_world.unwrap().text().await.unwrap();
5534
5535 assert_eq!(body_a, "hello-response");
5536 assert_eq!(body_b, "world-response");
5537
5538 token_a.cancel();
5539 token_b.cancel();
5540 }
5541
5542 #[tokio::test]
5543 #[allow(clippy::await_holding_lock)]
5544 async fn test_integration_unregistered_path_returns_404() {
5545 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5546
5547 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5548
5549 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5551 let port = listener.local_addr().unwrap().port();
5552 drop(listener);
5553
5554 let component = HttpComponent::new();
5555 let endpoint_ctx = NoOpComponentContext;
5556 let endpoint = component
5557 .create_endpoint(
5558 &format!("http://127.0.0.1:{port}/registered"),
5559 &endpoint_ctx,
5560 )
5561 .unwrap();
5562 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5563
5564 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5565 let token = tokio_util::sync::CancellationToken::new();
5566 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5567
5568 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5569
5570 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
5572 loop {
5573 if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
5574 .await
5575 .is_ok()
5576 {
5577 break;
5578 }
5579 if std::time::Instant::now() >= deadline {
5580 panic!("HTTP server did not start within 5s on port {port}");
5581 }
5582 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
5583 }
5584
5585 let client = reqwest::Client::new();
5586 let resp = client
5587 .get(format!("http://127.0.0.1:{port}/not-there"))
5588 .send()
5589 .await
5590 .unwrap();
5591 assert_eq!(resp.status().as_u16(), 404);
5592
5593 token.cancel();
5594 }
5595
5596 #[test]
5597 fn test_http_consumer_declares_concurrent() {
5598 use camel_component_api::ConcurrencyModel;
5599
5600 let config = HttpServerConfig {
5601 scheme: "http".to_string(),
5602 host: "127.0.0.1".to_string(),
5603 port: 19999,
5604 path: "/test".to_string(),
5605 max_request_body: 2 * 1024 * 1024,
5606 max_response_body: 10 * 1024 * 1024,
5607 max_inflight_requests: 1024,
5608 method: None,
5609 tls_config: None,
5610 };
5611 let consumer = HttpConsumer::new(config, test_rt());
5612 assert_eq!(
5613 consumer.concurrency_model(),
5614 ConcurrencyModel::Concurrent { max: None }
5615 );
5616 }
5617
5618 #[test]
5619 fn server_config_parses_tls_cert_and_key() {
5620 let cfg = HttpServerConfig::from_uri(
5621 "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
5622 )
5623 .unwrap();
5624 assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
5625 assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
5626 }
5627
5628 #[test]
5629 fn server_config_no_tls_when_params_absent() {
5630 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
5631 assert!(cfg.tls_config.is_none());
5632 }
5633
5634 #[tokio::test]
5639 async fn test_http_reply_body_stream_variant_exists() {
5640 use bytes::Bytes;
5641 use camel_component_api::CamelError;
5642 use futures::stream;
5643
5644 let chunks: Vec<Result<Bytes, CamelError>> =
5645 vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
5646 let stream = Box::pin(stream::iter(chunks));
5647 let reply_body = HttpReplyBody::Stream(stream);
5648 match reply_body {
5650 HttpReplyBody::Stream(_) => {}
5651 HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
5652 }
5653 }
5654
5655 #[cfg(feature = "otel")]
5660 mod otel_tests {
5661 use super::*;
5662 use camel_component_api::Message;
5663 use tower::ServiceExt;
5664
5665 #[tokio::test]
5666 async fn test_producer_injects_traceparent_header() {
5667 let (url, _handle) = start_test_server_with_header_capture().await;
5668 let ctx = test_producer_ctx();
5669
5670 let component = HttpComponent::new();
5671 let endpoint_ctx = NoOpComponentContext;
5672 let endpoint = component
5673 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5674 .unwrap();
5675 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5676
5677 let mut exchange = Exchange::new(Message::default());
5679 let mut headers = std::collections::HashMap::new();
5680 headers.insert(
5681 "traceparent".to_string(),
5682 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
5683 );
5684 camel_otel::extract_into_exchange(&mut exchange, &headers);
5685
5686 let result = producer.oneshot(exchange).await.unwrap();
5687
5688 let status = result
5690 .input
5691 .header("CamelHttpResponseCode")
5692 .and_then(|v| v.as_u64())
5693 .unwrap();
5694 assert_eq!(status, 200);
5695
5696 let traceparent = result.input.header("X-Received-Traceparent");
5698 assert!(
5699 traceparent.is_some(),
5700 "traceparent header should have been sent"
5701 );
5702
5703 let traceparent_str = traceparent.unwrap().as_str().unwrap();
5704 let parts: Vec<&str> = traceparent_str.split('-').collect();
5706 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
5707 assert_eq!(parts[0], "00", "version should be 00");
5708 assert_eq!(
5709 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
5710 "trace-id should match"
5711 );
5712 assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
5713 assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
5714 }
5715
5716 #[tokio::test]
5717 async fn test_consumer_extracts_traceparent_header() {
5718 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5719
5720 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5722 let port = listener.local_addr().unwrap().port();
5723 drop(listener);
5724
5725 let component = HttpComponent::new();
5726 let endpoint_ctx = NoOpComponentContext;
5727 let endpoint = component
5728 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
5729 .unwrap();
5730 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5731
5732 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5733 let token = tokio_util::sync::CancellationToken::new();
5734 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5735
5736 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5737 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5738
5739 let client = reqwest::Client::new();
5741 let send_fut = client
5742 .post(format!("http://127.0.0.1:{port}/trace"))
5743 .header(
5744 "traceparent",
5745 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
5746 )
5747 .body("test")
5748 .send();
5749
5750 let (http_result, _) = tokio::join!(send_fut, async {
5751 if let Some(envelope) = rx.recv().await {
5752 let mut injected_headers = std::collections::HashMap::new();
5755 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
5756
5757 assert!(
5758 injected_headers.contains_key("traceparent"),
5759 "Exchange should have traceparent after extraction"
5760 );
5761
5762 let traceparent = injected_headers.get("traceparent").unwrap();
5763 let parts: Vec<&str> = traceparent.split('-').collect();
5764 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
5765 assert_eq!(
5766 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
5767 "Trace ID should match the original traceparent header"
5768 );
5769
5770 if let Some(reply_tx) = envelope.reply_tx {
5771 let _ = reply_tx.send(Ok(envelope.exchange));
5772 }
5773 }
5774 });
5775
5776 let resp = http_result.unwrap();
5777 assert_eq!(resp.status().as_u16(), 200);
5778
5779 token.cancel();
5780 }
5781
5782 #[tokio::test]
5783 async fn test_consumer_extracts_mixed_case_traceparent_header() {
5784 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5785
5786 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5788 let port = listener.local_addr().unwrap().port();
5789 drop(listener);
5790
5791 let component = HttpComponent::new();
5792 let endpoint_ctx = NoOpComponentContext;
5793 let endpoint = component
5794 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
5795 .unwrap();
5796 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5797
5798 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5799 let token = tokio_util::sync::CancellationToken::new();
5800 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5801
5802 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5803 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5804
5805 let client = reqwest::Client::new();
5807 let send_fut = client
5808 .post(format!("http://127.0.0.1:{port}/trace"))
5809 .header(
5810 "TraceParent",
5811 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
5812 )
5813 .body("test")
5814 .send();
5815
5816 let (http_result, _) = tokio::join!(send_fut, async {
5817 if let Some(envelope) = rx.recv().await {
5818 let mut injected_headers = HashMap::new();
5821 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
5822
5823 assert!(
5824 injected_headers.contains_key("traceparent"),
5825 "Exchange should have traceparent after extraction from mixed-case header"
5826 );
5827
5828 let traceparent = injected_headers.get("traceparent").unwrap();
5829 let parts: Vec<&str> = traceparent.split('-').collect();
5830 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
5831 assert_eq!(
5832 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
5833 "Trace ID should match the original mixed-case TraceParent header"
5834 );
5835
5836 if let Some(reply_tx) = envelope.reply_tx {
5837 let _ = reply_tx.send(Ok(envelope.exchange));
5838 }
5839 }
5840 });
5841
5842 let resp = http_result.unwrap();
5843 assert_eq!(resp.status().as_u16(), 200);
5844
5845 token.cancel();
5846 }
5847
5848 #[tokio::test]
5849 async fn test_producer_no_trace_context_no_crash() {
5850 let (url, _handle) = start_test_server().await;
5851 let ctx = test_producer_ctx();
5852
5853 let component = HttpComponent::new();
5854 let endpoint_ctx = NoOpComponentContext;
5855 let endpoint = component
5856 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5857 .unwrap();
5858 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5859
5860 let exchange = Exchange::new(Message::default());
5862
5863 let result = producer.oneshot(exchange).await.unwrap();
5865
5866 let status = result
5868 .input
5869 .header("CamelHttpResponseCode")
5870 .and_then(|v| v.as_u64())
5871 .unwrap();
5872 assert_eq!(status, 200);
5873 }
5874
5875 async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
5877 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5878 let addr = listener.local_addr().unwrap();
5879 let url = format!("http://127.0.0.1:{}", addr.port());
5880
5881 let handle = tokio::spawn(async move {
5882 loop {
5883 if let Ok((mut stream, _)) = listener.accept().await {
5884 tokio::spawn(async move {
5885 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5886 let mut buf = vec![0u8; 8192];
5887 let n = stream.read(&mut buf).await.unwrap_or(0);
5888 let request = String::from_utf8_lossy(&buf[..n]).to_string();
5889
5890 let traceparent = request
5892 .lines()
5893 .find(|line| line.to_lowercase().starts_with("traceparent:"))
5894 .map(|line| {
5895 line.split(':')
5896 .nth(1)
5897 .map(|s| s.trim().to_string())
5898 .unwrap_or_default()
5899 })
5900 .unwrap_or_default();
5901
5902 let body =
5903 format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
5904 let response = format!(
5905 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
5906 body.len(),
5907 traceparent,
5908 body
5909 );
5910 let _ = stream.write_all(response.as_bytes()).await;
5911 });
5912 }
5913 }
5914 });
5915
5916 (url, handle)
5917 }
5918 }
5919
5920 #[tokio::test]
5929 async fn test_request_body_arrives_as_stream() {
5930 use camel_component_api::Body;
5931 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5932
5933 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5934 let port = listener.local_addr().unwrap().port();
5935 drop(listener);
5936
5937 let component = HttpComponent::new();
5938 let endpoint_ctx = NoOpComponentContext;
5939 let endpoint = component
5940 .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
5941 .unwrap();
5942 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5943
5944 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5945 let token = tokio_util::sync::CancellationToken::new();
5946 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5947
5948 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5949 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5950
5951 let client = reqwest::Client::new();
5952 let send_fut = client
5953 .post(format!("http://127.0.0.1:{port}/upload"))
5954 .body("hello streaming world")
5955 .send();
5956
5957 let (http_result, _) = tokio::join!(send_fut, async {
5958 if let Some(mut envelope) = rx.recv().await {
5959 assert!(
5961 matches!(envelope.exchange.input.body, Body::Stream(_)),
5962 "expected Body::Stream, got discriminant {:?}",
5963 std::mem::discriminant(&envelope.exchange.input.body)
5964 );
5965 let bytes = envelope
5967 .exchange
5968 .input
5969 .body
5970 .into_bytes(1024 * 1024)
5971 .await
5972 .unwrap();
5973 assert_eq!(&bytes[..], b"hello streaming world");
5974
5975 envelope.exchange.input.body = camel_component_api::Body::Empty;
5976 if let Some(reply_tx) = envelope.reply_tx {
5977 let _ = reply_tx.send(Ok(envelope.exchange));
5978 }
5979 }
5980 });
5981
5982 let resp = http_result.unwrap();
5983 assert_eq!(resp.status().as_u16(), 200);
5984
5985 token.cancel();
5986 }
5987
5988 #[tokio::test]
5993 async fn test_streaming_response_chunked() {
5994 use bytes::Bytes;
5995 use camel_component_api::Body;
5996 use camel_component_api::CamelError;
5997 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5998 use camel_component_api::{StreamBody, StreamMetadata};
5999 use futures::stream;
6000 use std::sync::Arc;
6001 use tokio::sync::Mutex;
6002
6003 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6004 let port = listener.local_addr().unwrap().port();
6005 drop(listener);
6006
6007 let component = HttpComponent::new();
6008 let endpoint_ctx = NoOpComponentContext;
6009 let endpoint = component
6010 .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
6011 .unwrap();
6012 let mut consumer = endpoint.create_consumer(rt()).unwrap();
6013
6014 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6015 let token = tokio_util::sync::CancellationToken::new();
6016 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6017
6018 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6019 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6020
6021 let client = reqwest::Client::new();
6022 let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
6023
6024 let (http_result, _) = tokio::join!(send_fut, async {
6025 if let Some(mut envelope) = rx.recv().await {
6026 let chunks: Vec<Result<Bytes, CamelError>> =
6028 vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
6029 let stream = Box::pin(stream::iter(chunks));
6030 envelope.exchange.input.body = Body::Stream(StreamBody {
6031 stream: Arc::new(Mutex::new(Some(stream))),
6032 metadata: StreamMetadata::default(),
6033 });
6034 if let Some(reply_tx) = envelope.reply_tx {
6035 let _ = reply_tx.send(Ok(envelope.exchange));
6036 }
6037 }
6038 });
6039
6040 let resp = http_result.unwrap();
6041 assert_eq!(resp.status().as_u16(), 200);
6042 let body = resp.text().await.unwrap();
6043 assert_eq!(body, "chunk1chunk2");
6044
6045 token.cancel();
6046 }
6047
6048 #[tokio::test]
6053 async fn test_413_when_content_length_exceeds_limit() {
6054 use camel_component_api::ConsumerContext;
6055
6056 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6057 let port = listener.local_addr().unwrap().port();
6058 drop(listener);
6059
6060 let component = HttpComponent::new();
6062 let endpoint_ctx = NoOpComponentContext;
6063 let endpoint = component
6064 .create_endpoint(
6065 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
6066 &endpoint_ctx,
6067 )
6068 .unwrap();
6069 let mut consumer = endpoint.create_consumer(rt()).unwrap();
6070
6071 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6072 let token = tokio_util::sync::CancellationToken::new();
6073 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6074
6075 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6076 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6077
6078 let client = reqwest::Client::new();
6079 let resp = client
6080 .post(format!("http://127.0.0.1:{port}/upload"))
6081 .header("Content-Length", "1000") .body("x".repeat(1000))
6083 .send()
6084 .await
6085 .unwrap();
6086
6087 assert_eq!(resp.status().as_u16(), 413);
6088
6089 token.cancel();
6090 }
6091
6092 #[tokio::test]
6096 async fn test_chunked_upload_without_content_length_bypasses_limit() {
6097 use bytes::Bytes;
6098 use camel_component_api::Body;
6099 use camel_component_api::ConsumerContext;
6100 use futures::stream;
6101
6102 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6103 let port = listener.local_addr().unwrap().port();
6104 drop(listener);
6105
6106 let component = HttpComponent::new();
6108 let endpoint_ctx = NoOpComponentContext;
6109 let endpoint = component
6110 .create_endpoint(
6111 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
6112 &endpoint_ctx,
6113 )
6114 .unwrap();
6115 let mut consumer = endpoint.create_consumer(rt()).unwrap();
6116
6117 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6118 let token = tokio_util::sync::CancellationToken::new();
6119 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6120
6121 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6122 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6123
6124 let client = reqwest::Client::new();
6125
6126 let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
6130 Ok(Bytes::from("y".repeat(50))),
6131 Ok(Bytes::from("y".repeat(50))),
6132 ];
6133 let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
6134 let send_fut = client
6135 .post(format!("http://127.0.0.1:{port}/upload"))
6136 .body(stream_body)
6137 .send();
6138
6139 let consumer_fut = async {
6140 match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
6142 Ok(Some(mut envelope)) => {
6143 assert!(
6144 matches!(envelope.exchange.input.body, Body::Stream(_)),
6145 "expected Body::Stream"
6146 );
6147 envelope.exchange.input.body = camel_component_api::Body::Empty;
6148 if let Some(reply_tx) = envelope.reply_tx {
6149 let _ = reply_tx.send(Ok(envelope.exchange));
6150 }
6151 }
6152 Ok(None) => panic!("consumer channel closed unexpectedly"),
6153 Err(_) => {
6154 }
6157 }
6158 };
6159
6160 let (http_result, _) = tokio::join!(send_fut, consumer_fut);
6161
6162 let resp = http_result.unwrap();
6163 assert_ne!(
6165 resp.status().as_u16(),
6166 413,
6167 "chunked upload must not be rejected by maxRequestBody"
6168 );
6169 assert_eq!(resp.status().as_u16(), 200);
6170
6171 token.cancel();
6172 }
6173
6174 #[test]
6175 fn test_is_private_ip_ranges() {
6176 use camel_api::is_ssrf_blocked_ip;
6177 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(
6196 &"2001:4860:4860::8888".parse().unwrap()
6197 )); }
6199
6200 #[test]
6201 fn test_title_case_header() {
6202 assert_eq!(title_case_header("content-type"), "Content-Type");
6203 assert_eq!(title_case_header("authorization"), "Authorization");
6204 assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
6205 assert_eq!(title_case_header("host"), "Host");
6206 assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
6207 assert_eq!(title_case_header("single"), "Single");
6208 assert_eq!(title_case_header(""), "");
6209 }
6210
6211 #[test]
6212 fn test_resolve_url_combines_path_and_query_sources() {
6213 let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
6214 let mut exchange = Exchange::new(Message::default());
6215 exchange.input.set_header(
6216 "CamelHttpPath",
6217 serde_json::Value::String("next".to_string()),
6218 );
6219 let url = HttpProducer::resolve_url(&exchange, &cfg);
6220 assert!(url.starts_with("http://example.com/base/next?"));
6221 assert!(url.contains("foo=bar"));
6222
6223 exchange.input.set_header(
6224 "CamelHttpUri",
6225 serde_json::Value::String("http://other.test/root".to_string()),
6226 );
6227 exchange.input.set_header(
6228 "CamelHttpQuery",
6229 serde_json::Value::String("a=1&b=2".to_string()),
6230 );
6231
6232 let override_url = HttpProducer::resolve_url(&exchange, &cfg);
6233 assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
6234 }
6235
6236 fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
6237 let mut exchange = Exchange::new(Message::default());
6238 exchange
6239 .input
6240 .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
6241 exchange.input.set_header(
6242 "CamelHttpQuery",
6243 serde_json::Value::String(query.to_string()),
6244 );
6245 exchange
6246 }
6247
6248 #[test]
6249 fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
6250 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6251 cfg.bridge_endpoint = true;
6252 cfg.query_params
6253 .insert("token".to_string(), "secret".to_string());
6254 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
6255 let url = HttpProducer::resolve_url(&exchange, &cfg);
6256 assert_eq!(url, "http://x/?token=secret");
6257 assert!(!url.contains("/foo"));
6258 assert!(!url.contains("dropme"));
6259 }
6260
6261 #[test]
6262 fn resolve_url_bridge_endpoint_false_merges_path() {
6263 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6264 cfg.bridge_endpoint = false;
6265 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
6266 let url = HttpProducer::resolve_url(&exchange, &cfg);
6267 assert!(url.contains("/foo"), "url should contain /foo: {url}");
6268 assert!(
6269 url.contains("dropme=1"),
6270 "url should contain dropme=1: {url}"
6271 );
6272 }
6273
6274 #[test]
6275 fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
6276 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6277 cfg.bridge_endpoint = true;
6278 let mut exchange = Exchange::new(Message::default());
6279 exchange.input.set_header(
6280 "CamelHttpPath",
6281 serde_json::Value::String("/foo".to_string()),
6282 );
6283 let url = HttpProducer::resolve_url(&exchange, &cfg);
6284 assert_eq!(url, "http://x");
6285 assert!(!url.contains("/foo"));
6286 }
6287
6288 #[test]
6289 fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
6290 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6291 cfg.bridge_endpoint = true;
6292 let mut exchange = Exchange::new(Message::default());
6294 exchange.input.set_header(
6295 "CamelHttpUri",
6296 serde_json::Value::String("http://dest/explicit".to_string()),
6297 );
6298 exchange.input.set_header(
6299 "CamelHttpPath",
6300 serde_json::Value::String("/foo".to_string()),
6301 );
6302 exchange.input.set_header(
6303 "CamelHttpQuery",
6304 serde_json::Value::String("x=1".to_string()),
6305 );
6306 let url = HttpProducer::resolve_url(&exchange, &cfg);
6307 assert_eq!(url, "http://x");
6311 }
6312
6313 #[test]
6314 fn test_http_producer_helpers_status_and_size_boundaries() {
6315 assert!(HttpProducer::is_ok_status(200, (200, 299)));
6316 assert!(HttpProducer::is_ok_status(299, (200, 299)));
6317 assert!(!HttpProducer::is_ok_status(199, (200, 299)));
6318 assert!(!HttpProducer::is_ok_status(300, (200, 299)));
6319
6320 assert!(!exceeds_max_response_body(10, 10));
6321 assert!(exceeds_max_response_body(11, 10));
6322 }
6323
6324 async fn setup_consumer_on_free_port(
6329 path: &str,
6330 ) -> (
6331 u16,
6332 tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
6333 tokio_util::sync::CancellationToken,
6334 ) {
6335 use camel_component_api::ConsumerContext;
6336
6337 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6338 let port = listener.local_addr().unwrap().port();
6339 drop(listener);
6340
6341 let consumer_cfg = HttpServerConfig {
6342 scheme: "http".to_string(),
6343 host: "127.0.0.1".to_string(),
6344 port,
6345 path: path.to_string(),
6346 max_request_body: 2 * 1024 * 1024,
6347 max_response_body: 10 * 1024 * 1024,
6348 max_inflight_requests: 1024,
6349 method: None,
6350 tls_config: None,
6351 };
6352 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6353
6354 let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6355 let token = tokio_util::sync::CancellationToken::new();
6356 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6357
6358 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6359 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6360
6361 (port, rx, token)
6362 }
6363
6364 #[tokio::test]
6365 async fn test_content_type_inferred_for_json_body() {
6366 let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
6367
6368 let client = reqwest::Client::new();
6369 let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
6370
6371 let (http_result, _) = tokio::join!(send_fut, async {
6372 if let Some(mut envelope) = rx.recv().await {
6373 envelope.exchange.input.body =
6374 camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
6375 if let Some(reply_tx) = envelope.reply_tx {
6376 let _ = reply_tx.send(Ok(envelope.exchange));
6377 }
6378 }
6379 });
6380
6381 let resp = http_result.unwrap();
6382 assert_eq!(resp.status().as_u16(), 200);
6383 let ct = resp
6384 .headers()
6385 .get("content-type")
6386 .expect("Content-Type header should be present");
6387 assert_eq!(ct, "application/json");
6388 let body = resp.text().await.unwrap();
6389 assert_eq!(body, r#"{"message":"hello"}"#);
6390
6391 token.cancel();
6392 }
6393
6394 #[tokio::test]
6395 async fn test_content_type_inferred_for_text_body() {
6396 let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
6397
6398 let client = reqwest::Client::new();
6399 let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
6400
6401 let (http_result, _) = tokio::join!(send_fut, async {
6402 if let Some(mut envelope) = rx.recv().await {
6403 envelope.exchange.input.body =
6404 camel_component_api::Body::Text("plain text response".to_string());
6405 if let Some(reply_tx) = envelope.reply_tx {
6406 let _ = reply_tx.send(Ok(envelope.exchange));
6407 }
6408 }
6409 });
6410
6411 let resp = http_result.unwrap();
6412 assert_eq!(resp.status().as_u16(), 200);
6413 let ct = resp
6414 .headers()
6415 .get("content-type")
6416 .expect("Content-Type header should be present");
6417 assert_eq!(ct, "text/plain; charset=utf-8");
6418 let body = resp.text().await.unwrap();
6419 assert_eq!(body, "plain text response");
6420
6421 token.cancel();
6422 }
6423
6424 #[tokio::test]
6425 async fn test_content_type_inferred_for_xml_body() {
6426 let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
6427
6428 let client = reqwest::Client::new();
6429 let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
6430
6431 let (http_result, _) = tokio::join!(send_fut, async {
6432 if let Some(mut envelope) = rx.recv().await {
6433 envelope.exchange.input.body =
6434 camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
6435 if let Some(reply_tx) = envelope.reply_tx {
6436 let _ = reply_tx.send(Ok(envelope.exchange));
6437 }
6438 }
6439 });
6440
6441 let resp = http_result.unwrap();
6442 assert_eq!(resp.status().as_u16(), 200);
6443 let ct = resp
6444 .headers()
6445 .get("content-type")
6446 .expect("Content-Type header should be present");
6447 assert_eq!(ct, "application/xml");
6448 let body = resp.text().await.unwrap();
6449 assert_eq!(body, "<root><item>value</item></root>");
6450
6451 token.cancel();
6452 }
6453
6454 #[tokio::test]
6455 async fn test_no_content_type_for_empty_body() {
6456 let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
6457
6458 let client = reqwest::Client::new();
6459 let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
6460
6461 let (http_result, _) = tokio::join!(send_fut, async {
6462 if let Some(mut envelope) = rx.recv().await {
6463 envelope.exchange.input.body = camel_component_api::Body::Empty;
6464 if let Some(reply_tx) = envelope.reply_tx {
6465 let _ = reply_tx.send(Ok(envelope.exchange));
6466 }
6467 }
6468 });
6469
6470 let resp = http_result.unwrap();
6471 assert_eq!(resp.status().as_u16(), 200);
6472 assert!(
6473 resp.headers().get("content-type").is_none(),
6474 "Empty body should not set Content-Type"
6475 );
6476
6477 token.cancel();
6478 }
6479
6480 #[tokio::test]
6481 async fn test_no_content_type_for_raw_bytes_body() {
6482 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
6483
6484 let client = reqwest::Client::new();
6485 let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
6486
6487 let (http_result, _) = tokio::join!(send_fut, async {
6488 if let Some(mut envelope) = rx.recv().await {
6489 envelope.exchange.input.body =
6490 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
6491 if let Some(reply_tx) = envelope.reply_tx {
6492 let _ = reply_tx.send(Ok(envelope.exchange));
6493 }
6494 }
6495 });
6496
6497 let resp = http_result.unwrap();
6498 assert_eq!(resp.status().as_u16(), 200);
6499 assert!(
6500 resp.headers().get("content-type").is_none(),
6501 "Raw Bytes body should not set Content-Type"
6502 );
6503
6504 token.cancel();
6505 }
6506
6507 #[tokio::test]
6508 async fn test_content_type_from_stream_metadata() {
6509 use camel_component_api::{StreamBody, StreamMetadata};
6510 use futures::stream;
6511
6512 let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
6513
6514 let client = reqwest::Client::new();
6515 let send_fut = client
6516 .get(format!("http://127.0.0.1:{port}/stream-ct"))
6517 .send();
6518
6519 let (http_result, _) = tokio::join!(send_fut, async {
6520 if let Some(mut envelope) = rx.recv().await {
6521 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
6522 vec![Ok(bytes::Bytes::from("audio data"))];
6523 let stream = Box::pin(stream::iter(chunks));
6524 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
6525 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
6526 metadata: StreamMetadata {
6527 size_hint: None,
6528 content_type: Some("audio/mpeg".to_string()),
6529 origin: None,
6530 },
6531 });
6532 if let Some(reply_tx) = envelope.reply_tx {
6533 let _ = reply_tx.send(Ok(envelope.exchange));
6534 }
6535 }
6536 });
6537
6538 let resp = http_result.unwrap();
6539 assert_eq!(resp.status().as_u16(), 200);
6540 let ct = resp
6541 .headers()
6542 .get("content-type")
6543 .expect("Content-Type header should be present");
6544 assert_eq!(ct, "audio/mpeg");
6545 let body = resp.text().await.unwrap();
6546 assert_eq!(body, "audio data");
6547
6548 token.cancel();
6549 }
6550
6551 #[tokio::test]
6552 async fn test_user_content_type_overrides_inferred() {
6553 let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
6554
6555 let client = reqwest::Client::new();
6556 let send_fut = client
6557 .get(format!("http://127.0.0.1:{port}/override-ct"))
6558 .send();
6559
6560 let (http_result, _) = tokio::join!(send_fut, async {
6561 if let Some(mut envelope) = rx.recv().await {
6562 envelope.exchange.input.body =
6563 camel_component_api::Body::Json(serde_json::json!({"ok": true}));
6564 envelope.exchange.input.set_header(
6565 "Content-Type",
6566 serde_json::Value::String("text/html".to_string()),
6567 );
6568 if let Some(reply_tx) = envelope.reply_tx {
6569 let _ = reply_tx.send(Ok(envelope.exchange));
6570 }
6571 }
6572 });
6573
6574 let resp = http_result.unwrap();
6575 assert_eq!(resp.status().as_u16(), 200);
6576 let ct = resp
6577 .headers()
6578 .get("content-type")
6579 .expect("Content-Type header should be present");
6580 assert_eq!(
6581 ct, "text/html",
6582 "User-set Content-Type should take precedence over inferred type"
6583 );
6584
6585 token.cancel();
6586 }
6587
6588 #[tokio::test]
6589 async fn test_user_content_type_with_bytes_body() {
6590 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
6591
6592 let client = reqwest::Client::new();
6593 let send_fut = client
6594 .get(format!("http://127.0.0.1:{port}/bytes-ct"))
6595 .send();
6596
6597 let (http_result, _) = tokio::join!(send_fut, async {
6598 if let Some(mut envelope) = rx.recv().await {
6599 envelope.exchange.input.body =
6600 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
6601 envelope.exchange.input.set_header(
6602 "Content-Type",
6603 serde_json::Value::String("application/json".to_string()),
6604 );
6605 if let Some(reply_tx) = envelope.reply_tx {
6606 let _ = reply_tx.send(Ok(envelope.exchange));
6607 }
6608 }
6609 });
6610
6611 let resp = http_result.unwrap();
6612 assert_eq!(resp.status().as_u16(), 200);
6613 let ct = resp
6614 .headers()
6615 .get("content-type")
6616 .expect("Content-Type header should be present for Bytes body with user header");
6617 assert_eq!(
6618 ct, "application/json",
6619 "User Content-Type should be sent for Bytes body"
6620 );
6621
6622 token.cancel();
6623 }
6624
6625 #[tokio::test]
6630 async fn monitor_task_silent_on_clean_exit() {
6631 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
6632 monitor_axum_task(
6634 handle,
6635 "127.0.0.1:0".to_string(),
6636 noop_rt(),
6637 "test-monitor".into(),
6638 )
6639 .await;
6640 }
6641
6642 #[tokio::test]
6643 async fn monitor_task_handles_panicked_task() {
6644 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
6645 panic!("simulated server crash");
6646 });
6647 monitor_axum_task(
6649 handle,
6650 "127.0.0.1:9999".to_string(),
6651 noop_rt(),
6652 "test-monitor".into(),
6653 )
6654 .await;
6655 }
6656
6657 #[test]
6662 fn http_auth_basic_debug_redacts_password() {
6663 let auth = HttpAuth::Basic {
6664 username: "admin".to_string(),
6665 password: "hunter2".to_string(),
6666 };
6667 let debug = format!("{:?}", auth);
6668 assert!(
6669 !debug.contains("hunter2"),
6670 "password must be redacted: {debug}"
6671 );
6672 assert!(debug.contains("admin"), "username should appear: {debug}");
6673 }
6674
6675 #[test]
6676 fn http_auth_bearer_debug_redacts_token() {
6677 let auth = HttpAuth::Bearer {
6678 token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
6679 };
6680 let debug = format!("{:?}", auth);
6681 assert!(
6682 !debug.contains("eyJhbGci"),
6683 "token must be redacted: {debug}"
6684 );
6685 }
6686
6687 #[test]
6688 fn http_auth_none_debug_shows_variant() {
6689 let debug = format!("{:?}", HttpAuth::None);
6690 assert!(
6691 debug.contains("None"),
6692 "None variant should appear: {debug}"
6693 );
6694 }
6695
6696 #[test]
6697 fn http_endpoint_config_debug_redacts_auth_credentials() {
6698 let config = HttpEndpointConfig::from_uri(
6699 "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
6700 )
6701 .unwrap();
6702 let debug = format!("{:?}", config);
6703 assert!(
6704 !debug.contains("secret123"),
6705 "password must be redacted in HttpEndpointConfig debug: {debug}"
6706 );
6707 }
6708
6709 use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
6714 use tower_http::services::ServeDir;
6715
6716 fn make_test_registry() -> HttpRouteRegistry {
6717 HttpRouteRegistry::new()
6718 }
6719
6720 fn make_test_state(registry: HttpRouteRegistry) -> AppState {
6721 AppState {
6722 registry,
6723 max_request_body: 2 * 1024 * 1024,
6724 max_response_body: 10 * 1024 * 1024,
6725 inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
6726 }
6727 }
6728
6729 #[allow(clippy::await_holding_lock)]
6730 #[tokio::test]
6731 async fn test_static_file_serving_serves_file_contents() {
6732 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6733 ServerRegistry::reset();
6734
6735 let temp_dir =
6737 std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
6738 std::fs::create_dir_all(&temp_dir).unwrap();
6739 std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
6740 std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
6741
6742 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
6743
6744 let registry = make_test_registry();
6745 let serve_dir = ServeDir::new(&canonical_dir)
6746 .precompressed_gzip()
6747 .precompressed_br()
6748 .append_index_html_on_directories(true);
6749
6750 let mount = StaticMount {
6751 mount_path: "/".to_string(),
6752 mode: MountMode::Static,
6753 dir: canonical_dir.clone(),
6754 cache_control: "public, max-age=3600".to_string(),
6755 error_pages: std::collections::HashMap::new(),
6756 serve_dir,
6757 };
6758 registry.register_static_mount(mount).await.unwrap();
6759
6760 let state = make_test_state(registry);
6761
6762 let req = Request::builder()
6764 .uri("/hello.txt")
6765 .body(AxumBody::empty())
6766 .unwrap();
6767 let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
6768 assert_eq!(resp.status(), StatusCode::OK);
6769 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6770 .await
6771 .unwrap();
6772 assert_eq!(&body[..], b"Hello, static world!");
6773
6774 let req = Request::builder()
6776 .uri("/style.css")
6777 .body(AxumBody::empty())
6778 .unwrap();
6779 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
6780 assert_eq!(resp.status(), StatusCode::OK);
6781 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6782 .await
6783 .unwrap();
6784 assert_eq!(&body[..], b"body { color: red; }");
6785
6786 let req = Request::builder()
6788 .uri("/missing.txt")
6789 .body(AxumBody::empty())
6790 .unwrap();
6791 let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
6792 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
6793
6794 std::fs::remove_dir_all(&temp_dir).ok();
6796 }
6797
6798 #[allow(clippy::await_holding_lock)]
6799 #[tokio::test]
6800 async fn test_spa_fallback_serves_index_for_unknown_paths() {
6801 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6802 ServerRegistry::reset();
6803
6804 let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
6805 std::fs::create_dir_all(&temp_dir).unwrap();
6806 std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
6807 std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
6808
6809 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
6810
6811 let registry = make_test_registry();
6812 let serve_dir = ServeDir::new(&canonical_dir)
6813 .precompressed_gzip()
6814 .precompressed_br()
6815 .append_index_html_on_directories(true);
6816
6817 let mount = StaticMount {
6818 mount_path: "/".to_string(),
6819 mode: MountMode::Spa,
6820 dir: canonical_dir.clone(),
6821 cache_control: "public, max-age=0".to_string(),
6822 error_pages: std::collections::HashMap::new(),
6823 serve_dir,
6824 };
6825 registry.register_static_mount(mount).await.unwrap();
6827
6828 let state = make_test_state(registry);
6829
6830 let req = Request::builder()
6832 .method("GET")
6833 .uri("/dashboard")
6834 .header("Accept", "text/html")
6835 .body(AxumBody::empty())
6836 .unwrap();
6837 let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
6838 assert_eq!(resp.status(), StatusCode::OK);
6839 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6840 .await
6841 .unwrap();
6842 assert_eq!(&body[..], b"<h1>SPA App</h1>");
6843
6844 let req = Request::builder()
6846 .method("GET")
6847 .uri("/app.js")
6848 .body(AxumBody::empty())
6849 .unwrap();
6850 let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
6851 assert_eq!(resp.status(), StatusCode::OK);
6852 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6853 .await
6854 .unwrap();
6855 assert_eq!(&body[..], b"console.log('app')");
6856
6857 let req = Request::builder()
6859 .method("GET")
6860 .uri("/api/data")
6861 .header("Accept", "application/json")
6862 .body(AxumBody::empty())
6863 .unwrap();
6864 let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
6865 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
6866
6867 let req = Request::builder()
6869 .method("GET")
6870 .uri("/style.css")
6871 .header("Accept", "text/html")
6872 .body(AxumBody::empty())
6873 .unwrap();
6874 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
6875 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
6876
6877 std::fs::remove_dir_all(&temp_dir).ok();
6879 }
6880
6881 #[allow(clippy::await_holding_lock)]
6888 async fn run_conditional_get_returns_304(mode: MountMode) {
6889 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6890 ServerRegistry::reset();
6891
6892 let temp_dir = std::env::temp_dir().join(format!(
6893 "http_cond_get_{}_{}",
6894 if mode == MountMode::Spa {
6895 "spa"
6896 } else {
6897 "static"
6898 },
6899 std::process::id()
6900 ));
6901 std::fs::create_dir_all(&temp_dir).unwrap();
6902 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
6903
6904 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
6905
6906 let registry = make_test_registry();
6907 let serve_dir = ServeDir::new(&canonical_dir)
6908 .precompressed_gzip()
6909 .precompressed_br()
6910 .append_index_html_on_directories(true);
6911
6912 let mount = StaticMount {
6913 mount_path: "/".to_string(),
6914 mode,
6915 dir: canonical_dir.clone(),
6916 cache_control: "public, max-age=3600".to_string(),
6917 error_pages: std::collections::HashMap::new(),
6918 serve_dir,
6919 };
6920 registry.register_static_mount(mount).await.unwrap();
6921
6922 let state = make_test_state(registry);
6923
6924 let req = Request::builder()
6926 .method("GET")
6927 .uri("/index.html")
6928 .body(AxumBody::empty())
6929 .unwrap();
6930 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
6931 assert_eq!(
6932 resp.status(),
6933 StatusCode::OK,
6934 "first GET should return 200, got {}",
6935 resp.status()
6936 );
6937 assert!(
6939 resp.headers().contains_key(http::header::CACHE_CONTROL),
6940 "200 response missing Cache-Control"
6941 );
6942 let etag = resp
6943 .headers()
6944 .get(http::header::ETAG)
6945 .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
6946 .clone();
6947 let last_modified = resp
6948 .headers()
6949 .get(http::header::LAST_MODIFIED)
6950 .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
6951 .clone();
6952 let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
6954 .await
6955 .unwrap();
6956
6957 let req = Request::builder()
6961 .method("GET")
6962 .uri("/index.html")
6963 .header(http::header::IF_NONE_MATCH, etag.clone())
6964 .body(AxumBody::empty())
6965 .unwrap();
6966 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
6967 assert_eq!(
6968 resp.status(),
6969 StatusCode::NOT_MODIFIED,
6970 "If-None-Match with matching ETag should return 304, got {}",
6971 resp.status()
6972 );
6973 assert!(
6975 resp.headers().contains_key(http::header::CACHE_CONTROL),
6976 "304 (If-None-Match) missing Cache-Control"
6977 );
6978 assert_eq!(
6981 resp.headers().get(http::header::ETAG),
6982 Some(&etag),
6983 "304 (If-None-Match) must echo the ETag validator"
6984 );
6985 assert_eq!(
6986 resp.headers().get(http::header::LAST_MODIFIED),
6987 Some(&last_modified),
6988 "304 (If-None-Match) must carry Last-Modified"
6989 );
6990
6991 let req = Request::builder()
6993 .method("GET")
6994 .uri("/index.html")
6995 .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
6996 .body(AxumBody::empty())
6997 .unwrap();
6998 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
6999 assert_eq!(
7000 resp.status(),
7001 StatusCode::NOT_MODIFIED,
7002 "If-Modified-Since with matching timestamp should return 304, got {}",
7003 resp.status()
7004 );
7005 assert!(
7006 resp.headers().contains_key(http::header::CACHE_CONTROL),
7007 "304 (If-Modified-Since) missing Cache-Control"
7008 );
7009 assert_eq!(
7010 resp.headers().get(http::header::ETAG),
7011 Some(&etag),
7012 "304 (If-Modified-Since) must carry the ETag validator"
7013 );
7014 assert_eq!(
7015 resp.headers().get(http::header::LAST_MODIFIED),
7016 Some(&last_modified),
7017 "304 (If-Modified-Since) must echo Last-Modified"
7018 );
7019
7020 let req = Request::builder()
7026 .method("GET")
7027 .uri("/index.html")
7028 .header(
7029 http::header::IF_MODIFIED_SINCE,
7030 "Wed, 21 Oct 2000 07:28:00 GMT",
7031 )
7032 .body(AxumBody::empty())
7033 .unwrap();
7034 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
7035 assert_eq!(
7036 resp.status(),
7037 StatusCode::OK,
7038 "past If-Modified-Since should return 200 (file modified after it), got {}",
7039 resp.status()
7040 );
7041
7042 std::fs::remove_dir_all(&temp_dir).ok();
7044 }
7045
7046 #[tokio::test]
7047 async fn test_conditional_get_returns_304_static_mode() {
7048 run_conditional_get_returns_304(MountMode::Static).await;
7049 }
7050
7051 #[tokio::test]
7052 async fn test_conditional_get_returns_304_spa_mode() {
7053 run_conditional_get_returns_304(MountMode::Spa).await;
7054 }
7055
7056 #[allow(clippy::await_holding_lock)]
7057 #[tokio::test]
7058 async fn test_error_page_mapping_serves_custom_404() {
7059 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7060 ServerRegistry::reset();
7061
7062 let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
7063 let errors_dir = temp_dir.join("errors");
7064 std::fs::create_dir_all(&errors_dir).unwrap();
7065 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
7066 std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
7067
7068 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
7069 let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
7070
7071 let registry = make_test_registry();
7072 let serve_dir = ServeDir::new(&canonical_dir)
7073 .precompressed_gzip()
7074 .precompressed_br()
7075 .append_index_html_on_directories(true);
7076
7077 let mut error_pages = std::collections::HashMap::new();
7078 error_pages.insert(404, canonical_404);
7079
7080 let mount = StaticMount {
7081 mount_path: "/".to_string(),
7082 mode: MountMode::Static,
7083 dir: canonical_dir.clone(),
7084 cache_control: "public, max-age=0".to_string(),
7085 error_pages,
7086 serve_dir,
7087 };
7088 registry.register_static_mount(mount).await.unwrap();
7089
7090 let state = make_test_state(registry);
7091
7092 let req = Request::builder()
7094 .method("GET")
7095 .uri("/missing.html")
7096 .body(AxumBody::empty())
7097 .unwrap();
7098 let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
7099 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7100 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7101 .await
7102 .unwrap();
7103 assert_eq!(&body[..], b"<h1>Custom 404</h1>");
7104
7105 let req = Request::builder()
7107 .method("GET")
7108 .uri("/index.html")
7109 .body(AxumBody::empty())
7110 .unwrap();
7111 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
7112 assert_eq!(resp.status(), StatusCode::OK);
7113 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7114 .await
7115 .unwrap();
7116 assert_eq!(&body[..], b"<h1>Home</h1>");
7117
7118 std::fs::remove_dir_all(&temp_dir).ok();
7120 }
7121
7122 #[tokio::test]
7123 async fn http_consumer_returns_body_and_code_on_stop() {
7124 use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
7125 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
7126 use tower::ServiceExt;
7127
7128 let set_body_step = CompiledStep::Process {
7130 kind_hint: camel_api::SpanKindHint::Internal,
7131 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
7132 ex.input.body = Body::Text("nope".into());
7133 Box::pin(async move { Ok(ex) })
7134 }),
7135 body_contract: None,
7136 lifecycle: None,
7137 label: None,
7138 };
7139 let set_status_step = CompiledStep::Process {
7140 kind_hint: camel_api::SpanKindHint::Internal,
7141 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
7142 ex.input.set_header(
7143 "CamelHttpResponseCode",
7144 serde_json::Value::Number(409.into()),
7145 );
7146 Box::pin(async move { Ok(ex) })
7147 }),
7148 body_contract: None,
7149 lifecycle: None,
7150 label: None,
7151 };
7152 let pipeline = compose_pipeline_with_handler(
7153 vec![set_body_step, set_status_step, CompiledStep::Stop],
7154 None,
7155 PipelineRuntimeCtx::compile_time(),
7156 );
7157
7158 let ex = Exchange::new(Message::default());
7159 let result = pipeline.oneshot(ex).await;
7160 assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
7161 let returned = result.unwrap();
7162 assert_eq!(returned.input.body.as_text(), Some("nope"));
7163 assert_eq!(
7164 returned
7165 .input
7166 .header("CamelHttpResponseCode")
7167 .and_then(|v| v.as_u64()),
7168 Some(409)
7169 );
7170 }
7171
7172 #[tokio::test]
7173 async fn http_consumer_returns_200_when_body_empty_on_stop() {
7174 use camel_api::{Exchange, Message};
7182 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
7183 use tower::ServiceExt;
7184
7185 let pipeline = compose_pipeline_with_handler(
7186 vec![CompiledStep::Stop],
7187 None,
7188 PipelineRuntimeCtx::compile_time(),
7189 );
7190 let ex = Exchange::new(Message::default());
7191 let result = pipeline.oneshot(ex).await;
7192 assert!(result.is_ok(), "Stop with empty body arrives as Ok");
7193 }
7196
7197 async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
7205 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7206 let port = listener.local_addr().unwrap().port();
7207 let registry = HttpRouteRegistry::new();
7208 tokio::spawn(run_axum_server(
7209 listener,
7210 registry.clone(),
7211 2 * 1024 * 1024,
7212 10 * 1024 * 1024,
7213 Arc::new(tokio::sync::Semaphore::new(1024)),
7214 test_rt(),
7215 "test-route".into(),
7216 ));
7217 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7219 (port, registry)
7220 }
7221
7222 fn spawn_responder(
7227 mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
7228 status: u16,
7229 body: String,
7230 ) -> tokio::task::JoinHandle<()> {
7231 tokio::spawn(async move {
7232 if let Some(envelope) = rx.recv().await {
7233 let _ = envelope.reply_tx.send(HttpReply {
7234 status,
7235 headers: vec![],
7236 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
7237 });
7238 }
7239 })
7240 }
7241
7242 #[tokio::test]
7243 async fn method_aware_dispatch_same_path_different_verbs() {
7244 let (port, registry) = spawn_test_server().await;
7245
7246 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7251 registry
7252 .register_rest_endpoint(
7253 "GET".into(),
7254 vec![PathSegment::Literal("users".into())],
7255 get_tx,
7256 )
7257 .await;
7258
7259 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7260 registry
7261 .register_rest_endpoint(
7262 "POST".into(),
7263 vec![PathSegment::Literal("users".into())],
7264 post_tx,
7265 )
7266 .await;
7267
7268 let get_handle = spawn_responder(get_rx, 200, "list".into());
7269 let post_handle = spawn_responder(post_rx, 201, "create".into());
7270
7271 let client = reqwest::Client::new();
7272
7273 let resp = client
7275 .get(format!("http://127.0.0.1:{port}/users"))
7276 .send()
7277 .await
7278 .unwrap();
7279 assert_eq!(resp.status().as_u16(), 200);
7280 let body = resp.text().await.unwrap();
7281 assert_eq!(body, "list");
7282
7283 let resp = client
7285 .post(format!("http://127.0.0.1:{port}/users"))
7286 .send()
7287 .await
7288 .unwrap();
7289 assert_eq!(resp.status().as_u16(), 201);
7290 let body = resp.text().await.unwrap();
7291 assert_eq!(body, "create");
7292
7293 let _ = tokio::join!(get_handle, post_handle);
7294 }
7295
7296 #[tokio::test]
7297 async fn method_aware_dispatch_templated_path_extracts_params() {
7298 let (port, registry) = spawn_test_server().await;
7299
7300 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7304 registry
7305 .register_rest_endpoint(
7306 "GET".into(),
7307 vec![
7308 PathSegment::Literal("users".into()),
7309 PathSegment::Param("id".into()),
7310 ],
7311 tx,
7312 )
7313 .await;
7314
7315 let handle = tokio::spawn(async move {
7318 if let Some(envelope) = rx.recv().await {
7319 let id = envelope.path_params.get("id").cloned().unwrap_or_default();
7320 let _ = envelope.reply_tx.send(HttpReply {
7321 status: 200,
7322 headers: vec![],
7323 body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
7324 });
7325 }
7326 });
7327
7328 let client = reqwest::Client::new();
7329 let resp = client
7330 .get(format!("http://127.0.0.1:{port}/users/42"))
7331 .send()
7332 .await
7333 .unwrap();
7334 assert_eq!(resp.status().as_u16(), 200);
7335 let body = resp.text().await.unwrap();
7336 assert_eq!(body, "id=42");
7337
7338 let _ = handle.await;
7339 }
7340
7341 #[tokio::test]
7342 async fn method_aware_dispatch_unmatched_method_falls_through() {
7343 let (port, _registry) = spawn_test_server().await;
7348
7349 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7351 _registry
7352 .register_rest_endpoint(
7353 "GET".into(),
7354 vec![PathSegment::Literal("users".into())],
7355 get_tx,
7356 )
7357 .await;
7358
7359 let drain = tokio::spawn(async move {
7362 let mut get_rx = get_rx;
7363 while get_rx.recv().await.is_some() {}
7364 });
7365
7366 let client = reqwest::Client::new();
7367 let resp = client
7368 .delete(format!("http://127.0.0.1:{port}/users"))
7369 .send()
7370 .await
7371 .unwrap();
7372 assert_eq!(resp.status().as_u16(), 404);
7373
7374 drop(drain);
7375 }
7376
7377 #[tokio::test]
7378 async fn regression_legacy_exact_api_route_still_works() {
7379 let (port, registry) = spawn_test_server().await;
7384
7385 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7386 registry.register_api_route("/legacy/path".into(), tx).await;
7387
7388 let handle = tokio::spawn(async move {
7389 if let Some(envelope) = rx.recv().await {
7390 let _ = envelope.reply_tx.send(HttpReply {
7391 status: 200,
7392 headers: vec![],
7393 body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
7394 });
7395 }
7396 });
7397
7398 let client = reqwest::Client::new();
7399 let resp = client
7400 .get(format!("http://127.0.0.1:{port}/legacy/path"))
7401 .send()
7402 .await
7403 .unwrap();
7404 assert_eq!(resp.status().as_u16(), 200);
7405 let body = resp.text().await.unwrap();
7406 assert_eq!(body, "legacy ok");
7407
7408 let _ = handle.await;
7409 }
7410
7411 #[allow(clippy::await_holding_lock)]
7412 #[tokio::test]
7413 async fn regression_static_mount_still_works() {
7414 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7418 ServerRegistry::reset();
7419
7420 let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
7421 std::fs::create_dir_all(&temp_dir).unwrap();
7422 std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
7423 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
7424
7425 let registry = make_test_registry();
7426 let serve_dir = ServeDir::new(&canonical_dir)
7427 .precompressed_gzip()
7428 .precompressed_br()
7429 .append_index_html_on_directories(true);
7430 let mount = StaticMount {
7431 mount_path: "/".to_string(),
7432 mode: MountMode::Static,
7433 dir: canonical_dir.clone(),
7434 cache_control: "public, max-age=3600".to_string(),
7435 error_pages: std::collections::HashMap::new(),
7436 serve_dir,
7437 };
7438 registry.register_static_mount(mount).await.unwrap();
7439
7440 let state = make_test_state(registry);
7441 let req = Request::builder()
7442 .uri("/regress.txt")
7443 .body(AxumBody::empty())
7444 .unwrap();
7445 let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
7446 assert_eq!(resp.status(), StatusCode::OK);
7447 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7448 .await
7449 .unwrap();
7450 assert_eq!(&body[..], b"static works");
7451
7452 std::fs::remove_dir_all(&temp_dir).ok();
7453 }
7454
7455 #[tokio::test]
7464 async fn deregister_one_method_keeps_sibling_verbs() {
7465 let (port, registry) = spawn_test_server().await;
7469
7470 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7471 registry
7472 .register_rest_endpoint(
7473 "GET".into(),
7474 vec![PathSegment::Literal("users".into())],
7475 get_tx,
7476 )
7477 .await;
7478
7479 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7480 registry
7481 .register_rest_endpoint(
7482 "POST".into(),
7483 vec![PathSegment::Literal("users".into())],
7484 post_tx,
7485 )
7486 .await;
7487
7488 let drain = tokio::spawn(async move {
7490 let mut get_rx = get_rx;
7491 while get_rx.recv().await.is_some() {}
7492 });
7493
7494 registry.unregister_rest_endpoint("GET", "/users").await;
7496 drop(drain);
7497
7498 let post_handle = spawn_responder(post_rx, 201, "create".into());
7499
7500 let client = reqwest::Client::new();
7501 let resp = client
7503 .post(format!("http://127.0.0.1:{port}/users"))
7504 .send()
7505 .await
7506 .unwrap();
7507 assert_eq!(resp.status().as_u16(), 201);
7508 assert_eq!(resp.text().await.unwrap(), "create");
7509
7510 let _ = post_handle.await;
7511 }
7512
7513 #[tokio::test]
7514 async fn dispatch_exact_legacy_beats_rest_template() {
7515 let (port, registry) = spawn_test_server().await;
7520
7521 let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7523 registry
7524 .register_api_route("/api/users".into(), exact_tx)
7525 .await;
7526 let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
7527
7528 let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7530 registry
7531 .register_rest_endpoint(
7532 "GET".into(),
7533 vec![
7534 PathSegment::Literal("api".into()),
7535 PathSegment::Param("resource".into()),
7536 ],
7537 tpl_tx,
7538 )
7539 .await;
7540 let _tpl_drain = tokio::spawn(async move {
7546 let mut tpl_rx = tpl_rx;
7547 if let Some(env) = tpl_rx.recv().await {
7548 let _ = env.reply_tx.send(HttpReply {
7549 status: 200,
7550 headers: vec![],
7551 body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
7552 });
7553 }
7554 });
7555
7556 let client = reqwest::Client::new();
7557 let resp = client
7558 .get(format!("http://127.0.0.1:{port}/api/users"))
7559 .send()
7560 .await
7561 .unwrap();
7562 assert_eq!(resp.status().as_u16(), 200);
7563 assert_eq!(resp.text().await.unwrap(), "exact");
7565
7566 let _ = exact_handle.await;
7567 }
7568
7569 #[tokio::test]
7570 async fn ambiguous_rest_templates_return_500_not_silent_404() {
7571 let (port, registry) = spawn_test_server().await;
7576
7577 let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7578 registry
7579 .register_rest_endpoint(
7580 "GET".into(),
7581 vec![
7582 PathSegment::Literal("users".into()),
7583 PathSegment::Param("id".into()),
7584 ],
7585 a_tx,
7586 )
7587 .await;
7588
7589 let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7590 registry
7591 .register_rest_endpoint(
7592 "GET".into(),
7593 vec![
7594 PathSegment::Literal("users".into()),
7595 PathSegment::Param("name".into()),
7596 ],
7597 b_tx,
7598 )
7599 .await;
7600
7601 let client = reqwest::Client::new();
7602 let resp = client
7603 .get(format!("http://127.0.0.1:{port}/users/42"))
7604 .send()
7605 .await
7606 .unwrap();
7607 assert_eq!(resp.status().as_u16(), 500);
7609 }
7610
7611 #[test]
7612 fn from_uri_round_trips_templated_path_with_http_method() {
7613 use crate::UriConfig;
7619 let cfg =
7620 HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
7621 assert_eq!(cfg.host, "0.0.0.0");
7622 assert_eq!(cfg.port, 8080);
7623 assert_eq!(cfg.path, "/users/{id}");
7624 assert_eq!(cfg.method.as_deref(), Some("GET"));
7625
7626 let cfg_lc =
7628 HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
7629 assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
7630 assert_eq!(cfg_lc.path, "/orders");
7631 }
7632
7633 #[test]
7638 fn type_conversion_failed_maps_to_400() {
7639 let reply = pipeline_error_to_reply(
7640 CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
7641 "/api/users",
7642 );
7643 assert_eq!(reply.status, 400);
7644 let ct = reply
7646 .headers
7647 .iter()
7648 .find(|(k, _)| k == "Content-Type")
7649 .map(|(_, v)| v.as_str());
7650 assert_eq!(ct, Some("application/json"));
7651 let body = match &reply.body {
7653 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
7654 _ => panic!("expected bytes body"),
7655 };
7656 assert!(body.contains("\"error\""));
7657 assert!(body.contains("bad_request"));
7658 assert!(body.contains("invalid JSON at line 1"));
7659 }
7660
7661 #[test]
7662 fn other_error_still_maps_to_500() {
7663 let reply =
7664 pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
7665 assert_eq!(reply.status, 500);
7666 }
7667
7668 #[test]
7669 fn unauthenticated_maps_to_401() {
7670 let reply = pipeline_error_to_reply(
7671 CamelError::Unauthenticated("no token".to_string()),
7672 "/api/users",
7673 );
7674 assert_eq!(reply.status, 401);
7675 }
7676
7677 #[test]
7678 fn unauthorized_maps_to_403() {
7679 let reply = pipeline_error_to_reply(
7680 CamelError::Unauthorized("forbidden".to_string()),
7681 "/api/users",
7682 );
7683 assert_eq!(reply.status, 403);
7684 }
7685
7686 #[test]
7687 fn validation_error_maps_to_400() {
7688 let reply = pipeline_error_to_reply(
7689 CamelError::ValidationError("body does not match schema".to_string()),
7690 "/api/users",
7691 );
7692 assert_eq!(reply.status, 400);
7693 let ct = reply
7694 .headers
7695 .iter()
7696 .find(|(k, _)| k == "Content-Type")
7697 .map(|(_, v)| v.as_str());
7698 assert_eq!(ct, Some("application/json"));
7699 let body = match &reply.body {
7700 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
7701 _ => panic!("expected bytes body"),
7702 };
7703 assert!(body.contains("\"error\""));
7704 assert!(body.contains("validation_error"));
7705 assert!(body.contains("body does not match schema"));
7706 }
7707
7708 #[test]
7709 fn https_consumer_without_tls_cert_errors() {
7710 let endpoint = HttpEndpoint {
7711 uri: "https://0.0.0.0:8443/api".to_string(),
7712 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
7713 server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
7714 client: reqwest::Client::new(),
7715 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
7716 PINNED_CLIENT_TTL,
7717 PINNED_CLIENT_MAX_ENTRIES,
7718 )),
7719 http_config: HttpConfig::default(),
7720 };
7721 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7722 let result = endpoint.create_consumer(rt);
7723 assert!(result.is_err(), "expected error for https without tls cert");
7724 if let Err(e) = result {
7725 let msg = e.to_string();
7726 assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
7727 }
7728 }
7729
7730 #[test]
7731 fn http_consumer_with_tls_config_errors() {
7732 let endpoint = HttpEndpoint {
7733 uri: "http://0.0.0.0:8080/api".to_string(),
7734 config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
7735 server_config: HttpServerConfig::from_uri(
7736 "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
7737 )
7738 .unwrap(),
7739 client: reqwest::Client::new(),
7740 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
7741 PINNED_CLIENT_TTL,
7742 PINNED_CLIENT_MAX_ENTRIES,
7743 )),
7744 http_config: HttpConfig::default(),
7745 };
7746 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7747 let result = endpoint.create_consumer(rt);
7748 assert!(result.is_err(), "expected error for http with tls config");
7749 if let Err(e) = result {
7750 let msg = e.to_string();
7751 assert!(msg.contains("https"), "error must mention https: {msg}");
7752 }
7753 }
7754
7755 #[test]
7756 fn https_consumer_with_partial_tls_cert_only_errors() {
7757 let server_config =
7760 HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
7761 assert!(
7762 server_config.tls_config.is_none(),
7763 "partial tlsCert must not create ServerTlsConfig"
7764 );
7765 let endpoint = HttpEndpoint {
7766 uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
7767 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
7768 .unwrap(),
7769 server_config,
7770 client: reqwest::Client::new(),
7771 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
7772 PINNED_CLIENT_TTL,
7773 PINNED_CLIENT_MAX_ENTRIES,
7774 )),
7775 http_config: HttpConfig::default(),
7776 };
7777 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7778 let result = endpoint.create_consumer(rt);
7779 assert!(
7780 result.is_err(),
7781 "must error: https:// requires both tlsCert and tlsKey"
7782 );
7783 }
7784
7785 #[test]
7786 fn load_tls_config_parses_valid_pem() {
7787 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7789 use camel_component_api::test_support::tls;
7790 let (_, cert_pem, key_pem) = tls::gen_server_cert();
7791 let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
7792 let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
7793
7794 let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
7795 assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
7796 }
7797
7798 #[tokio::test(flavor = "multi_thread")]
7799 #[allow(clippy::await_holding_lock)]
7800 async fn consumer_tls_handshake_roundtrip() {
7801 use camel_component_api::test_support::tls;
7802 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7803
7804 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7806
7807 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7809
7810 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
7812 let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
7813 let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
7814 let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
7815
7816 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7818 let port = probe.local_addr().unwrap().port();
7819 drop(probe);
7820
7821 ServerRegistry::reset();
7822
7823 let component = HttpComponent::new();
7825 let endpoint_ctx = NoOpComponentContext;
7826 let uri = format!(
7827 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
7828 cert_path.to_string_lossy(),
7829 key_path.to_string_lossy(),
7830 );
7831 let endpoint = component
7832 .create_endpoint(&uri, &endpoint_ctx)
7833 .expect("create TLS endpoint");
7834 let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
7835
7836 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7838 let token = tokio_util::sync::CancellationToken::new();
7839 let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
7840 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7841
7842 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
7844
7845 let ca_bytes = std::fs::read(&ca_path).unwrap();
7847 let client = reqwest::Client::builder()
7848 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
7849 .build()
7850 .unwrap();
7851
7852 let send_fut = client
7853 .post(format!("https://localhost:{port}/test"))
7854 .body("ping")
7855 .send();
7856
7857 let (http_result, _) = tokio::join!(send_fut, async {
7859 if let Some(mut envelope) = rx.recv().await {
7860 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
7861 if let Some(reply_tx) = envelope.reply_tx {
7862 let _ = reply_tx.send(Ok(envelope.exchange));
7863 }
7864 }
7865 });
7866
7867 let resp = http_result.expect("TLS handshake + request must succeed");
7868
7869 assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
7870 let body = resp.text().await.unwrap();
7871 assert_eq!(body, "pong");
7872
7873 token.cancel();
7874 }
7875
7876 #[tokio::test(flavor = "multi_thread")]
7877 #[allow(clippy::await_holding_lock)]
7878 async fn consumer_tls_rejects_client_without_ca() {
7879 use camel_component_api::test_support::tls;
7880 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7881
7882 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7883
7884 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7886
7887 let (_, cert_pem, key_pem) = tls::gen_server_cert();
7888 let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
7889 let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
7890
7891 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7892 let port = probe.local_addr().unwrap().port();
7893 drop(probe);
7894
7895 ServerRegistry::reset();
7896
7897 let component = HttpComponent::new();
7899 let endpoint_ctx = NoOpComponentContext;
7900 let uri = format!(
7901 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
7902 cert_path.to_string_lossy(),
7903 key_path.to_string_lossy(),
7904 );
7905 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
7906 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7907 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7908 let token = tokio_util::sync::CancellationToken::new();
7909 let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
7910 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7911
7912 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
7913
7914 let client = reqwest::Client::builder().build().unwrap();
7916
7917 let result = client
7918 .get(format!("https://localhost:{port}/test"))
7919 .send()
7920 .await;
7921
7922 assert!(
7923 result.is_err(),
7924 "must reject without CA — proves real verification"
7925 );
7926
7927 token.cancel();
7928 }
7929
7930 #[test]
7931 fn server_config_partial_tls_cert_without_key() {
7932 let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
7934 assert!(cfg.tls_config.is_none());
7936 }
7937
7938 #[test]
7939 fn endpoint_uri_options_count_parity() {
7940 assert_eq!(
7942 HttpEndpointConfig::uri_options().len(),
7943 20,
7944 "HttpEndpointUriConfig #[uri_param] count drifted from parser"
7945 );
7946 }
7947
7948 fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
7949 pairs
7950 .iter()
7951 .map(|(k, v)| {
7952 (
7953 (*k).to_string(),
7954 serde_json::Value::String((*v).to_string()),
7955 )
7956 })
7957 .collect()
7958 }
7959
7960 #[test]
7961 fn response_emits_cache_control_via_pragma_warning() {
7962 let headers = make_headers(&[
7963 ("Cache-Control", "public, max-age=3600"),
7964 ("Via", "1.1 myproxy"),
7965 ("Pragma", "no-cache"),
7966 ("Warning", "199 misc"),
7967 ]);
7968 let selected = select_response_headers(&headers, None, None);
7969 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
7970 for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
7971 assert!(
7972 names.contains(&expected),
7973 "{expected} should pass through to the response"
7974 );
7975 }
7976 }
7977
7978 #[test]
7979 fn response_excludes_request_only_and_server_owned() {
7980 let headers = make_headers(&[
7981 ("User-Agent", "x"),
7982 ("Accept", "*/*"),
7983 ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
7984 ]);
7985 let selected = select_response_headers(&headers, None, None);
7986 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
7987 for excluded in ["User-Agent", "Accept", "Date"] {
7988 assert!(
7989 !names.contains(&excluded),
7990 "{excluded} should NOT appear in the response"
7991 );
7992 }
7993 }
7994
7995 #[test]
7996 fn response_re_derives_content_type() {
7997 let headers = make_headers(&[("Content-Type", "text/plain")]);
7998 let selected = select_response_headers(&headers, Some("application/json".into()), None);
7999 let ct_entries: Vec<&str> = selected
8000 .iter()
8001 .filter(|(k, _)| k == "Content-Type")
8002 .map(|(_, v)| v.as_str())
8003 .collect();
8004 assert_eq!(
8005 ct_entries,
8006 ["application/json"],
8007 "exactly one Content-Type entry, re-derived from user_content_type"
8008 );
8009 }
8010
8011 #[test]
8012 fn response_excludes_camel_headers() {
8013 let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
8014 let selected = select_response_headers(&headers, None, None);
8015 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
8016 assert!(
8017 !names.contains(&"CamelHttpPath"),
8018 "Camel-namespace headers must be excluded"
8019 );
8020 assert!(
8021 names.contains(&"Cache-Control"),
8022 "Cache-Control must pass through"
8023 );
8024 }
8025
8026 async fn start_host_capturing_destination() -> (
8038 String,
8039 Arc<std::sync::Mutex<Option<(String, String)>>>,
8040 tokio::task::JoinHandle<()>,
8041 ) {
8042 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8043 let port = listener.local_addr().unwrap().port();
8044 let url = format!("http://127.0.0.1:{port}");
8045 let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
8046 Arc::new(std::sync::Mutex::new(None));
8047 let captured_clone = Arc::clone(&captured);
8048 let handle = tokio::spawn(async move {
8049 use tokio::io::{AsyncReadExt, AsyncWriteExt};
8050 if let Ok((mut stream, _)) = listener.accept().await {
8051 let mut buf = vec![0u8; 16384];
8052 let n = stream.read(&mut buf).await.unwrap_or(0);
8053 let request = String::from_utf8_lossy(&buf[..n]).to_string();
8054 if request.contains("\r\n\r\n") {
8055 let request_line = request.lines().next().unwrap_or("").to_string();
8056 let host_value = request
8057 .lines()
8058 .find(|l| l.to_lowercase().starts_with("host:"))
8059 .and_then(|l| l.split_once(':'))
8060 .map(|(_, v)| v.trim().to_string())
8061 .unwrap_or_default();
8062 *captured_clone.lock().unwrap() = Some((host_value, request_line));
8063 }
8064 let body = r#"{"echo":"ok"}"#;
8065 let resp = format!(
8066 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
8067 body.len(),
8068 body
8069 );
8070 let _ = stream.write_all(resp.as_bytes()).await;
8071 }
8072 });
8073 (url, captured, handle)
8074 }
8075
8076 #[tokio::test]
8081 async fn bridge_proxy_outbound_host_matches_destination() {
8082 use tower::ServiceExt;
8083
8084 let (url, captured, _handle) = start_host_capturing_destination().await;
8085 let expected_host = url.strip_prefix("http://").unwrap();
8088
8089 let ctx = test_producer_ctx();
8090 let component = HttpComponent::new();
8091 let endpoint_ctx = NoOpComponentContext;
8092 let endpoint = component
8093 .create_endpoint(
8094 &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
8095 &endpoint_ctx,
8096 )
8097 .unwrap();
8098 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
8099
8100 let mut exchange = Exchange::new(Message::default());
8103 exchange.input.set_header("Host", "localhost");
8104 exchange.input.set_header("CamelHttpPath", "/foo");
8105
8106 let result = producer.oneshot(exchange).await;
8107 assert!(result.is_ok(), "producer call failed: {:?}", result);
8108
8109 tokio::time::sleep(Duration::from_millis(100)).await;
8110 let (host_value, request_line) = captured
8111 .lock()
8112 .unwrap()
8113 .take()
8114 .expect("destination capture mutex empty — producer did not reach the destination");
8115
8116 assert_ne!(
8117 host_value, "localhost",
8118 "bridge producer must not forward the exchange Host: localhost"
8119 );
8120 assert_eq!(
8121 host_value, expected_host,
8122 "Host must be derived from the destination authority (no scheme)"
8123 );
8124 assert!(
8125 !request_line.contains("/foo"),
8126 "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
8127 );
8128 }
8129
8130 #[tokio::test]
8135 async fn bridge_proxy_route_set_response_header_survives() {
8136 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8137
8138 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8139 let port = listener.local_addr().unwrap().port();
8140 drop(listener);
8141
8142 let component = HttpComponent::new();
8143 let endpoint_ctx = NoOpComponentContext;
8144 let endpoint = component
8145 .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
8146 .unwrap();
8147 let mut consumer = endpoint.create_consumer(rt()).unwrap();
8148
8149 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8150 let token = tokio_util::sync::CancellationToken::new();
8151 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8152
8153 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8154 tokio::time::sleep(Duration::from_millis(50)).await;
8155
8156 let client = reqwest::Client::new();
8157 let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
8158
8159 let (http_result, _) = tokio::join!(send_fut, async {
8163 if let Some(mut envelope) = rx.recv().await {
8164 envelope
8165 .exchange
8166 .input
8167 .set_header("Cache-Control", "public, max-age=3600");
8168 if let Some(reply_tx) = envelope.reply_tx {
8169 let _ = reply_tx.send(Ok(envelope.exchange));
8170 }
8171 }
8172 });
8173
8174 let resp = http_result.unwrap();
8175 assert_eq!(resp.status().as_u16(), 200);
8176
8177 let cache_control = resp.headers().get("cache-control");
8178 assert!(
8179 cache_control.is_some(),
8180 "Cache-Control header must survive to the wire response"
8181 );
8182 assert_eq!(
8183 cache_control.unwrap().to_str().unwrap(),
8184 "public, max-age=3600"
8185 );
8186
8187 token.cancel();
8188 }
8189
8190 use camel_api::security_policy::CredentialSource;
8209 use camel_auth::credential_source::extract_token_from_exchange;
8210 use camel_auth::native_auth::NativeCredentialStore;
8211 use camel_auth::{StaticTokenAuthenticator, TokenAuthenticator};
8212
8213 const SENTINEL_QRY_42: &str = "SENTINEL_QRY_42"; const SENTINEL_CKY_7: &str = "SENTINEL_CKY_7"; const SENTINEL_BAD_1: &str = "SENTINEL_BAD_1"; fn envelope_to_exchange(envelope: &RequestEnvelope) -> Exchange {
8221 let mut msg = Message::default();
8222 msg.set_header(
8223 "CamelHttpMethod",
8224 serde_json::Value::String(envelope.method.clone()),
8225 );
8226 msg.set_header(
8227 "CamelHttpPath",
8228 serde_json::Value::String(envelope.path.clone()),
8229 );
8230 msg.set_header(
8231 "CamelHttpQuery",
8232 serde_json::Value::String(envelope.query.clone()),
8233 );
8234 for (k, v) in &envelope.headers {
8235 if let Ok(val_str) = v.to_str() {
8236 msg.set_header(
8237 title_case_header(k.as_str()),
8238 serde_json::Value::String(val_str.to_string()),
8239 );
8240 }
8241 }
8242 Exchange::new(msg)
8243 }
8244
8245 async fn spawn_failing_auth_route(
8252 registry: &HttpRouteRegistry,
8253 path: &str,
8254 sources: Vec<CredentialSource>,
8255 ) {
8256 let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
8257 NativeCredentialStore::try_new(vec![]).unwrap(),
8258 ));
8259 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
8260 registry.register_api_route(path.to_string(), tx).await;
8261 let path_owned = path.to_string();
8262 tokio::spawn(async move {
8263 while let Some(envelope) = rx.recv().await {
8264 let exchange = envelope_to_exchange(&envelope);
8265 let reply_tx = envelope.reply_tx;
8266 let result: Result<(), CamelError> = async {
8267 let token = extract_token_from_exchange(&exchange, &sources)
8268 .map(|extracted| extracted.token)
8269 .ok_or_else(|| {
8270 CamelError::Unauthenticated("no credential in any source".into())
8271 })?;
8272 authenticator.authenticate_bearer(&token).await?;
8273 Ok(())
8274 }
8275 .await;
8276 let reply = match result {
8277 Ok(()) => HttpReply {
8278 status: 200,
8279 headers: vec![],
8280 body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
8281 },
8282 Err(e) => pipeline_error_to_reply(e, &path_owned),
8283 };
8284 let _ = reply_tx.send(reply);
8285 }
8286 });
8287 }
8288
8289 fn captured_logs_contain(needle: &str) -> bool {
8293 let buf = tracing_test::internal::global_buf().lock().unwrap();
8294 String::from_utf8_lossy(&buf).contains(needle)
8295 }
8296
8297 #[tracing_test::traced_test]
8298 #[tokio::test]
8299 async fn error_context_redacts_query_sentinel() {
8300 let (port, registry) = spawn_test_server().await;
8301 spawn_failing_auth_route(
8302 ®istry,
8303 "/secure-query",
8304 vec![CredentialSource::QueryParam {
8305 param: "token".to_string(),
8306 }],
8307 )
8308 .await;
8309
8310 let client = reqwest::Client::new();
8311 let resp = client
8312 .get(format!(
8314 "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
8315 ))
8316 .send()
8317 .await
8318 .unwrap();
8319
8320 assert_eq!(resp.status().as_u16(), 401);
8321 let body = resp.text().await.unwrap();
8322 assert_eq!(body, "Unauthorized");
8323 assert!(
8324 !body.contains(SENTINEL_QRY_42),
8325 "reply body must not contain the query credential"
8326 );
8327 assert!(
8328 !captured_logs_contain(SENTINEL_QRY_42),
8329 "no tracing record during request handling may render the query credential"
8330 );
8331 assert!(
8335 captured_logs_contain("Authentication failed"),
8336 "positive control: the failed-auth warn! must be captured by the test subscriber"
8337 );
8338 }
8339
8340 #[tracing_test::traced_test]
8341 #[tokio::test]
8342 async fn error_context_redacts_cookie_sentinel() {
8343 let (port, registry) = spawn_test_server().await;
8344 spawn_failing_auth_route(
8345 ®istry,
8346 "/secure-cookie",
8347 vec![CredentialSource::Cookie {
8348 name: "session".to_string(),
8349 }],
8350 )
8351 .await;
8352
8353 let client = reqwest::Client::new();
8354 let resp = client
8355 .get(format!("http://127.0.0.1:{port}/secure-cookie"))
8356 .header("Cookie", format!("session={SENTINEL_CKY_7}"))
8357 .send()
8358 .await
8359 .unwrap();
8360
8361 assert_eq!(resp.status().as_u16(), 401);
8362 let body = resp.text().await.unwrap();
8363 assert_eq!(body, "Unauthorized");
8364 assert!(
8365 !body.contains(SENTINEL_CKY_7),
8366 "reply body must not contain the cookie credential"
8367 );
8368 assert!(
8369 !captured_logs_contain(SENTINEL_CKY_7),
8370 "no tracing record during request handling may render the cookie credential"
8371 );
8372 }
8373
8374 #[tracing_test::traced_test]
8375 #[tokio::test]
8376 async fn error_reply_no_credential_value() {
8377 let (port, registry) = spawn_test_server().await;
8378 spawn_failing_auth_route(
8379 ®istry,
8380 "/secure-bad",
8381 vec![CredentialSource::Cookie {
8382 name: "session".to_string(),
8383 }],
8384 )
8385 .await;
8386
8387 let client = reqwest::Client::new();
8388 let resp = client
8389 .get(format!("http://127.0.0.1:{port}/secure-bad"))
8390 .header("Cookie", format!("session={SENTINEL_BAD_1}"))
8391 .send()
8392 .await
8393 .unwrap();
8394
8395 assert_eq!(resp.status().as_u16(), 401);
8396 let body = resp.text().await.unwrap();
8397 assert_eq!(body, "Unauthorized");
8398 assert!(
8399 !body.contains(SENTINEL_BAD_1),
8400 "reply body must not contain the credential value"
8401 );
8402 assert!(
8403 !captured_logs_contain(SENTINEL_BAD_1),
8404 "error logs must not render the credential value"
8405 );
8406 }
8407
8408 async fn spawn_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
8422 use tokio::io::AsyncWriteExt;
8423
8424 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
8425 .await
8426 .expect("bind ephemeral 127.0.0.1 listener");
8427 let port = listener.local_addr().expect("local addr").port();
8428 let base_url = format!("http://localhost:{port}");
8429 let handle = tokio::spawn(async move {
8430 while let Ok((mut conn, _)) = listener.accept().await {
8431 let _ = conn
8432 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
8433 .await;
8434 let _ = conn.shutdown().await;
8435 }
8436 });
8437 (base_url, handle)
8438 }
8439
8440 fn responder_port(base_url: &str) -> u16 {
8444 url::Url::parse(base_url)
8445 .expect("responder base URL parses")
8446 .port()
8447 .expect("responder base URL carries an explicit port")
8448 }
8449
8450 fn endpoint_with_shared_cache(
8455 base_url: &str,
8456 pinned_cache: &Arc<PinnedClientCache>,
8457 ) -> HttpEndpoint {
8458 let uri = format!("{base_url}?allowInternal=true");
8459 HttpEndpoint {
8460 uri: uri.clone(),
8461 config: HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses"),
8462 server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
8463 client: reqwest::Client::new(),
8464 pinned_cache: Arc::clone(pinned_cache),
8465 http_config: HttpConfig::default(),
8466 }
8467 }
8468
8469 #[tokio::test]
8470 async fn producers_share_endpoint_cache() {
8471 use tower::ServiceExt;
8472
8473 let (base_url, _handle) = spawn_multi_accept_200().await;
8474 let pinned_cache = Arc::new(PinnedClientCache::new(
8475 PINNED_CLIENT_TTL,
8476 PINNED_CLIENT_MAX_ENTRIES,
8477 ));
8478
8479 let ctx = test_producer_ctx();
8480 let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
8481 let producer_a = endpoint.create_producer(rt(), &ctx);
8482 let producer_b = endpoint.create_producer(rt(), &ctx);
8483
8484 for producer in [producer_a, producer_b] {
8487 let producer = producer.expect("create producer");
8488 let exchange = Exchange::new(Message::default());
8489 let reply = producer.oneshot(exchange).await;
8490 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
8491 }
8492
8493 assert_eq!(
8494 pinned_cache.build_count(),
8495 1,
8496 "both producers must hit the same shared cache entry; a second \
8497 build means sharing is broken"
8498 );
8499 }
8500
8501 #[tokio::test]
8502 async fn producer_repeated_hostname_requests_build_one_client() {
8503 use tower::ServiceExt;
8504
8505 let (base_url, _handle) = spawn_multi_accept_200().await;
8506 let pinned_cache = Arc::new(PinnedClientCache::new(
8507 PINNED_CLIENT_TTL,
8508 PINNED_CLIENT_MAX_ENTRIES,
8509 ));
8510 let ctx = test_producer_ctx();
8511 let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
8512 let producer = endpoint
8513 .create_producer(rt(), &ctx)
8514 .expect("create producer");
8515
8516 for i in 0..2 {
8519 let exchange = Exchange::new(Message::default());
8520 let reply = producer.clone().oneshot(exchange).await;
8521 assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
8522 }
8523
8524 assert_eq!(
8525 pinned_cache.build_count(),
8526 1,
8527 "repeated hostname requests must reuse the one pinned client; \
8528 0 builds means the producer bypassed the cache, more than 1 \
8529 means the entry was dropped"
8530 );
8531 }
8532
8533 #[tokio::test]
8534 async fn ip_literal_request_never_enters_cache() {
8535 use tower::ServiceExt;
8536
8537 let (base_url, _handle) = spawn_multi_accept_200().await;
8538 let pinned_cache = Arc::new(PinnedClientCache::new(
8539 PINNED_CLIENT_TTL,
8540 PINNED_CLIENT_MAX_ENTRIES,
8541 ));
8542
8543 let ctx = test_producer_ctx();
8544 let destination = format!("http://127.0.0.1:{}/ping", responder_port(&base_url));
8545 let endpoint = endpoint_with_shared_cache(&destination, &pinned_cache);
8546 let producer = endpoint
8547 .create_producer(rt(), &ctx)
8548 .expect("create producer");
8549
8550 let exchange = Exchange::new(Message::default());
8551 let reply = producer.oneshot(exchange).await;
8552 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
8553
8554 assert_eq!(
8555 pinned_cache.build_count(),
8556 0,
8557 "an IP-literal URL must use the shared unpinned client and \
8558 never enter the pinned cache"
8559 );
8560 }
8561}