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::{
17 HttpComponentKind, PINNED_CLIENT_MAX_ENTRIES, PINNED_CLIENT_TTL, PinnedClientCache,
18};
19pub use config::HttpConfig;
20pub use health::HttpHealthCheck;
21pub use registry::HttpRouteRegistry;
22pub use static_config::HttpStaticConfig;
23pub use static_endpoint::{HttpStaticComponent, HttpStaticConsumer, HttpStaticEndpoint};
24
25use std::collections::HashMap;
26use std::future::Future;
27use std::pin::Pin;
28
29use std::sync::{Arc, Mutex, OnceLock};
30use std::task::{Context, Poll};
31use std::time::Duration;
32
33use tokio::sync::OnceCell;
34use tower::Layer;
35use tower::Service;
36use tracing::debug;
37
38use axum::body::BodyDataStream;
39use camel_api::component_metadata::ComponentMetadata;
40use camel_auth::bearer_token_layer::BearerTokenLayer;
41use camel_auth::oauth2::TokenProvider;
42use camel_component_api::tls_source::ServerTlsSource;
43use camel_component_api::{Body, BoxProcessor, CamelError, Exchange, StreamBody, StreamMetadata};
44use camel_component_api::{Component, Consumer, Endpoint, ProducerContext, RuntimeObservability};
45use camel_component_api::{UriComponents, UriConfig, parse_uri, raw_query_pairs};
46use futures::StreamExt;
47use futures::TryStreamExt;
48use futures::stream::BoxStream;
49
50#[derive(Clone)]
111pub struct HttpEndpointConfig {
112 pub base_url: String,
113 pub http_method: Option<String>,
114 pub throw_exception_on_failure: bool,
115 pub ok_status_code_range: (u16, u16),
116 pub response_timeout: Option<Duration>,
117 pub query_params: Vec<(String, String)>,
121 pub raw_query: Option<String>,
126 pub allow_internal: bool,
127 pub allow_cleartext: bool,
133 pub blocked_hosts: Vec<String>,
134 pub max_body_size: usize,
135 pub read_timeout_ms: u64,
136 pub max_response_bytes: usize,
137 pub auth: HttpAuth,
138 pub token_provider: Option<Arc<dyn TokenProvider>>,
139 pub user_agent: Option<String>,
140 pub bridge_endpoint: bool,
141 pub connection_close: bool,
142 pub skip_request_headers: Vec<String>,
143 pub skip_response_headers: Vec<String>,
144 pub follow_redirects: bool,
145 pub max_redirects: usize,
146 pub allowed_uri_hosts: Option<Vec<AllowedUriHost>>,
151}
152
153impl std::fmt::Debug for HttpEndpointConfig {
166 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167 f.debug_struct("HttpEndpointConfig")
168 .field("base_url", &camel_api::redact::redact_url(&self.base_url))
169 .field("http_method", &self.http_method)
170 .field(
171 "throw_exception_on_failure",
172 &self.throw_exception_on_failure,
173 )
174 .field("ok_status_code_range", &self.ok_status_code_range)
175 .field("response_timeout", &self.response_timeout)
176 .field(
177 "query_params",
178 &self
179 .query_params
180 .iter()
181 .map(|(key, _)| (key, "***"))
182 .collect::<Vec<_>>(),
183 )
184 .field("raw_query", &self.raw_query.as_ref().map(|_| "?[redacted]"))
185 .field("allow_internal", &self.allow_internal)
186 .field("allow_cleartext", &self.allow_cleartext)
187 .field("blocked_hosts", &self.blocked_hosts)
188 .field("max_body_size", &self.max_body_size)
189 .field("read_timeout_ms", &self.read_timeout_ms)
190 .field("max_response_bytes", &self.max_response_bytes)
191 .field("auth", &self.auth)
192 .field("token_provider", &self.token_provider)
193 .field("user_agent", &self.user_agent)
194 .field("bridge_endpoint", &self.bridge_endpoint)
195 .field("connection_close", &self.connection_close)
196 .field("skip_request_headers", &self.skip_request_headers)
197 .field("skip_response_headers", &self.skip_response_headers)
198 .field("follow_redirects", &self.follow_redirects)
199 .field("max_redirects", &self.max_redirects)
200 .field("allowed_uri_hosts", &self.allowed_uri_hosts)
201 .finish()
202 }
203}
204
205#[derive(Clone, PartialEq)]
206pub enum HttpAuth {
207 None,
208 Basic { username: String, password: String },
209 Bearer { token: String },
210}
211
212impl std::fmt::Debug for HttpAuth {
213 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214 match self {
215 HttpAuth::None => f.write_str("None"),
216 HttpAuth::Basic { username, .. } => f
217 .debug_struct("Basic")
218 .field("username", username)
219 .field("password", &"***")
220 .finish(),
221 HttpAuth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
222 }
223 }
224}
225
226fn is_consumed_option(key: &str) -> bool {
235 HttpEndpointConfig::uri_options()
236 .iter()
237 .any(|option| option.name == key || option.aliases.iter().any(|alias| alias == key))
238}
239
240impl UriConfig for HttpEndpointConfig {
241 fn scheme() -> &'static str {
243 "http"
244 }
245
246 fn from_uri(uri: &str) -> Result<Self, CamelError> {
247 let parts = parse_uri(uri)?;
248 Self::from_components(parts)
249 }
250
251 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
252 if parts.scheme != "http" && parts.scheme != "https" {
254 return Err(CamelError::InvalidUri(format!(
255 "expected scheme 'http' or 'https', got '{}'",
256 parts.scheme
257 )));
258 }
259
260 let base_url = format!("{}:{}", parts.scheme, parts.path);
263
264 let http_method = parts.params.get("httpMethod").cloned();
265
266 let throw_exception_on_failure = match parts.params.get("throwExceptionOnFailure") {
267 Some(v) => parse_bool_param_http(v).map_err(|e| {
268 CamelError::InvalidUri(format!("invalid value for throwExceptionOnFailure: {e}"))
269 })?,
270 None => true,
271 };
272
273 let ok_status_code_range = match parts.params.get("okStatusCodeRange") {
275 Some(v) => parse_ok_status_code_range(v)?,
276 None => (200, 299),
277 };
278
279 let response_timeout = match parts.params.get("responseTimeout") {
280 Some(v) => Some(v.parse::<u64>().map(Duration::from_millis).map_err(|e| {
281 CamelError::InvalidUri(format!("invalid value for responseTimeout: {e}"))
282 })?),
283 None => None,
284 };
285
286 let allow_internal = match parts.params.get("allowInternal") {
288 Some(v) => parse_bool_param_http(v).map_err(|e| {
289 CamelError::InvalidUri(format!("invalid value for allowInternal: {e}"))
290 })?,
291 None => false, };
293
294 let allow_cleartext = match parts.params.get("allowCleartext") {
297 Some(v) => parse_bool_param_http(v).map_err(|e| {
298 CamelError::InvalidUri(format!("invalid value for allowCleartext: {e}"))
299 })?,
300 None => false, };
302
303 let blocked_hosts = parts
305 .params
306 .get("blockedHosts")
307 .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
308 .unwrap_or_default();
309
310 let max_body_size = match parts.params.get("maxBodySize") {
311 Some(v) => v.parse::<usize>().map_err(|e| {
312 CamelError::InvalidUri(format!("invalid value for maxBodySize: {e}"))
313 })?,
314 None => 10 * 1024 * 1024, };
316
317 let read_timeout_ms = match parts.params.get("readTimeout") {
318 Some(v) => v.parse::<u64>().map_err(|e| {
319 CamelError::InvalidUri(format!("invalid value for readTimeout: {e}"))
320 })?,
321 None => 30_000, };
323
324 let max_response_bytes = match parts.params.get("maxResponseBytes") {
325 Some(v) => v.parse::<usize>().map_err(|e| {
326 CamelError::InvalidUri(format!("invalid value for maxResponseBytes: {e}"))
327 })?,
328 None => 10 * 1024 * 1024, };
330
331 let auth = parse_auth_from_params(&parts.params)?;
332
333 let user_agent = parts.params.get("userAgent").cloned();
334
335 if parts.params.contains_key("cookieHandling") {
336 return Err(CamelError::InvalidUri(
337 "cookieHandling is not supported".into(),
338 ));
339 }
340
341 let bridge_endpoint = match parts.params.get("bridgeEndpoint") {
342 Some(v) => parse_bool_param_http(v).map_err(|e| {
343 CamelError::InvalidUri(format!("invalid value for bridgeEndpoint: {e}"))
344 })?,
345 None => false,
346 };
347
348 let connection_close = match parts.params.get("connectionClose") {
349 Some(v) => parse_bool_param_http(v).map_err(|e| {
350 CamelError::InvalidUri(format!("invalid value for connectionClose: {e}"))
351 })?,
352 None => false,
353 };
354
355 let skip_request_headers = parts
356 .params
357 .get("skipRequestHeaders")
358 .map(|v| {
359 v.split(',')
360 .map(str::trim)
361 .filter(|s| !s.is_empty())
362 .map(|s| s.to_ascii_lowercase())
363 .collect::<Vec<_>>()
364 })
365 .unwrap_or_default();
366
367 let skip_response_headers = parts
368 .params
369 .get("skipResponseHeaders")
370 .map(|v| {
371 v.split(',')
372 .map(str::trim)
373 .filter(|s| !s.is_empty())
374 .map(|s| s.to_ascii_lowercase())
375 .collect::<Vec<_>>()
376 })
377 .unwrap_or_default();
378
379 let follow_redirects = match parts.params.get("followRedirects") {
380 Some(v) => parse_bool_param_http(v).map_err(|e| {
381 CamelError::InvalidUri(format!("invalid value for followRedirects: {e}"))
382 })?,
383 None => false,
384 };
385
386 let max_redirects = match parts.params.get("maxRedirects") {
387 Some(v) => v.parse::<usize>().map_err(|e| {
388 CamelError::InvalidUri(format!("invalid value for maxRedirects: {e}"))
389 })?,
390 None => 10,
391 };
392
393 let allowed_uri_hosts = match parts.params.get("allowedUriHosts") {
396 Some(v) => Some(parse_allowed_uri_hosts(v)?),
397 None => None,
398 };
399
400 let raw_query = parts.raw_query.clone();
405
406 Ok(Self {
407 base_url,
408 http_method,
409 throw_exception_on_failure,
410 ok_status_code_range,
411 response_timeout,
412 query_params: Vec::new(),
413 raw_query,
414 allow_internal,
415 allow_cleartext,
416 blocked_hosts,
417 max_body_size,
418 read_timeout_ms,
419 max_response_bytes,
420 auth,
421 token_provider: None,
422 user_agent,
423 bridge_endpoint,
424 connection_close,
425 skip_request_headers,
426 skip_response_headers,
427 follow_redirects,
428 max_redirects,
429 allowed_uri_hosts,
430 })
431 }
432}
433
434#[derive(Debug, Clone, UriConfig)]
441#[allow(dead_code)]
442#[uri_scheme = "http"]
443#[uri_config(
444 skip_impl,
445 metadata(
446 scheme = "http",
447 description = "HTTP client and server component",
448 producer,
449 consumer,
450 streaming
451 ),
452 crate = "camel_component_api"
453)]
454struct HttpEndpointUriConfig {
455 #[allow(dead_code)]
456 _base_url: String,
457
458 #[uri_param(
459 name = "httpMethod",
460 desc = "HTTP method. Defaults to CamelHttpMethod header or POST/GET"
461 )]
462 http_method: Option<String>,
463
464 #[uri_param(
465 name = "throwExceptionOnFailure",
466 default = "true",
467 desc = "Throw on non-2xx status"
468 )]
469 throw_exception_on_failure: bool,
470
471 #[uri_param(
472 name = "okStatusCodeRange",
473 default = "200-299",
474 desc = "Success status code range"
475 )]
476 ok_status_code_range: String,
477
478 #[uri_param(name = "responseTimeout", desc = "Response timeout in milliseconds")]
479 response_timeout: Option<u64>,
480
481 #[uri_param(
482 name = "connectTimeout",
483 desc = "Connection timeout in milliseconds (consumed option; effective timeout comes from the global http config)"
484 )]
485 connect_timeout: Option<u64>,
486
487 #[uri_param(
488 name = "allowInternal",
489 default = "false",
490 desc = "Allow private/internal network destinations (SSRF)"
491 )]
492 allow_internal: bool,
493
494 #[uri_param(
495 name = "allowCleartext",
496 default = "false",
497 desc = "Allow cleartext http:// to public targets (transport consent; ADR-0081)"
498 )]
499 allow_cleartext: bool,
500
501 #[uri_param(name = "blockedHosts", desc = "Comma-separated blocked host list")]
502 blocked_hosts: Option<String>,
503
504 #[uri_param(
505 name = "maxBodySize",
506 default = "10485760",
507 desc = "Max request/response body bytes"
508 )]
509 max_body_size: u64,
510
511 #[uri_param(name = "readTimeout", desc = "Socket read timeout in milliseconds")]
512 read_timeout: Option<u64>,
513
514 #[uri_param(name = "maxResponseBytes", desc = "Max response body bytes")]
515 max_response_bytes: Option<u64>,
516
517 #[uri_param(
518 name = "authMethod",
519 kind = "enum:Basic,Bearer",
520 desc = "Authentication method"
521 )]
522 auth_method: Option<String>,
523
524 #[uri_param(name = "authUsername", secret, desc = "Basic auth username")]
525 auth_username: Option<String>,
526
527 #[uri_param(name = "authPassword", secret, desc = "Basic auth password")]
528 auth_password: Option<String>,
529
530 #[uri_param(name = "authBearerToken", secret, desc = "Bearer auth token")]
531 auth_bearer_token: Option<String>,
532
533 #[uri_param(name = "userAgent", desc = "User-Agent header")]
534 user_agent: Option<String>,
535
536 #[uri_param(
537 name = "bridgeEndpoint",
538 default = "false",
539 desc = "Bridge endpoint mode"
540 )]
541 bridge_endpoint: bool,
542
543 #[uri_param(
544 name = "connectionClose",
545 default = "false",
546 desc = "Send Connection: close"
547 )]
548 connection_close: bool,
549
550 #[uri_param(
551 name = "skipRequestHeaders",
552 desc = "Comma-separated request headers to skip"
553 )]
554 skip_request_headers: Option<String>,
555
556 #[uri_param(
557 name = "skipResponseHeaders",
558 desc = "Comma-separated response headers to skip"
559 )]
560 skip_response_headers: Option<String>,
561
562 #[uri_param(
563 name = "followRedirects",
564 default = "false",
565 desc = "Follow HTTP redirects"
566 )]
567 follow_redirects: bool,
568
569 #[uri_param(name = "maxRedirects", default = "10", desc = "Max redirect hops")]
570 max_redirects: u64,
571
572 #[uri_param(
573 name = "allowedUriHosts",
574 desc = "Comma-separated allowlist of CamelHttpUri override hosts (host or host:port)"
575 )]
576 allowed_uri_hosts: Option<String>,
577}
578
579impl HttpEndpointConfig {
580 pub fn metadata() -> ComponentMetadata {
583 HttpEndpointUriConfig::metadata()
584 }
585
586 pub fn uri_options() -> Vec<camel_api::component_metadata::UriOption> {
588 HttpEndpointUriConfig::uri_options()
589 }
590}
591
592fn parse_auth_from_params(params: &HashMap<String, String>) -> Result<HttpAuth, CamelError> {
593 let Some(method) = params.get("authMethod") else {
594 return Ok(HttpAuth::None);
595 };
596
597 if method.eq_ignore_ascii_case("none") {
598 return Ok(HttpAuth::None);
599 }
600
601 if method.eq_ignore_ascii_case("basic") {
602 let username = params.get("authUsername").cloned().ok_or_else(|| {
603 CamelError::InvalidUri("authUsername is required for authMethod=Basic".to_string())
604 })?;
605 let password = params.get("authPassword").cloned().ok_or_else(|| {
606 CamelError::InvalidUri("authPassword is required for authMethod=Basic".to_string())
607 })?;
608 return Ok(HttpAuth::Basic { username, password });
609 }
610
611 if method.eq_ignore_ascii_case("bearer") {
612 let token = params.get("authBearerToken").cloned().ok_or_else(|| {
613 CamelError::InvalidUri("authBearerToken is required for authMethod=Bearer".to_string())
614 })?;
615 return Ok(HttpAuth::Bearer { token });
616 }
617
618 Err(CamelError::InvalidUri(format!(
619 "invalid value for authMethod: {method} (expected None, Basic, or Bearer)"
620 )))
621}
622
623fn parse_bool_param_http(value: &str) -> Result<bool, CamelError> {
624 match value.to_ascii_lowercase().as_str() {
625 "true" | "1" | "yes" => Ok(true),
626 "false" | "0" | "no" => Ok(false),
627 _ => Err(CamelError::InvalidUri(format!(
628 "invalid boolean value: '{value}'"
629 ))),
630 }
631}
632
633impl HttpEndpointConfig {
634 pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
635 let parts = parse_uri(uri)?;
636 let mut endpoint = Self::from_components(parts.clone())?;
637 if endpoint.response_timeout.is_none() {
638 endpoint.response_timeout = Some(Duration::from_millis(config.response_timeout_ms));
639 }
640 if !parts.params.contains_key("allowInternal") {
641 endpoint.allow_internal = config.allow_internal;
642 }
643 if !parts.params.contains_key("blockedHosts") {
644 endpoint.blocked_hosts = config.blocked_hosts.clone();
645 }
646 if !parts.params.contains_key("maxBodySize") {
647 endpoint.max_body_size = config.max_body_size;
648 }
649 if !parts.params.contains_key("readTimeout") {
650 endpoint.read_timeout_ms = config.read_timeout_ms;
651 }
652 if !parts.params.contains_key("maxResponseBytes") {
653 endpoint.max_response_bytes = config.max_response_bytes;
654 }
655 if !parts.params.contains_key("okStatusCodeRange")
656 && let Some(range) = &config.ok_status_code_range
657 {
658 endpoint.ok_status_code_range = parse_ok_status_code_range(range)?;
659 }
660 if !parts.params.contains_key("followRedirects") {
661 endpoint.follow_redirects = config.follow_redirects;
662 }
663 if !parts.params.contains_key("maxRedirects") {
664 endpoint.max_redirects = config.max_redirects.unwrap_or(10);
665 }
666
667 Ok(endpoint)
668 }
669}
670
671#[derive(Debug, Clone)]
677pub struct HttpServerConfig {
678 pub scheme: String,
680 pub host: String,
682 pub port: u16,
684 pub path: String,
686 pub max_request_body: usize,
688 pub max_response_body: usize,
690 pub max_inflight_requests: usize,
692 pub method: Option<String>,
699 pub tls_config: Option<crate::config::ServerTlsConfig>,
702}
703
704impl UriConfig for HttpServerConfig {
705 fn scheme() -> &'static str {
707 "http"
708 }
709
710 fn from_uri(uri: &str) -> Result<Self, CamelError> {
711 let parts = parse_uri(uri)?;
712 Self::from_components(parts)
713 }
714
715 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
716 if parts.scheme != "http" && parts.scheme != "https" {
718 return Err(CamelError::InvalidUri(format!(
719 "expected scheme 'http' or 'https', got '{}'",
720 parts.scheme
721 )));
722 }
723
724 let authority_and_path = parts.path.trim_start_matches('/');
727
728 let (authority, path_suffix) = if let Some(idx) = authority_and_path.find('/') {
730 (&authority_and_path[..idx], &authority_and_path[idx..])
731 } else {
732 (authority_and_path, "/")
733 };
734
735 let path = if path_suffix.is_empty() {
736 "/"
737 } else {
738 path_suffix
739 }
740 .to_string();
741
742 let (host, port) = if let Some(colon) = authority.rfind(':') {
744 let port_str = &authority[colon + 1..];
745 match port_str.parse::<u16>() {
746 Ok(p) => (authority[..colon].to_string(), p),
747 Err(_) => {
748 return Err(CamelError::InvalidUri(format!(
749 "invalid port '{}' in authority",
750 port_str
751 )));
752 }
753 }
754 } else {
755 let default_port = if parts.scheme == "https" { 443 } else { 80 };
757 (authority.to_string(), default_port)
758 };
759
760 let max_request_body = parts
761 .params
762 .get("maxRequestBody")
763 .and_then(|v| v.parse::<usize>().ok())
764 .unwrap_or(2 * 1024 * 1024); let max_response_body = parts
767 .params
768 .get("maxResponseBody")
769 .and_then(|v| v.parse::<usize>().ok())
770 .unwrap_or(10 * 1024 * 1024); let max_inflight_requests = parts
773 .params
774 .get("maxInflightRequests")
775 .and_then(|v| v.parse::<usize>().ok())
776 .unwrap_or(1024);
777
778 let method = parts.params.get("httpMethod").map(|m| m.to_uppercase());
784
785 Ok(Self {
786 scheme: parts.scheme,
787 host,
788 port,
789 path,
790 max_request_body,
791 max_response_body,
792 max_inflight_requests,
793 method,
794 tls_config: {
795 let cert = parts.params.get("tlsCert").cloned();
796 let key = parts.params.get("tlsKey").cloned();
797 match (cert, key) {
798 (Some(c), Some(k)) => Some(crate::config::ServerTlsConfig {
799 cert_path: c,
800 key_path: k,
801 }),
802 (None, None) => None,
803 _ => None, }
805 },
806 })
807 }
808}
809
810impl HttpServerConfig {
811 pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
812 let parts = parse_uri(uri)?;
813 let mut server = Self::from_components(parts.clone())?;
814 if !parts.params.contains_key("maxRequestBody") {
815 server.max_request_body = config.max_request_body;
816 }
817 if !parts.params.contains_key("maxResponseBody") {
818 server.max_response_body = config.max_body_size;
820 }
821 Ok(server)
822 }
823}
824
825pub enum HttpReplyBody {
833 Bytes(bytes::Bytes),
834 Stream(BoxStream<'static, Result<bytes::Bytes, CamelError>>),
835}
836
837pub struct RequestEnvelope {
842 pub method: String,
843 pub path: String,
844 pub query: String,
845 pub headers: http::HeaderMap,
846 pub body: StreamBody,
847 pub path_params: std::collections::HashMap<String, String>,
853 pub reply_tx: tokio::sync::oneshot::Sender<HttpReply>,
854}
855
856pub struct HttpReply {
860 pub status: u16,
861 pub headers: Vec<(String, String)>,
862 pub body: HttpReplyBody,
863}
864
865type ServerKey = (String, u16);
870
871struct ServerHandle {
873 registry: HttpRouteRegistry,
874 bound_addr: std::net::SocketAddr,
877 max_request_body: usize,
878 max_response_body: usize,
879 max_inflight_requests: usize,
880 is_tls: bool,
881 tls_cert_path: Option<String>,
882 tls_key_path: Option<String>,
883 monitor_task: tokio::task::JoinHandle<()>,
886 #[allow(dead_code)]
892 server_abort: tokio::task::AbortHandle,
893 tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
896 tls_source: Option<ServerTlsSource>,
897}
898
899#[derive(Default)]
902struct RegistryState {
903 entries: HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>,
904 staged: HashMap<ServerKey, tokio::net::TcpListener>,
905}
906
907pub struct ServerRegistry {
909 inner: Mutex<RegistryState>,
910}
911
912impl ServerRegistry {
913 pub fn global() -> &'static Self {
915 static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
916 INSTANCE.get_or_init(|| ServerRegistry {
917 inner: Mutex::new(RegistryState::default()),
918 })
919 }
920
921 #[allow(clippy::too_many_arguments)]
924 pub async fn get_or_spawn(
925 &'static self,
926 host: &str,
927 port: u16,
928 max_request_body: usize,
929 max_response_body: usize,
930 max_inflight_requests: usize,
931 runtime: Arc<dyn RuntimeObservability>,
932 route_id: String,
933 tls_config: Option<crate::config::ServerTlsConfig>,
934 ) -> Result<HttpRouteRegistry, CamelError> {
935 self.get_or_spawn_internal(
936 host,
937 port,
938 max_request_body,
939 max_response_body,
940 max_inflight_requests,
941 runtime,
942 route_id,
943 tls_config,
944 None,
945 )
946 .await
947 }
948
949 #[allow(clippy::too_many_arguments)]
956 pub async fn get_or_spawn_with_listener(
957 &'static self,
958 listener: tokio::net::TcpListener,
959 max_request_body: usize,
960 max_response_body: usize,
961 max_inflight_requests: usize,
962 runtime: Arc<dyn RuntimeObservability>,
963 route_id: String,
964 tls_config: Option<crate::config::ServerTlsConfig>,
965 ) -> Result<HttpRouteRegistry, CamelError> {
966 let addr = listener
967 .local_addr()
968 .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
969 self.get_or_spawn_internal(
970 &addr.ip().to_string(),
971 addr.port(),
972 max_request_body,
973 max_response_body,
974 max_inflight_requests,
975 runtime,
976 route_id,
977 tls_config,
978 Some(listener),
979 )
980 .await
981 }
982
983 pub async fn stage_listener(
990 &'static self,
991 listener: tokio::net::TcpListener,
992 ) -> Result<(), CamelError> {
993 let addr = listener
994 .local_addr()
995 .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
996 let host = addr.ip().to_string();
997 use std::collections::hash_map::Entry;
998 let mut guard = self.inner.lock().map_err(|_| {
999 CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
1000 })?;
1001 match guard.staged.entry((host.clone(), addr.port())) {
1002 Entry::Occupied(_) => Err(CamelError::EndpointCreationFailed(format!(
1003 "listener already staged for {host}:{}",
1004 addr.port()
1005 ))),
1006 Entry::Vacant(slot) => {
1007 slot.insert(listener);
1008 Ok(())
1009 }
1010 }
1011 }
1012
1013 pub fn bound_addr(&'static self, host: &str, port: u16) -> Option<std::net::SocketAddr> {
1016 let guard = self.inner.lock().ok()?;
1017 guard
1018 .entries
1019 .get(&(host.to_string(), port))
1020 .and_then(|cell| cell.get())
1021 .map(|handle| handle.bound_addr)
1022 }
1023
1024 #[allow(clippy::too_many_arguments)]
1025 async fn get_or_spawn_internal(
1026 &'static self,
1027 host: &str,
1028 port: u16,
1029 max_request_body: usize,
1030 max_response_body: usize,
1031 max_inflight_requests: usize,
1032 runtime: Arc<dyn RuntimeObservability>,
1033 route_id: String,
1034 tls_config: Option<crate::config::ServerTlsConfig>,
1035 provided: Option<tokio::net::TcpListener>,
1036 ) -> Result<HttpRouteRegistry, CamelError> {
1037 let host_owned = host.to_string();
1038 let key = (host.to_string(), port);
1039
1040 let cell = {
1041 let mut guard = self.inner.lock().map_err(|_| {
1042 CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
1043 })?;
1044 if let Some(existing) = guard.entries.get(&key)
1048 && let Some(handle) = existing.get()
1049 && handle.monitor_task.is_finished()
1050 {
1051 if handle.is_tls {
1054 let scheme = if handle.is_tls { "https" } else { "http" };
1055 camel_component_api::tls_source::TlsReloadRegistry::global()
1056 .unregister(scheme, host, port);
1057 }
1058 guard.entries.remove(&key);
1059 }
1060 guard
1061 .entries
1062 .entry(key)
1063 .or_insert_with(|| Arc::new(OnceCell::new()))
1064 .clone()
1065 };
1066
1067 if let Some(existing) = cell.get()
1068 && existing.max_request_body != max_request_body
1069 {
1070 return Err(CamelError::EndpointCreationFailed(format!(
1071 "incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
1072 existing.max_request_body, max_request_body
1073 )));
1074 }
1075
1076 if let Some(existing) = cell.get()
1077 && existing.max_response_body != max_response_body
1078 {
1079 return Err(CamelError::EndpointCreationFailed(format!(
1080 "incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
1081 existing.max_response_body, max_response_body
1082 )));
1083 }
1084
1085 if let Some(existing) = cell.get()
1086 && existing.max_inflight_requests != max_inflight_requests
1087 {
1088 return Err(CamelError::EndpointCreationFailed(format!(
1089 "incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
1090 existing.max_inflight_requests, max_inflight_requests
1091 )));
1092 }
1093
1094 if let Some(existing) = cell.get()
1096 && existing.is_tls != tls_config.is_some()
1097 {
1098 return Err(CamelError::EndpointCreationFailed(format!(
1099 "incompatible TLS mode for shared server (host={host}, port={port}): existing is_tls={}, new has_tls={}",
1100 existing.is_tls,
1101 tls_config.is_some()
1102 )));
1103 }
1104
1105 if let (Some(existing), Some(new_tls)) = (cell.get(), &tls_config)
1107 && (existing.tls_cert_path.as_deref() != Some(&new_tls.cert_path)
1108 || existing.tls_key_path.as_deref() != Some(&new_tls.key_path))
1109 {
1110 return Err(CamelError::EndpointCreationFailed(format!(
1111 "incompatible TLS cert/key for shared server (host={host}, port={port}): routes on the same TLS port must use the same cert and key"
1112 )));
1113 }
1114
1115 let handle = cell
1116 .get_or_try_init(|| {
1117 let rt = Arc::clone(&runtime);
1118 let rid = route_id.clone();
1119 let key = (host_owned.clone(), port);
1120 async move {
1121 let source = match provided {
1130 Some(listener) => ListenerSource::Staged(listener),
1131 None => {
1132 let mut guard = self.inner.lock().map_err(|_| {
1133 CamelError::EndpointCreationFailed(
1134 "ServerRegistry lock poisoned".into(),
1135 )
1136 })?;
1137 match guard.staged.remove(&key) {
1138 Some(listener) => ListenerSource::Staged(listener),
1139 None => {
1143 if let Some((staged_host, _)) = guard
1144 .staged
1145 .keys()
1146 .find(|(_, staged_port)| *staged_port == port)
1147 {
1148 let staged_host = staged_host.clone();
1149 return Err(CamelError::EndpointCreationFailed(
1150 format!(
1151 "staged listener conflict on port {port}: staged under host {staged_host}, requested {host_owned}"
1152 ),
1153 ));
1154 }
1155 ListenerSource::Bind
1156 }
1157 }
1158 }
1159 };
1160 spawn_entry(
1161 key,
1162 source,
1163 max_request_body,
1164 max_response_body,
1165 max_inflight_requests,
1166 rt,
1167 rid,
1168 tls_config,
1169 )
1170 .await
1171 .and_then(|handle| {
1172 Arc::try_unwrap(handle).map_err(|_| {
1176 CamelError::EndpointCreationFailed(
1177 "spawned server handle has dangling clones".into(),
1178 )
1179 })
1180 })
1181 }
1182 })
1183 .await?;
1184
1185 Ok(handle.registry.clone())
1186 }
1187
1188 pub async fn unregister(&self, host: &str, port: u16) {
1192 debug!(
1193 host = host,
1194 port = port,
1195 "consumer unregistered from HTTP server"
1196 );
1197 }
1198
1199 #[cfg(test)]
1206 pub fn reset() {
1207 let instance = Self::global();
1208 let mut guard = instance
1209 .inner
1210 .lock()
1211 .expect("ServerRegistry lock poisoned during test reset");
1212 guard.entries.clear();
1213 guard.staged.clear();
1214 }
1215}
1216
1217enum ListenerSource {
1220 Bind,
1221 Staged(tokio::net::TcpListener),
1222}
1223
1224#[allow(clippy::too_many_arguments)]
1229async fn spawn_entry(
1230 key: ServerKey,
1231 source: ListenerSource,
1232 max_request_body: usize,
1233 max_response_body: usize,
1234 max_inflight_requests: usize,
1235 runtime: Arc<dyn RuntimeObservability>,
1236 route_id: String,
1237 tls_config: Option<crate::config::ServerTlsConfig>,
1238) -> Result<Arc<ServerHandle>, CamelError> {
1239 let rt = Arc::clone(&runtime);
1240 let rid = route_id.clone();
1241 let (host_owned, port) = key;
1242 let listener = match source {
1243 ListenerSource::Bind => {
1244 let addr = format!("{host_owned}:{port}");
1245 tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
1246 CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
1247 })?
1248 }
1249 ListenerSource::Staged(listener) => listener,
1250 };
1251 let bound_addr = listener
1252 .local_addr()
1253 .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
1254 let server_exited = tokio_util::sync::CancellationToken::new();
1255 let registry = HttpRouteRegistry::new_with_server_exited(server_exited.clone());
1256 let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
1257 let tls_rustls_cfg: Option<axum_server::tls_rustls::RustlsConfig>;
1260 let tls_source: Option<ServerTlsSource>;
1261 let server_task = if let Some(ref tls) = tls_config {
1262 let rustls_config = load_tls_config(&tls.cert_path, &tls.key_path)?;
1263 let source = ServerTlsSource {
1264 cert_path: std::path::PathBuf::from(&tls.cert_path),
1265 key_path: std::path::PathBuf::from(&tls.key_path),
1266 client_ca_path: None,
1267 };
1268 let rustls_cfg =
1272 axum_server::tls_rustls::RustlsConfig::from_config(std::sync::Arc::new(rustls_config));
1273 tls_rustls_cfg = Some(rustls_cfg.clone());
1274 tls_source = Some(source);
1275 let std_listener = listener.into_std().map_err(|e| {
1277 CamelError::EndpointCreationFailed(format!("TLS listener conversion: {e}"))
1278 })?;
1279 tokio::spawn(run_axum_server_tls(
1280 std_listener,
1281 rustls_cfg,
1282 registry.clone(),
1283 max_request_body,
1284 max_response_body,
1285 Arc::clone(&inflight),
1286 Arc::clone(&rt),
1287 rid.clone(),
1288 ))
1289 } else {
1290 tls_rustls_cfg = None;
1291 tls_source = None;
1292 tokio::spawn(run_axum_server(
1293 listener,
1294 registry.clone(),
1295 max_request_body,
1296 max_response_body,
1297 Arc::clone(&inflight),
1298 Arc::clone(&rt),
1299 rid.clone(),
1300 ))
1301 };
1302 let addr_for_monitor = format!("{host_owned}:{port}");
1303 let server_abort = server_task.abort_handle();
1304 let monitor_task = tokio::spawn(monitor_axum_task(
1305 server_task,
1306 addr_for_monitor,
1307 Arc::clone(&rt),
1308 rid,
1309 server_exited,
1310 ));
1311 let handle = ServerHandle {
1312 registry,
1313 bound_addr,
1314 max_request_body,
1315 max_response_body,
1316 max_inflight_requests,
1317 is_tls: tls_config.is_some(),
1318 tls_cert_path: tls_config.as_ref().map(|t| t.cert_path.clone()),
1319 tls_key_path: tls_config.as_ref().map(|t| t.key_path.clone()),
1320 monitor_task,
1321 server_abort,
1322 tls_config: tls_rustls_cfg,
1323 tls_source,
1324 };
1325 if let (Some(tls_cfg), Some(source)) = (handle.tls_config.as_ref(), handle.tls_source.as_ref())
1330 {
1331 let handler = Arc::new(crate::tls_reload::HttpReloadHandler::new(
1332 tls_cfg.clone(),
1333 source.clone(),
1334 host_owned.clone(),
1335 port,
1336 ));
1337 camel_component_api::tls_source::TlsReloadRegistry::global().register(handler);
1338 }
1339 Ok(Arc::new(handle))
1340}
1341
1342use axum::{
1347 Router,
1348 body::Body as AxumBody,
1349 extract::{Request, State},
1350 http::{Response, StatusCode},
1351 response::IntoResponse,
1352};
1353
1354#[derive(Clone)]
1355pub(crate) struct AppState {
1356 registry: HttpRouteRegistry,
1357 max_request_body: usize,
1358 max_response_body: usize,
1359 inflight: Arc<tokio::sync::Semaphore>,
1360}
1361
1362const CONSUMER_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
1369
1370async fn run_axum_server(
1371 listener: tokio::net::TcpListener,
1372 registry: HttpRouteRegistry,
1373 max_request_body: usize,
1374 max_response_body: usize,
1375 inflight: Arc<tokio::sync::Semaphore>,
1376 runtime: Arc<dyn RuntimeObservability>,
1377 route_id: String,
1378) {
1379 let state = AppState {
1380 registry,
1381 max_request_body,
1382 max_response_body,
1383 inflight,
1384 };
1385 let app = Router::new()
1386 .fallback(dispatch_handler)
1387 .with_state(state)
1388 .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1389 StatusCode::REQUEST_TIMEOUT,
1390 CONSUMER_REQUEST_TIMEOUT,
1391 ));
1392
1393 axum::serve(listener, app).await.unwrap_or_else(|e| {
1394 runtime
1395 .metrics()
1396 .increment_errors(&route_id, "e:http:accept");
1397 tracing::error!(error = %e, "Axum server error");
1399 });
1400}
1401
1402#[allow(clippy::too_many_arguments)]
1403async fn run_axum_server_tls(
1404 listener: std::net::TcpListener,
1405 tls_cfg: axum_server::tls_rustls::RustlsConfig,
1406 registry: HttpRouteRegistry,
1407 max_request_body: usize,
1408 max_response_body: usize,
1409 inflight: Arc<tokio::sync::Semaphore>,
1410 runtime: Arc<dyn RuntimeObservability>,
1411 route_id: String,
1412) {
1413 let state = AppState {
1414 registry,
1415 max_request_body,
1416 max_response_body,
1417 inflight,
1418 };
1419 let app = Router::new()
1420 .fallback(dispatch_handler)
1421 .with_state(state)
1422 .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1423 StatusCode::REQUEST_TIMEOUT,
1424 CONSUMER_REQUEST_TIMEOUT,
1425 ));
1426
1427 let server = match axum_server::from_tcp_rustls(listener, tls_cfg) {
1432 Ok(server) => server,
1433 Err(e) => {
1434 runtime
1435 .metrics()
1436 .increment_errors(&route_id, "e:http:accept-tls");
1437 tracing::error!(error = %e, "Axum TLS server setup error");
1439 return;
1440 }
1441 };
1442
1443 server
1444 .serve(app.into_make_service())
1445 .await
1446 .unwrap_or_else(|e| {
1447 runtime
1448 .metrics()
1449 .increment_errors(&route_id, "e:http:accept-tls");
1450 tracing::error!(error = %e, "Axum TLS server error");
1452 });
1453}
1454
1455async fn monitor_axum_task(
1465 handle: tokio::task::JoinHandle<()>,
1466 addr: String,
1467 runtime: Arc<dyn RuntimeObservability>,
1468 route_id: String,
1469 server_exited: tokio_util::sync::CancellationToken,
1470) {
1471 match handle.await {
1472 Ok(()) => {
1473 }
1475 Err(join_err) => {
1476 runtime
1477 .metrics()
1478 .increment_errors(&route_id, "e:http:server-task-exited");
1479 tracing::error!(
1481 addr = %addr,
1482 error = %join_err,
1483 "Axum server task exited unexpectedly — all routes on this port are now dead"
1484 );
1485 server_exited.cancel();
1489 }
1490 }
1491}
1492
1493fn load_tls_config(
1496 cert_path: &str,
1497 key_path: &str,
1498) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
1499 use std::fs::File;
1500 use std::io::BufReader;
1501
1502 let cert_file = File::open(cert_path)
1503 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
1504 let key_file = File::open(key_path)
1505 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
1506
1507 let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
1508 .collect::<Result<Vec<_>, _>>()
1509 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
1510
1511 let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
1512 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
1513 .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
1514
1515 tokio_rustls::rustls::ServerConfig::builder()
1516 .with_no_client_auth()
1517 .with_single_cert(certs, key)
1518 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
1519}
1520
1521async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
1522 let path = req.uri().path().to_owned();
1523 let method = req.method().to_string();
1524
1525 let api_sender = {
1542 let inner = state.registry.inner.read().await;
1543 inner.api_routes.get(&path).cloned()
1544 }; let (rest_sender, path_params) = if api_sender.is_some() {
1547 (None, Default::default())
1549 } else {
1550 let inner = state.registry.inner.read().await;
1551 match rest_match::match_endpoint(&method, &path, &inner.rest_endpoints) {
1552 rest_match::MatchOutcome::Found(m) => (Some(m.payload), m.path_params),
1553 rest_match::MatchOutcome::Ambiguous => {
1554 tracing::warn!(
1560 method = %method,
1561 path = %path,
1562 "ambiguous REST template match — returning 500"
1563 );
1564 return Response::builder()
1565 .status(StatusCode::INTERNAL_SERVER_ERROR)
1566 .body(AxumBody::from("Internal Server Error"))
1567 .expect("infallible"); }
1569 rest_match::MatchOutcome::NotFound => (None, Default::default()),
1570 }
1571 }; let sender = api_sender.or(rest_sender);
1574
1575 if let Some(sender) = sender {
1576 let query = req.uri().query().unwrap_or("").to_string();
1577 let headers = req.headers().clone();
1578
1579 let content_length: Option<u64> = headers
1581 .get(http::header::CONTENT_LENGTH)
1582 .and_then(|v| v.to_str().ok())
1583 .and_then(|s| s.parse().ok());
1584
1585 if let Some(len) = content_length
1586 && len > state.max_request_body as u64
1587 {
1588 return Response::builder()
1589 .status(StatusCode::PAYLOAD_TOO_LARGE)
1590 .body(AxumBody::from("Request body exceeds configured limit"))
1591 .expect("infallible"); }
1593
1594 let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
1595 Ok(permit) => permit,
1596 Err(_) => {
1597 return Response::builder()
1598 .status(StatusCode::SERVICE_UNAVAILABLE)
1599 .body(AxumBody::from("Service Unavailable"))
1600 .expect("infallible"); }
1602 };
1603
1604 let content_type = headers
1610 .get(http::header::CONTENT_TYPE)
1611 .and_then(|v| v.to_str().ok())
1612 .map(|s| s.to_string());
1613
1614 let data_stream: BodyDataStream = req.into_body().into_data_stream();
1615 let max_body = state.max_request_body;
1616 let mut seen: u64 = 0;
1617 let capped_stream =
1618 data_stream
1619 .map_err(|e| CamelError::Io(e.to_string()))
1620 .map(move |chunk| match chunk {
1621 Ok(bytes) => {
1622 seen = seen.saturating_add(bytes.len() as u64);
1623 if seen > max_body as u64 {
1624 Err(CamelError::ProcessorError(format!(
1625 "Request body exceeds configured limit of {max_body} bytes"
1626 )))
1627 } else {
1628 Ok(bytes)
1629 }
1630 }
1631 Err(e) => Err(e),
1632 });
1633 let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(capped_stream);
1634
1635 let stream_body = StreamBody {
1636 stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
1637 metadata: StreamMetadata {
1638 size_hint: content_length,
1639 content_type,
1640 origin: None,
1641 },
1642 };
1643
1644 let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
1645 let envelope = RequestEnvelope {
1646 method,
1647 path,
1648 query,
1649 headers,
1650 body: stream_body,
1651 path_params,
1652 reply_tx,
1653 };
1654
1655 if sender.send(envelope).await.is_err() {
1656 return Response::builder()
1657 .status(StatusCode::SERVICE_UNAVAILABLE)
1658 .body(AxumBody::from("Consumer unavailable"))
1659 .expect("infallible"); }
1661
1662 match reply_rx.await {
1663 Ok(reply) => {
1664 let reply = match reply.body {
1665 HttpReplyBody::Bytes(b)
1666 if exceeds_max_response_body(b.len(), state.max_response_body) =>
1667 {
1668 HttpReply {
1669 status: 500,
1670 headers: vec![],
1671 body: HttpReplyBody::Bytes(bytes::Bytes::from(
1672 "Response body exceeds configured limit",
1673 )),
1674 }
1675 }
1676 _ => reply,
1677 };
1678
1679 let status =
1680 StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1681 let mut builder = Response::builder().status(status);
1682 for (k, v) in &reply.headers {
1683 builder = builder.header(k.as_str(), v.as_str());
1684 }
1685 match reply.body {
1686 HttpReplyBody::Bytes(b) => {
1687 builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
1688 Response::builder()
1689 .status(StatusCode::INTERNAL_SERVER_ERROR)
1690 .body(AxumBody::from("Invalid response headers from consumer"))
1691 .expect("infallible") })
1693 }
1694 HttpReplyBody::Stream(stream) => builder
1695 .body(AxumBody::from_stream(stream))
1696 .unwrap_or_else(|_| {
1697 Response::builder()
1698 .status(StatusCode::INTERNAL_SERVER_ERROR)
1699 .body(AxumBody::from("Invalid response headers from consumer"))
1700 .expect("infallible") }),
1702 }
1703 }
1704 Err(_) => Response::builder()
1705 .status(StatusCode::INTERNAL_SERVER_ERROR)
1706 .body(AxumBody::from("Pipeline error"))
1707 .expect("infallible"), }
1709 } else {
1710 static_dispatch::dispatch_static(&state, req, &path).await
1712 }
1713}
1714
1715fn exceeds_max_response_body(len: usize, max: usize) -> bool {
1716 len > max
1717}
1718
1719fn title_case_header(name: &str) -> String {
1720 name.split('-')
1721 .map(|part| {
1722 let mut chars = part.chars();
1723 match chars.next() {
1724 None => String::new(),
1725 Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
1726 }
1727 })
1728 .collect::<Vec<_>>()
1729 .join("-")
1730}
1731
1732pub(crate) struct HttpKernelAuth {
1747 pub(crate) plan: camel_api::security_policy::RouteSecurityPlan,
1748 pub(crate) providers: Arc<camel_auth::ProviderRegistry>,
1749}
1750
1751impl HttpKernelAuth {
1752 pub(crate) fn from_security_context(
1757 ctx: &camel_component_api::SecurityContext,
1758 ) -> Option<Self> {
1759 Some(Self {
1760 plan: ctx.plan.clone()?,
1761 providers: ctx.providers.clone()?,
1762 })
1763 }
1764}
1765
1766fn envelope_channel_capacity(max_inflight_requests: usize) -> usize {
1780 max_inflight_requests.max(1)
1781}
1782
1783pub struct HttpConsumer {
1784 config: HttpServerConfig,
1785 runtime: Arc<dyn RuntimeObservability>,
1787 kernel: Option<Arc<HttpKernelAuth>>,
1791}
1792
1793impl HttpConsumer {
1794 pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
1795 Self {
1796 config,
1797 runtime,
1798 kernel: None,
1799 }
1800 }
1801}
1802
1803#[async_trait::async_trait]
1804impl Consumer for HttpConsumer {
1805 async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
1806 use camel_component_api::{Body, Exchange, Message};
1807
1808 let registry = ServerRegistry::global()
1809 .get_or_spawn(
1810 &self.config.host,
1811 self.config.port,
1812 self.config.max_request_body,
1813 self.config.max_response_body,
1814 self.config.max_inflight_requests,
1815 self.runtime.clone(),
1816 ctx.route_id().to_string(),
1817 self.config.tls_config.clone(),
1818 )
1819 .await?;
1820
1821 let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(
1825 envelope_channel_capacity(self.config.max_inflight_requests),
1826 );
1827 if let Some(method) = self.config.method.clone() {
1834 let segments = rest_match::parse_path_template(&self.config.path);
1835 registry
1836 .register_rest_endpoint(method, segments, env_tx)
1837 .await;
1838 } else {
1839 registry
1840 .register_api_route(self.config.path.clone(), env_tx)
1841 .await;
1842 }
1843
1844 ctx.mark_ready();
1853
1854 let path = self.config.path.clone();
1855 let registry_for_cleanup = registry.clone();
1856 let server_exited = registry.server_exited.clone();
1857 let cancel_token = ctx.cancel_token();
1858 let kernel = self.kernel.clone();
1859 let mut server_died = false;
1864 loop {
1865 tokio::select! {
1866 _ = ctx.cancelled() => {
1867 break;
1868 }
1869 _ = server_exited.cancelled() => {
1870 server_died = true;
1875 break;
1876 }
1877 envelope = env_rx.recv() => {
1878 let Some(envelope) = envelope else { break; };
1879
1880 let mut msg = Message::default();
1882
1883 msg.set_header("CamelHttpMethod",
1885 serde_json::Value::String(envelope.method.clone()));
1886 msg.set_header("CamelHttpPath",
1887 serde_json::Value::String(envelope.path.clone()));
1888 msg.set_header("CamelHttpQuery",
1889 serde_json::Value::String(envelope.query.clone()));
1890
1891 for (param_name, param_value) in &envelope.path_params {
1898 msg.set_header(
1899 format!("CamelHttpPath_{param_name}"),
1900 serde_json::Value::String(param_value.clone()),
1901 );
1902 }
1903
1904 for (k, v) in &envelope.headers {
1906 if let Ok(val_str) = v.to_str() {
1907 msg.set_header(
1908 title_case_header(k.as_str()),
1909 serde_json::Value::String(val_str.to_string()),
1910 );
1911 }
1912 }
1913
1914 msg.body = Body::Stream(envelope.body);
1917
1918 #[allow(unused_mut)]
1919 let mut exchange = Exchange::new(msg);
1920
1921 #[cfg(feature = "otel")]
1923 {
1924 let headers: HashMap<String, String> = envelope
1925 .headers
1926 .iter()
1927 .filter_map(|(k, v)| {
1928 Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1929 })
1930 .collect();
1931 camel_otel::extract_into_exchange(&mut exchange, &headers);
1932 }
1933
1934 let reply_tx = envelope.reply_tx;
1935 let sender = ctx.sender().clone();
1936 let path_clone = path.clone();
1937 let cancel = cancel_token.clone();
1938 let auth_headers = envelope.headers.clone();
1942 let auth_uri: http::Uri = {
1943 let full = if envelope.query.is_empty() {
1944 envelope.path.clone()
1945 } else {
1946 format!("{}?{}", envelope.path, envelope.query)
1947 };
1948 full.parse().unwrap_or_default()
1952 };
1953 let kernel = kernel.clone();
1954
1955 tokio::spawn(async move {
1975 if cancel.is_cancelled() {
1983 let _ = reply_tx.send(HttpReply {
1984 status: 503,
1985 headers: vec![],
1986 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
1987 });
1988 return;
1989 }
1990
1991 if let Some(kernel) = kernel.as_ref()
2000 && !matches!(
2001 kernel.plan.access_mode,
2002 camel_api::security_policy::AccessMode::Public
2003 )
2004 {
2005 let principal = match camel_auth::extract_token_multi(
2006 &auth_headers,
2007 &auth_uri,
2008 &kernel.plan.credential_sources,
2009 ) {
2010 Some(extracted) => {
2011 match camel_auth::kernel_authenticate(
2012 &kernel.plan,
2013 &kernel.providers,
2014 &extracted,
2015 )
2016 .await
2017 {
2018 Ok(principal) => principal,
2019 Err(e) => {
2020 tracing::warn!(
2022 path = %path_clone,
2023 error = %e,
2024 "HTTP request authentication failed"
2025 );
2026 let _ = reply_tx.send(pipeline_error_to_reply(
2027 e,
2028 &path_clone,
2029 ));
2030 return;
2031 }
2032 }
2033 }
2034 None => {
2035 tracing::warn!(
2037 path = %path_clone,
2038 "HTTP request rejected: no credential found in any source"
2039 );
2040 let _ = reply_tx.send(pipeline_error_to_reply(
2041 CamelError::Unauthenticated(
2042 "no credential found in any source".to_string(),
2043 ),
2044 &path_clone,
2045 ));
2046 return;
2047 }
2048 };
2049 camel_auth::install_carrier(&mut exchange, &principal);
2050 }
2051
2052 let (tx, rx) = tokio::sync::oneshot::channel();
2054 let envelope = camel_component_api::consumer::ExchangeEnvelope {
2055 exchange,
2056 reply_tx: Some(tx),
2057 };
2058
2059 let result = match sender.send(envelope).await {
2060 Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
2061 Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
2062 }
2063 .and_then(|r| r);
2064
2065 let reply = match result {
2066 Ok(out) => {
2067 let status = out
2068 .input
2069 .header("CamelHttpResponseCode")
2070 .and_then(|v| {
2071 let raw = v.as_u64()
2072 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
2073 let code = raw as u16;
2074 (100..1000).contains(&code).then_some(code)
2075 })
2076 .unwrap_or(200);
2077
2078 let user_content_type = out
2079 .input
2080 .header("Content-Type")
2081 .and_then(|v| v.as_str().map(|s| s.to_string()));
2082
2083 let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
2084 Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
2085 Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
2086 Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
2087 Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
2088 v.to_string().into_bytes(),
2089 )), Some("application/json".to_string())),
2090 Body::Stream(s) => {
2091 let ct = s.metadata.content_type.clone();
2092 match s.stream.lock().await.take() {
2093 Some(stream) => (
2094 HttpReplyBody::Stream(stream),
2095 ct,
2096 ),
2097 None => {
2098 tracing::error!(
2100 "Body::Stream already consumed before HTTP reply — returning 500"
2101 );
2102 let error_reply = HttpReply {
2103 status: 500,
2104 headers: vec![],
2105 body: HttpReplyBody::Bytes(bytes::Bytes::new()),
2106 };
2107 if reply_tx.send(error_reply).is_err() {
2108 debug!("reply_tx dropped before error reply could be sent");
2109 }
2110 return;
2111 }
2112 }
2113 }
2114 _ => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
2116 };
2117
2118 let resp_headers = select_response_headers(
2119 &out.input.headers,
2120 user_content_type,
2121 inferred_content_type,
2122 );
2123
2124 HttpReply {
2125 status,
2126 headers: resp_headers,
2127 body: reply_body,
2128 }
2129 }
2130 Err(e) => {
2131 pipeline_error_to_reply(e, &path_clone)
2132 }
2133 };
2134
2135 let _ = reply_tx.send(reply);
2137 });
2138 }
2139 }
2140 }
2141
2142 if let Some(method) = &self.config.method {
2147 registry_for_cleanup
2148 .unregister_rest_endpoint(method, &path)
2149 .await;
2150 } else {
2151 registry_for_cleanup.unregister_api_route(&path).await;
2152 }
2153
2154 ServerRegistry::global()
2160 .unregister(&self.config.host, self.config.port)
2161 .await;
2162
2163 if server_died {
2164 tracing::error!(
2166 host = %self.config.host,
2167 port = self.config.port,
2168 path = %path,
2169 "Shared HTTP server exited — failing consumer to engage route supervision (ADR-0007)"
2170 );
2171 return Err(CamelError::RouteError(format!(
2172 "shared HTTP server for {}:{} exited unexpectedly; route transport is dead",
2173 self.config.host, self.config.port
2174 )));
2175 }
2176
2177 Ok(())
2178 }
2179
2180 async fn stop(&mut self) -> Result<(), CamelError> {
2181 Ok(())
2182 }
2183
2184 fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
2185 camel_component_api::ConcurrencyModel::Concurrent { max: None }
2186 }
2187
2188 fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
2195 camel_component_api::ConsumerStartupMode::Explicit
2196 }
2197
2198 fn set_security_context(&mut self, ctx: camel_component_api::SecurityContext) {
2201 self.kernel = HttpKernelAuth::from_security_context(&ctx).map(Arc::new);
2202 }
2203}
2204
2205pub struct HttpComponent {
2210 config: HttpConfig,
2211 pinned_cache: std::sync::Arc<PinnedClientCache>,
2212 client: reqwest::Client,
2213 strict_tls_error: Option<CamelError>,
2217}
2218
2219#[cfg(test)]
2220thread_local! {
2221 static BUILD_CLIENT_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
2222}
2223
2224pub(crate) fn build_client(
2225 config: &HttpConfig,
2226 resolve_override: Option<(&str, &[std::net::SocketAddr])>,
2227) -> reqwest::Client {
2228 #[cfg(test)]
2229 BUILD_CLIENT_CALLS.with(|c| c.set(c.get() + 1));
2230
2231 let mut builder = reqwest::Client::builder()
2232 .no_proxy() .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
2234 .pool_max_idle_per_host(config.pool_max_idle_per_host)
2235 .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
2236
2237 builder = builder.redirect(reqwest::redirect::Policy::none());
2241
2242 if let Some((host, addrs)) = resolve_override {
2243 builder = builder.resolve_to_addrs(host, addrs);
2244 }
2245
2246 if let Some(tls) = &config.tls
2247 && tls.enabled
2248 {
2249 if tls.insecure || !tls.verify_peer {
2250 tracing::warn!("HTTP TLS verification disabled — connections are vulnerable to MitM");
2252 builder = builder.danger_accept_invalid_certs(true);
2253 }
2254
2255 if let Some(ca_path) = &tls.ca_cert_path {
2256 match std::fs::read(ca_path) {
2261 Ok(ca_bytes) => {
2262 let pem_sections = rustls_pemfile::certs(&mut std::io::Cursor::new(&ca_bytes))
2269 .filter(|r| r.is_ok())
2270 .count();
2271 if pem_sections == 0 {
2272 tracing::warn!(
2274 "configured CA certificate contains no parseable PEM CERTIFICATE section — falling back to system roots"
2275 );
2276 }
2277 match reqwest::Certificate::from_pem(&ca_bytes)
2278 .or_else(|_| reqwest::Certificate::from_der(&ca_bytes))
2279 {
2280 Ok(ca_cert) => {
2281 builder = builder.add_root_certificate(ca_cert);
2282 }
2283 Err(e) => {
2284 tracing::warn!(
2286 error = %e,
2287 "configured CA certificate failed to parse — falling back to system roots"
2288 );
2289 }
2290 }
2291 }
2292 Err(e) => {
2293 tracing::warn!(
2295 error = %e,
2296 "configured CA certificate file unreadable — falling back to system roots"
2297 );
2298 }
2299 }
2300 }
2301
2302 if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path) {
2306 match (std::fs::read(cert_path), std::fs::read(key_path)) {
2307 (Ok(cert_bytes), Ok(key_bytes)) => {
2308 let mut identity_pem = cert_bytes;
2309 identity_pem.extend_from_slice(&key_bytes);
2310 match reqwest::Identity::from_pem(&identity_pem) {
2311 Ok(identity) => {
2312 builder = builder.identity(identity);
2313 }
2314 Err(e) => {
2315 tracing::warn!(
2317 error = %e,
2318 "configured mTLS identity failed to parse — client certificate NOT used"
2319 );
2320 }
2321 }
2322 }
2323 (cert_r, key_r) => {
2324 tracing::warn!(
2326 cert_ok = cert_r.is_ok(),
2327 key_ok = key_r.is_ok(),
2328 "configured mTLS cert/key file unreadable — client certificate NOT used"
2329 );
2330 }
2331 }
2332 }
2333 }
2334
2335 builder
2336 .build()
2337 .expect("reqwest::Client::build() with valid config should not fail") }
2339
2340fn strict_tls_error(config: &HttpConfig) -> Option<CamelError> {
2349 let tls = config.tls.as_ref()?;
2350 if !tls.enabled || !tls.strict {
2351 return None;
2352 }
2353 if let Some(ca_path) = &tls.ca_cert_path {
2354 match std::fs::read(ca_path) {
2355 Ok(ca_bytes) => {
2356 let pem_sections = rustls_pemfile::certs(&mut std::io::Cursor::new(&ca_bytes))
2368 .filter(|r| r.is_ok())
2369 .count();
2370 if pem_sections == 0 {
2371 return Some(CamelError::EndpointCreationFailed(format!(
2372 "tls.strict: configured CA certificate '{ca_path}' has no \
2373 parseable PEM CERTIFICATE section (DER bundles are not \
2374 enforced by the TLS backend — convert to PEM)"
2375 )));
2376 }
2377 }
2378 Err(e) => {
2379 return Some(CamelError::EndpointCreationFailed(format!(
2380 "tls.strict: configured CA certificate '{ca_path}' is unreadable: {e}"
2381 )));
2382 }
2383 }
2384 }
2385 if tls.client_cert_path.is_some() != tls.client_key_path.is_some() {
2389 return Some(CamelError::EndpointCreationFailed(
2390 "tls.strict: mTLS requires BOTH client_cert_path and client_key_path".to_string(),
2391 ));
2392 }
2393 if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path) {
2394 match (std::fs::read(cert_path), std::fs::read(key_path)) {
2395 (Ok(mut cert_bytes), Ok(key_bytes)) => {
2396 cert_bytes.extend_from_slice(&key_bytes);
2397 if reqwest::Identity::from_pem(&cert_bytes).is_err() {
2398 return Some(CamelError::EndpointCreationFailed(
2399 "tls.strict: configured mTLS identity failed to parse".to_string(),
2400 ));
2401 }
2402 }
2403 _ => {
2404 return Some(CamelError::EndpointCreationFailed(
2405 "tls.strict: configured mTLS cert/key files are unreadable".to_string(),
2406 ));
2407 }
2408 }
2409 }
2410 None
2411}
2412
2413#[cfg(test)]
2414pub(crate) fn build_client_call_count() -> u64 {
2415 BUILD_CLIENT_CALLS.with(|c| c.get())
2416}
2417
2418impl HttpComponent {
2419 pub fn new() -> Self {
2420 let config = HttpConfig::default();
2421 let strict_err = strict_tls_error(&config);
2422 Self {
2423 client: build_client(&config, None),
2424 config,
2425 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2426 PINNED_CLIENT_TTL,
2427 PINNED_CLIENT_MAX_ENTRIES,
2428 )),
2429 strict_tls_error: strict_err,
2430 }
2431 }
2432
2433 pub fn with_config(config: HttpConfig) -> Self {
2434 let strict_err = strict_tls_error(&config);
2435 Self {
2436 client: build_client(&config, None),
2437 config,
2438 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2439 PINNED_CLIENT_TTL,
2440 PINNED_CLIENT_MAX_ENTRIES,
2441 )),
2442 strict_tls_error: strict_err,
2443 }
2444 }
2445
2446 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2447 match config {
2448 Some(cfg) => Self::with_config(cfg),
2449 None => Self::new(),
2450 }
2451 }
2452}
2453
2454impl Default for HttpComponent {
2455 fn default() -> Self {
2456 Self::new()
2457 }
2458}
2459
2460impl Component for HttpComponent {
2461 fn scheme(&self) -> &str {
2462 "http"
2463 }
2464
2465 fn metadata(&self) -> ComponentMetadata {
2466 HttpEndpointConfig::metadata()
2467 }
2468
2469 fn create_endpoint(
2470 &self,
2471 uri: &str,
2472 ctx: &dyn camel_component_api::ComponentContext,
2473 ) -> Result<Box<dyn Endpoint>, CamelError> {
2474 if let Some(err) = &self.strict_tls_error {
2475 return Err(err.clone());
2476 }
2477 self.config.validate()?;
2478 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2479 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2480 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2481 server_config.host.clone(),
2482 server_config.port,
2483 )));
2484 self.pinned_cache
2485 .wire(HttpComponentKind::Http, ctx.metrics());
2486 Ok(Box::new(HttpEndpoint {
2487 uri: uri.to_string(),
2488 config,
2489 server_config,
2490 client: self.client.clone(),
2491 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2492 http_config: self.config.clone(),
2493 }))
2494 }
2495}
2496
2497pub struct HttpsComponent {
2498 config: HttpConfig,
2499 pinned_cache: std::sync::Arc<PinnedClientCache>,
2500 client: reqwest::Client,
2501 strict_tls_error: Option<CamelError>,
2505}
2506
2507impl HttpsComponent {
2508 pub fn new() -> Self {
2509 let config = HttpConfig::default();
2510 let strict_err = strict_tls_error(&config);
2511 Self {
2512 client: build_client(&config, None),
2513 config,
2514 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2515 PINNED_CLIENT_TTL,
2516 PINNED_CLIENT_MAX_ENTRIES,
2517 )),
2518 strict_tls_error: strict_err,
2519 }
2520 }
2521
2522 pub fn with_config(config: HttpConfig) -> Self {
2523 let strict_err = strict_tls_error(&config);
2524 Self {
2525 client: build_client(&config, None),
2526 config,
2527 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2528 PINNED_CLIENT_TTL,
2529 PINNED_CLIENT_MAX_ENTRIES,
2530 )),
2531 strict_tls_error: strict_err,
2532 }
2533 }
2534
2535 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2536 match config {
2537 Some(cfg) => Self::with_config(cfg),
2538 None => Self::new(),
2539 }
2540 }
2541}
2542
2543impl Default for HttpsComponent {
2544 fn default() -> Self {
2545 Self::new()
2546 }
2547}
2548
2549impl Component for HttpsComponent {
2550 fn scheme(&self) -> &str {
2551 "https"
2552 }
2553
2554 fn metadata(&self) -> ComponentMetadata {
2555 let mut meta = HttpEndpointConfig::metadata();
2558 meta.scheme = "https".to_string();
2559 meta.description = "HTTPS client and server component (TLS over HTTP)".to_string();
2560 meta
2561 }
2562
2563 fn create_endpoint(
2564 &self,
2565 uri: &str,
2566 ctx: &dyn camel_component_api::ComponentContext,
2567 ) -> Result<Box<dyn Endpoint>, CamelError> {
2568 if let Some(err) = &self.strict_tls_error {
2569 return Err(err.clone());
2570 }
2571 self.config.validate()?;
2572 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2573 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2574 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2575 server_config.host.clone(),
2576 server_config.port,
2577 )));
2578 self.pinned_cache
2579 .wire(HttpComponentKind::Https, ctx.metrics());
2580 Ok(Box::new(HttpEndpoint {
2581 uri: uri.to_string(),
2582 config,
2583 server_config,
2584 client: self.client.clone(),
2585 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2586 http_config: self.config.clone(),
2587 }))
2588 }
2589}
2590
2591struct HttpEndpoint {
2596 uri: String,
2597 config: HttpEndpointConfig,
2598 server_config: HttpServerConfig,
2599 client: reqwest::Client,
2600 pinned_cache: std::sync::Arc<PinnedClientCache>,
2601 http_config: HttpConfig,
2602}
2603
2604impl Endpoint for HttpEndpoint {
2605 fn uri(&self) -> &str {
2606 &self.uri
2607 }
2608
2609 fn create_consumer(
2610 &self,
2611 rt: Arc<dyn camel_component_api::RuntimeObservability>,
2612 ) -> Result<Box<dyn Consumer>, CamelError> {
2613 let scheme_is_https = self.server_config.scheme == "https";
2616 let has_tls = self.server_config.tls_config.is_some();
2617
2618 if scheme_is_https && !has_tls {
2619 return Err(CamelError::EndpointCreationFailed(
2620 "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
2621 ));
2622 }
2623 if !scheme_is_https && has_tls {
2624 return Err(CamelError::EndpointCreationFailed(
2625 "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
2626 ));
2627 }
2628 Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
2629 }
2630
2631 fn create_producer(
2632 &self,
2633 rt: Arc<dyn camel_component_api::RuntimeObservability>,
2634 _ctx: &ProducerContext,
2635 ) -> Result<BoxProcessor, CamelError> {
2636 let producer = HttpProducer {
2637 config: Arc::new(self.config.clone()),
2638 client: self.client.clone(),
2639 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2640 http_config: Arc::new(self.http_config.clone()),
2641 runtime: rt,
2642 };
2643 if let Some(ref provider) = self.config.token_provider {
2644 let layer = BearerTokenLayer::new(Arc::clone(provider));
2645 Ok(BoxProcessor::new(layer.layer(producer)))
2646 } else {
2647 Ok(BoxProcessor::new(producer))
2648 }
2649 }
2650}
2651
2652#[derive(Clone)]
2657struct HttpProducer {
2658 config: Arc<HttpEndpointConfig>,
2659 client: reqwest::Client,
2660 pinned_cache: std::sync::Arc<PinnedClientCache>,
2661 http_config: Arc<HttpConfig>,
2662 runtime: Arc<dyn RuntimeObservability>,
2668}
2669
2670impl HttpProducer {
2671 fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
2672 if let Some(ref method) = config.http_method {
2673 return method.to_uppercase();
2674 }
2675 if let Some(method) = exchange
2676 .input
2677 .header("CamelHttpMethod")
2678 .and_then(|v| v.as_str())
2679 {
2680 return method.to_uppercase();
2681 }
2682 if !exchange.input.body.is_empty() {
2683 return "POST".to_string();
2684 }
2685 "GET".to_string()
2686 }
2687
2688 fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> Result<String, CamelError> {
2689 if config.bridge_endpoint {
2698 let Some(query) = resolve_endpoint_query(config)? else {
2699 return Ok(config.base_url.clone());
2700 };
2701 let _: url::Url = url::Url::parse(&config.base_url).map_err(|e| {
2709 CamelError::ProcessorError(format!(
2710 "invalid base URL '{}': {e}",
2711 redact_url_for_diagnostics(&config.base_url)
2712 ))
2713 })?;
2714 let mut url = config.base_url.clone();
2715 url.push('?');
2716 url.push_str(&query);
2717 return Ok(url);
2718 }
2719
2720 if let Some(uri) = exchange
2721 .input
2722 .header("CamelHttpUri")
2723 .and_then(|v| v.as_str())
2724 {
2725 if let Some(fence) = &config.allowed_uri_hosts
2730 && !uri_host_allowed(uri, fence)?
2731 {
2732 return Err(CamelError::ProcessorError(format!(
2733 "CamelHttpUri host not allowed by allowedUriHosts fence: {}",
2734 redact_url_for_diagnostics(uri)
2735 )));
2736 }
2737 let (base, override_query) = match uri.split_once('?') {
2744 Some((base, query)) => (base, Some(query)),
2745 None => (uri, None),
2746 };
2747 if let Some(query) = override_query {
2753 for (_key, span) in raw_query_pairs(query)? {
2754 validate_raw_query_span(span)?;
2755 }
2756 }
2757 let mut url = base.to_string();
2758 if let Some(path) = exchange
2759 .input
2760 .header("CamelHttpPath")
2761 .and_then(|v| v.as_str())
2762 {
2763 if !url.ends_with('/') && !path.starts_with('/') {
2764 url.push('/');
2765 }
2766 url.push_str(path);
2767 }
2768 if let Some(query) = exchange
2769 .input
2770 .header("CamelHttpQuery")
2771 .and_then(|v| v.as_str())
2772 {
2773 if let Some(merged) = merge_header_query(override_query, query)? {
2774 url.push('?');
2775 url.push_str(&merged);
2776 }
2777 return Ok(url);
2778 }
2779 if let Some(query) = override_query {
2780 url.push('?');
2781 url.push_str(query);
2782 }
2783 return Ok(url);
2784 }
2785
2786 let mut url = config.base_url.clone();
2787
2788 if let Some(path) = exchange
2789 .input
2790 .header("CamelHttpPath")
2791 .and_then(|v| v.as_str())
2792 {
2793 if !url.ends_with('/') && !path.starts_with('/') {
2794 url.push('/');
2795 }
2796 url.push_str(path);
2797 }
2798
2799 if let Some(query) = exchange
2800 .input
2801 .header("CamelHttpQuery")
2802 .and_then(|v| v.as_str())
2803 {
2804 if let Some(merged) =
2809 merge_header_query(resolve_endpoint_query(config)?.as_deref(), query)?
2810 {
2811 url.push('?');
2812 url.push_str(&merged);
2813 }
2814 return Ok(url);
2815 }
2816
2817 if let Some(query) = resolve_endpoint_query(config)? {
2818 url.push('?');
2819 url.push_str(&query);
2820 }
2821
2822 Ok(url)
2823 }
2824
2825 fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
2826 status >= range.0 && status <= range.1
2827 }
2828}
2829
2830#[derive(Clone, Debug, PartialEq, Eq)]
2835pub struct AllowedUriHost {
2836 pub host: String,
2838 pub port: Option<u16>,
2840}
2841
2842fn parse_allowed_uri_hosts(raw: &str) -> Result<Vec<AllowedUriHost>, CamelError> {
2850 let mut entries = Vec::new();
2851 for segment in raw.split(',') {
2852 let segment = segment.trim();
2853 if segment.is_empty() {
2854 continue;
2855 }
2856 let parsed = url::Url::parse(&format!("http://{segment}"))
2857 .map_err(|_| invalid_allowed_uri_host_entry(segment))?;
2858 if parsed.path() != "/" || !parsed.username().is_empty() || parsed.password().is_some() {
2862 return Err(invalid_allowed_uri_host_entry(segment));
2863 }
2864 let Some(host) = parsed.host_str() else {
2865 return Err(invalid_allowed_uri_host_entry(segment));
2866 };
2867 entries.push(AllowedUriHost {
2868 host: host.to_string(),
2869 port: parsed.port(),
2870 });
2871 }
2872 if entries.is_empty() {
2873 return Err(CamelError::InvalidUri(
2874 "allowedUriHosts declares no valid host entries".to_string(),
2875 ));
2876 }
2877 Ok(entries)
2878}
2879
2880fn invalid_allowed_uri_host_entry(segment: &str) -> CamelError {
2881 CamelError::InvalidUri(format!("invalid allowedUriHosts entry '{segment}'"))
2882}
2883
2884pub(crate) fn uri_host_allowed(
2891 url_str: &str,
2892 fence: &[AllowedUriHost],
2893) -> Result<bool, CamelError> {
2894 let Ok(parsed) = url::Url::parse(url_str) else {
2895 return Ok(false);
2896 };
2897 let Some(host) = parsed.host_str() else {
2898 return Ok(false);
2899 };
2900 let effective_port = parsed.port().or(match parsed.scheme() {
2901 "https" => Some(443_u16),
2902 "http" => Some(80),
2903 _ => None,
2904 });
2905 Ok(fence.iter().any(|entry| {
2906 entry.host == host
2907 && match entry.port {
2908 None => true,
2909 Some(port) => effective_port == Some(port),
2910 }
2911 }))
2912}
2913
2914fn resolve_endpoint_query(config: &HttpEndpointConfig) -> Result<Option<String>, CamelError> {
2927 let mut parts: Vec<String> = Vec::new();
2928 let mut authored_keys = std::collections::HashSet::new();
2929
2930 if let Some(raw) = config.raw_query.as_deref() {
2931 for (key, span) in raw_query_pairs(raw)? {
2932 authored_keys.insert(key.clone());
2933 if is_consumed_option(&key) {
2934 continue;
2935 }
2936 validate_raw_query_span(span)?;
2937 parts.push(span.to_string());
2938 }
2939 }
2940
2941 for (key, value) in &config.query_params {
2942 if !authored_keys.contains(key.as_str()) {
2943 parts.push(format!(
2944 "{}={}",
2945 encode_query_component(key),
2946 encode_query_component(value)
2947 ));
2948 }
2949 }
2950
2951 if parts.is_empty() && config.raw_query.as_deref() != Some("") {
2952 return Ok(None);
2953 }
2954 Ok(Some(parts.join("&")))
2955}
2956
2957fn merge_header_query(
2966 higher_precedence: Option<&str>,
2967 header_query: &str,
2968) -> Result<Option<String>, CamelError> {
2969 if header_query.is_empty() {
2970 return Ok(higher_precedence.map(str::to_string));
2971 }
2972 let mut parts: Vec<String> = Vec::new();
2973 let mut higher_keys = std::collections::HashSet::new();
2974 for (key, span) in raw_query_pairs(higher_precedence.unwrap_or(""))? {
2975 higher_keys.insert(key);
2976 parts.push(span.to_string());
2977 }
2978 for (key, span) in raw_query_pairs(header_query)? {
2979 validate_raw_query_span(span)?;
2980 if !higher_keys.contains(key.as_str()) {
2981 parts.push(span.to_string());
2982 }
2983 }
2984 if parts.is_empty() {
2985 return Ok(None);
2986 }
2987 Ok(Some(parts.join("&")))
2988}
2989
2990fn is_legal_query_byte(byte: u8) -> bool {
3001 matches!(byte,
3002 b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z'
3003 | b'-' | b'.' | b'_' | b'~'
3004 | b'!' | b'$' | b'&' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'='
3005 | b':' | b'@' | b'/' | b'?'
3006 | b'%')
3007}
3008
3009fn validate_raw_query_span(span: &str) -> Result<(), CamelError> {
3015 for &byte in span.as_bytes() {
3016 if !is_legal_query_byte(byte) {
3017 return Err(CamelError::ProcessorError(format!(
3018 "raw query pair '{span}' contains byte 0x{byte:02X}, which is not legal in a URL query component"
3019 )));
3020 }
3021 }
3022 Ok(())
3023}
3024
3025fn encode_query_component(component: &str) -> String {
3029 const HEX: &[u8; 16] = b"0123456789ABCDEF";
3030 let mut out = String::with_capacity(component.len());
3031 for &byte in component.as_bytes() {
3032 match byte {
3033 b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z' | b'-' | b'.' | b'_' | b'~' => {
3034 out.push(byte as char);
3035 }
3036 _ => {
3037 out.push('%');
3038 out.push(HEX[(byte >> 4) as usize] as char);
3039 out.push(HEX[(byte & 0x0f) as usize] as char);
3040 }
3041 }
3042 }
3043 out
3044}
3045
3046pub(crate) fn redact_url_for_diagnostics(raw: &str) -> String {
3074 match url::Url::parse(raw) {
3075 Ok(mut u) => {
3076 if !u.cannot_be_a_base()
3083 && u.host_str().is_none()
3084 && camel_api::redact::window_has_at_sign(raw)
3085 {
3086 return "[redacted]".to_string();
3087 }
3088 if !u.username().is_empty() || u.password().is_some() {
3089 let _ = u.set_username("***");
3090 let _ = u.set_password(None);
3091 }
3092 let s = u.to_string();
3095 camel_api::redact::redact_url(&s)
3096 }
3097 Err(_) => camel_api::redact::redact_url_fail_closed(raw),
3098 }
3099}
3100
3101const MAX_ERROR_RESPONSE_BODY_BYTES: usize = 4096;
3106
3107fn truncate_error_body(body: &[u8]) -> String {
3108 if body.len() <= MAX_ERROR_RESPONSE_BODY_BYTES {
3109 String::from_utf8_lossy(body).into_owned()
3110 } else {
3111 let mut s = String::from_utf8_lossy(&body[..MAX_ERROR_RESPONSE_BODY_BYTES]).into_owned();
3112 s.push_str("...[truncated]");
3113 s
3114 }
3115}
3116
3117impl HttpProducer {
3118 fn is_entity_enclosing(method: &str) -> bool {
3123 matches!(method, "POST" | "PUT" | "PATCH")
3124 }
3125}
3126
3127impl Service<Exchange> for HttpProducer {
3128 type Response = Exchange;
3129 type Error = CamelError;
3130 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
3131
3132 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
3133 Poll::Ready(Ok(()))
3134 }
3135
3136 fn call(&mut self, exchange: Exchange) -> Self::Future {
3137 let config = self.config.clone();
3138 let shared_client = self.client.clone();
3139 let pinned_cache = std::sync::Arc::clone(&self.pinned_cache);
3140 let http_config = self.http_config.clone();
3141 let component_metrics = self.runtime.component_metrics();
3142
3143 Box::pin(async move {
3144 let mut exchange = exchange;
3145 let outcome = async {
3146 let method_str = HttpProducer::resolve_method(&exchange, &config);
3147 let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
3152 let url = HttpProducer::resolve_url(&exchange, &config)?;
3153
3154 ssrf::validate_url_for_ssrf(&url, &config)?;
3156
3157 let resolved = ssrf::resolve_initial_url_for_ssrf(
3165 &url,
3166 config.allow_internal,
3167 config.allow_cleartext,
3168 )
3169 .await?;
3170 let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
3171 pinned_cache
3172 .get_or_build(host.as_str(), addrs, || {
3173 build_client(&http_config, Some((host.as_str(), addrs)))
3174 })
3175 .await
3176 } else {
3177 shared_client.clone()
3178 };
3179
3180 debug!(
3181 correlation_id = %exchange.correlation_id(),
3182 method = %method_str,
3183 url = %redact_url_for_diagnostics(&url),
3184 "HTTP request"
3185 );
3186
3187 let method = method_str.parse::<reqwest::Method>().map_err(|e| {
3188 CamelError::ProcessorError(format!(
3189 "Invalid HTTP method '{}': {}",
3190 method_str, e
3191 ))
3192 })?;
3193
3194 let mut collected_headers: Vec<(
3196 reqwest::header::HeaderName,
3197 reqwest::header::HeaderValue,
3198 )> = Vec::new();
3199
3200 if let Some(user_agent) = &config.user_agent
3201 && !config.bridge_endpoint
3202 {
3203 match constructed_header("user-agent", user_agent) {
3204 Ok((_, val)) => {
3205 collected_headers.push((reqwest::header::USER_AGENT, val));
3206 }
3207 Err(drop) => debug!(
3208 correlation_id = %exchange.correlation_id(),
3209 header = %drop.name,
3210 "outbound header dropped: {}",
3211 drop.reason
3212 ),
3213 }
3214 }
3215
3216 #[cfg(feature = "otel")]
3218 let should_inject_otel = !config.bridge_endpoint;
3219 #[cfg(feature = "otel")]
3220 if should_inject_otel {
3221 let mut otel_headers = HashMap::new();
3222 camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
3223 for (k, v) in otel_headers {
3224 match constructed_header(&k, &v) {
3225 Ok((name, val)) => collected_headers.push((name, val)),
3226 Err(drop) => debug!(
3227 correlation_id = %exchange.correlation_id(),
3228 header = %drop.name,
3229 "outbound header dropped: {}",
3230 drop.reason
3231 ),
3232 }
3233 }
3234 }
3235
3236 let conn_tokens = header_policy::connection_tokens(
3237 exchange
3238 .input
3239 .headers
3240 .iter()
3241 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3242 .filter_map(|(_, v)| v.as_str()),
3243 );
3244
3245 let outbound = select_outbound_headers(
3246 &exchange.input.headers,
3247 &config.skip_request_headers,
3248 &conn_tokens,
3249 );
3250 for drop in &outbound.drops {
3251 if let Some(value_kind) = drop.value_kind {
3252 debug!(
3253 correlation_id = %exchange.correlation_id(),
3254 header = %drop.name,
3255 value_kind = value_kind,
3256 "outbound header dropped: {}",
3257 drop.reason
3258 );
3259 } else {
3260 debug!(
3261 correlation_id = %exchange.correlation_id(),
3262 header = %drop.name,
3263 "outbound header dropped: {}",
3264 drop.reason
3265 );
3266 }
3267 }
3268 collected_headers.extend(outbound.accepted);
3269
3270 if !config.bridge_endpoint {
3272 match &config.auth {
3273 HttpAuth::None => {}
3274 HttpAuth::Basic { username, password } => {
3275 use base64::Engine;
3276 let credentials = format!("{username}:{password}");
3278 let encoded =
3279 base64::engine::general_purpose::STANDARD.encode(credentials);
3280 match constructed_header("authorization", &format!("Basic {encoded}")) {
3283 Ok((_, val)) => {
3284 collected_headers.push((reqwest::header::AUTHORIZATION, val));
3285 }
3286 Err(drop) => debug!(
3287 correlation_id = %exchange.correlation_id(),
3288 header = %drop.name,
3289 "outbound header dropped: {}",
3290 drop.reason
3291 ),
3292 }
3293 }
3294 HttpAuth::Bearer { token } => {
3295 let bearer = format!("Bearer {token}");
3297 match constructed_header("authorization", &bearer) {
3298 Ok((_, val)) => {
3299 collected_headers.push((reqwest::header::AUTHORIZATION, val));
3300 }
3301 Err(drop) => debug!(
3302 correlation_id = %exchange.correlation_id(),
3303 header = %drop.name,
3304 "outbound header dropped: {}",
3305 drop.reason
3306 ),
3307 }
3308 }
3309 }
3310
3311 if config.connection_close {
3312 collected_headers.push((
3313 reqwest::header::CONNECTION,
3314 reqwest::header::HeaderValue::from_static("close"),
3315 ));
3316 }
3317 }
3318
3319 let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
3321 let materialized_body: Option<Vec<u8>> = if is_stream_body {
3322 if suppress_body {
3323 std::mem::take(&mut exchange.input.body);
3331 tracing::warn!(
3333 correlation_id = %exchange.correlation_id(),
3334 method = %method_str,
3335 "dropping request body for non-entity-enclosing HTTP method"
3336 );
3337 }
3338 None } else {
3340 let body = std::mem::take(&mut exchange.input.body);
3341 let bytes = body.into_bytes(config.max_body_size).await?;
3342 if bytes.is_empty() {
3343 None
3345 } else if suppress_body {
3346 tracing::warn!(
3348 correlation_id = %exchange.correlation_id(),
3349 method = %method_str,
3350 "dropping request body for non-entity-enclosing HTTP method"
3351 );
3352 None
3353 } else {
3354 Some(bytes.to_vec())
3355 }
3356 };
3357
3358 let response = if config.follow_redirects && !is_stream_body {
3359 ssrf::send_with_ssrf_safe_redirects(
3365 &client,
3366 &shared_client,
3367 &pinned_cache,
3368 &http_config,
3369 &config,
3370 method,
3371 &url,
3372 collected_headers,
3373 materialized_body,
3374 config.max_redirects,
3375 config.response_timeout,
3376 )
3377 .await?
3378 } else {
3379 let mut request = client.request(method, &url);
3381
3382 if let Some(timeout) = config.response_timeout {
3383 request = request.timeout(timeout);
3384 }
3385
3386 for (name, value) in &collected_headers {
3387 request = request.header(name, value);
3388 }
3389
3390 if is_stream_body {
3391 if let Body::Stream(ref s) = exchange.input.body {
3392 let mut stream_lock = s.stream.lock().await;
3393 if let Some(stream) = stream_lock.take() {
3394 request = request.body(reqwest::Body::wrap_stream(stream));
3395 } else {
3396 return Err(CamelError::AlreadyConsumed);
3397 }
3398 }
3399 } else if let Some(ref body_bytes) = materialized_body {
3400 request = request.body(body_bytes.clone());
3401 }
3402
3403 request.send().await.map_err(|e| {
3404 CamelError::ProcessorError(format!("HTTP request failed: {e}"))
3405 })?
3406 };
3407
3408 let status_code = response.status().as_u16();
3409 let status_text = response
3410 .status()
3411 .canonical_reason()
3412 .unwrap_or("Unknown")
3413 .to_string();
3414
3415 for (key, value) in response.headers() {
3416 if config
3417 .skip_response_headers
3418 .iter()
3419 .any(|h| h.eq_ignore_ascii_case(key.as_str()))
3420 {
3421 continue;
3422 }
3423 if let Ok(val_str) = value.to_str() {
3424 exchange.input.set_header(
3425 title_case_header(key.as_str()),
3426 serde_json::Value::String(val_str.to_string()),
3427 );
3428 }
3429 }
3430
3431 exchange.input.set_header(
3432 "CamelHttpResponseCode",
3433 serde_json::Value::Number(status_code.into()),
3434 );
3435 exchange.input.set_header(
3436 "CamelHttpResponseText",
3437 serde_json::Value::String(status_text.clone()),
3438 );
3439
3440 let read_timeout = Duration::from_millis(config.read_timeout_ms);
3442 let response_body = tokio::time::timeout(read_timeout, async {
3443 if let Some(content_len) = response.content_length()
3445 && content_len > config.max_response_bytes as u64
3446 {
3447 return Err(CamelError::ProcessorError(format!(
3448 "Response body too large: {} bytes exceeds limit of {} bytes",
3449 content_len, config.max_response_bytes
3450 )));
3451 }
3452 use futures::TryStreamExt;
3454 let mut stream = response.bytes_stream();
3455 let mut total: usize = 0;
3456 let mut collected = Vec::new();
3457 while let Some(chunk) = stream.try_next().await.map_err(|e| {
3458 CamelError::ProcessorError(format!("Failed to read response body: {e}"))
3459 })? {
3460 total += chunk.len();
3461 if total > config.max_response_bytes {
3462 return Err(CamelError::ProcessorError(format!(
3463 "Response body too large: {} bytes exceeds limit of {} bytes",
3464 total, config.max_response_bytes
3465 )));
3466 }
3467 collected.push(chunk);
3468 }
3469 let mut result = bytes::BytesMut::with_capacity(total);
3470 for chunk in collected {
3471 result.extend_from_slice(&chunk);
3472 }
3473 Ok::<bytes::Bytes, CamelError>(result.freeze())
3474 })
3475 .await
3476 .map_err(|_| {
3477 CamelError::ProcessorError(format!(
3478 "Read timeout after {}ms",
3479 config.read_timeout_ms
3480 ))
3481 })??;
3482
3483 if config.throw_exception_on_failure
3484 && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
3485 {
3486 return Err(CamelError::HttpOperationFailed {
3487 method: method_str,
3488 url: redact_url_for_diagnostics(&url),
3491 status_code,
3492 status_text,
3493 response_body: Some(truncate_error_body(&response_body)),
3494 });
3495 }
3496
3497 if !response_body.is_empty() {
3498 exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
3499 }
3500
3501 debug!(
3502 correlation_id = %exchange.correlation_id(),
3503 status = status_code,
3504 url = %redact_url_for_diagnostics(&url),
3505 "HTTP response"
3506 );
3507 Ok(exchange)
3508 }
3509 .await;
3510 component_metrics.observe("http", "request", outcome.is_err());
3517 outcome
3518 })
3519 }
3520}
3521
3522#[cfg(test)]
3536pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
3537
3538#[cfg(test)]
3546pub(crate) fn lock_registry_test_mutex() -> std::sync::MutexGuard<'static, ()> {
3547 REGISTRY_TEST_MUTEX
3548 .lock()
3549 .unwrap_or_else(|poisoned| poisoned.into_inner())
3550}
3551
3552fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
3562 match e {
3563 CamelError::Unauthenticated(msg) => {
3564 tracing::warn!(error = %msg, path = %path, "Authentication failed");
3565 HttpReply {
3566 status: 401,
3567 headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
3568 body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
3569 }
3570 }
3571 CamelError::Unauthorized(msg) => {
3572 tracing::warn!(error = %msg, path = %path, "Authorization failed");
3573 HttpReply {
3574 status: 403,
3575 headers: vec![],
3576 body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
3577 }
3578 }
3579 CamelError::TypeConversionFailed(msg) => {
3580 tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
3581 json_error_reply(400, "bad_request", msg)
3582 }
3583 CamelError::ValidationError(msg) => {
3584 tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
3585 json_error_reply(400, "validation_error", msg)
3586 }
3587 CamelError::ConsumerStopping => {
3588 tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
3589 HttpReply {
3590 status: 503,
3591 headers: vec![],
3592 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
3593 }
3594 }
3595 CamelError::UnsupportedMediaType { consumed, declared } => {
3596 tracing::warn!(error = %consumed, declared = %declared, path = %path, "Unsupported media type (bad request)");
3597 json_error_reply(
3598 415,
3599 "unsupported_media_type",
3600 format!("consumed {consumed}, declared {declared}"),
3601 )
3602 }
3603 CamelError::NotAcceptable { accept, produced } => {
3604 tracing::warn!(error = %accept, produced = %produced, path = %path, "Not acceptable (bad request)");
3605 json_error_reply(
3606 406,
3607 "not_acceptable",
3608 format!("accept {accept}, produced {produced}"),
3609 )
3610 }
3611 e => {
3612 tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
3614 HttpReply {
3615 status: 500,
3616 headers: vec![],
3617 body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
3618 }
3619 }
3620 }
3621}
3622
3623fn json_error_reply(status: u16, code: &str, message: String) -> HttpReply {
3631 let body = serde_json::to_string(&serde_json::json!({
3632 "error": code,
3633 "message": message,
3634 }))
3635 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
3637 status,
3638 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
3639 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
3640 }
3641}
3642
3643const fn json_value_kind(v: &serde_json::Value) -> &'static str {
3647 match v {
3648 serde_json::Value::Null => "null",
3649 serde_json::Value::Bool(_) => "bool",
3650 serde_json::Value::Number(_) => "number",
3651 serde_json::Value::String(_) => "string",
3652 serde_json::Value::Array(_) => "array",
3653 serde_json::Value::Object(_) => "object",
3654 }
3655}
3656
3657fn scalar_string_form(v: &serde_json::Value) -> Option<String> {
3662 match v {
3663 serde_json::Value::String(s) => Some(s.clone()),
3664 serde_json::Value::Number(n) => Some(n.to_string()),
3665 serde_json::Value::Bool(b) => Some(b.to_string()),
3666 _ => None,
3667 }
3668}
3669
3670fn select_response_headers(
3685 headers: &HashMap<String, serde_json::Value>,
3686 user_content_type: Option<String>,
3687 inferred_content_type: Option<String>,
3688) -> Vec<(String, String)> {
3689 let conn_tokens = header_policy::connection_tokens(
3690 headers
3691 .iter()
3692 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3693 .filter_map(|(_, v)| v.as_str()),
3694 );
3695 let mut selected: Vec<(String, String)> = Vec::new();
3696 for (k, v) in headers {
3697 if k.starts_with("Camel") {
3698 debug!(header = %k, "reply header dropped: Camel namespace");
3699 continue;
3700 }
3701 if header_policy::excluded_response(k, &conn_tokens) {
3702 debug!(header = %k, "reply header dropped: emission policy");
3703 continue;
3704 }
3705 match scalar_string_form(v) {
3706 Some(s) => selected.push((k.clone(), s)),
3707 None => debug!(
3708 header = %k,
3709 value_kind = json_value_kind(v),
3710 "reply header dropped: no scalar string form"
3711 ),
3712 }
3713 }
3714 if let Some(ct) = user_content_type.or(inferred_content_type) {
3715 selected.push(("Content-Type".to_string(), ct));
3716 }
3717 selected
3718}
3719
3720#[derive(Debug)]
3725struct OutboundHeaderDrop<'a> {
3726 name: &'a str,
3727 reason: &'static str,
3728 value_kind: Option<&'static str>,
3729}
3730
3731struct OutboundHeaderSelection<'a> {
3734 accepted: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>,
3735 drops: Vec<OutboundHeaderDrop<'a>>,
3736}
3737
3738fn select_outbound_headers<'a>(
3752 headers: &'a HashMap<String, serde_json::Value>,
3753 skip_request_headers: &[String],
3754 conn_tokens: &[String],
3755) -> OutboundHeaderSelection<'a> {
3756 let mut accepted = Vec::new();
3757 let mut drops = Vec::new();
3758 for (key, value) in headers {
3759 if key.starts_with("Camel") {
3760 drops.push(OutboundHeaderDrop {
3761 name: key,
3762 reason: "Camel namespace",
3763 value_kind: None,
3764 });
3765 continue;
3766 }
3767 if skip_request_headers
3768 .iter()
3769 .any(|h| h.eq_ignore_ascii_case(key))
3770 {
3771 drops.push(OutboundHeaderDrop {
3772 name: key,
3773 reason: "skip_request_headers",
3774 value_kind: None,
3775 });
3776 continue;
3777 }
3778 if header_policy::excluded_outbound(key, conn_tokens) {
3779 drops.push(OutboundHeaderDrop {
3780 name: key,
3781 reason: "outbound emission policy",
3782 value_kind: None,
3783 });
3784 continue;
3785 }
3786 let Some(val_str) = scalar_string_form(value) else {
3787 drops.push(OutboundHeaderDrop {
3788 name: key,
3789 reason: "no scalar string form",
3790 value_kind: Some(json_value_kind(value)),
3791 });
3792 continue;
3793 };
3794 match constructed_header(key, &val_str) {
3795 Ok((name, val)) => accepted.push((name, val)),
3796 Err(drop) => drops.push(drop),
3797 }
3798 }
3799 OutboundHeaderSelection { accepted, drops }
3800}
3801
3802fn constructed_header<'a>(
3807 name: &'a str,
3808 value: &str,
3809) -> Result<(reqwest::header::HeaderName, reqwest::header::HeaderValue), OutboundHeaderDrop<'a>> {
3810 let header_name = match reqwest::header::HeaderName::from_bytes(name.as_bytes()) {
3811 Ok(header_name) => header_name,
3812 Err(_) => {
3813 return Err(OutboundHeaderDrop {
3814 name,
3815 reason: "invalid header name",
3816 value_kind: None,
3817 });
3818 }
3819 };
3820 let header_value = match reqwest::header::HeaderValue::from_str(value) {
3821 Ok(header_value) => header_value,
3822 Err(_) => {
3823 return Err(OutboundHeaderDrop {
3824 name,
3825 reason: "invalid header value",
3826 value_kind: None,
3827 });
3828 }
3829 };
3830 Ok((header_name, header_value))
3831}
3832
3833#[cfg(test)]
3834mod tests {
3835 use camel_component_api::test_support::NoopRuntimeObservability;
3836
3837 fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3841 std::sync::Arc::new(NoopRuntimeObservability)
3842 }
3843 fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3844 std::sync::Arc::new(NoopRuntimeObservability)
3845 }
3846 fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3847 std::sync::Arc::new(NoopRuntimeObservability)
3848 }
3849
3850 use super::*;
3851 use crate::config::TlsConfig;
3852 use crate::rest_match::PathSegment;
3853 use camel_component_api::{Message, NoOpComponentContext};
3854 use std::sync::Arc;
3855 use std::time::Duration;
3856
3857 fn test_producer_ctx() -> ProducerContext {
3858 ProducerContext::new()
3859 }
3860
3861 #[test]
3866 fn redact_url_drops_oauth2_fragment_access_token() {
3867 let redacted =
3868 redact_url_for_diagnostics("https://app.example/cb#access_token=SECRET&state=x");
3869 assert!(
3870 !redacted.contains("SECRET"),
3871 "fragment access token leaked: {redacted}"
3872 );
3873 assert!(
3874 !redacted.contains("access_token"),
3875 "fragment key leaked: {redacted}"
3876 );
3877 assert!(
3878 redacted.ends_with("#[redacted]"),
3879 "fragment must be replaced with the sentinel: {redacted}"
3880 );
3881 }
3882
3883 #[test]
3884 fn redact_url_drops_oauth2_fragment_id_token() {
3885 let redacted =
3886 redact_url_for_diagnostics("https://app.example/cb#id_token=eyJhbG.SECRET.SIG&state=y");
3887 assert!(
3888 !redacted.contains("eyJhbG"),
3889 "id token payload leaked: {redacted}"
3890 );
3891 assert!(
3892 !redacted.contains("id_token"),
3893 "id token key leaked: {redacted}"
3894 );
3895 assert!(
3896 !redacted.contains("SECRET"),
3897 "id token signature leaked: {redacted}"
3898 );
3899 assert!(
3900 redacted.ends_with("#[redacted]"),
3901 "fragment must be replaced with the sentinel: {redacted}"
3902 );
3903 }
3904
3905 #[test]
3906 fn redact_url_drops_generic_fragment_kv() {
3907 let redacted = redact_url_for_diagnostics("https://h.example/p/session#session=abc123");
3908 assert!(
3909 !redacted.contains("abc123"),
3910 "fragment value leaked: {redacted}"
3911 );
3912 assert!(
3913 !redacted.contains("session="),
3914 "fragment key leaked: {redacted}"
3915 );
3916 assert!(
3917 redacted.contains("#[redacted]"),
3918 "fragment must be replaced with the sentinel: {redacted}"
3919 );
3920 }
3921
3922 #[test]
3923 fn redact_url_query_and_fragment_sentinels_compose() {
3924 let redacted = redact_url_for_diagnostics("https://h.example/p?a=1#access_token=x");
3925 assert_eq!(
3926 redacted, "https://h.example/p?[redacted]#[redacted]",
3927 "query and fragment sentinels must compose: {redacted}"
3928 );
3929 }
3930
3931 #[test]
3932 fn redact_url_drops_benign_fragment_too() {
3933 let redacted = redact_url_for_diagnostics("https://h.example/docs#section-3");
3936 assert_eq!(
3937 redacted, "https://h.example/docs#[redacted]",
3938 "benign fragment must still be dropped: {redacted}"
3939 );
3940 }
3941
3942 #[test]
3943 fn redact_url_unparseable_fragment_credentials_dropped() {
3944 let raw = "ht tps://app.example/cb#access_token=SECRET";
3945 assert!(
3946 url::Url::parse(raw).is_err(),
3947 "fixture must be unparseable: {raw}"
3948 );
3949 let redacted = redact_url_for_diagnostics(raw);
3950 assert!(
3951 !redacted.contains("SECRET"),
3952 "unparseable fragment token leaked: {redacted}"
3953 );
3954 assert!(
3955 !redacted.contains("access_token"),
3956 "unparseable fragment bytes leaked: {redacted}"
3957 );
3958 assert!(
3959 redacted.contains("#[redacted]"),
3960 "unparseable fragment must end in the sentinel: {redacted}"
3961 );
3962 }
3963
3964 #[test]
3965 fn redact_url_double_slash_evader_sentinel() {
3966 let redacted = redact_url_for_diagnostics("scheme:////user:pass@evil/");
3969 assert_eq!(
3970 redacted, "[redacted]",
3971 "double-slash evader must fail closed: {redacted}"
3972 );
3973 }
3974
3975 #[test]
3976 fn redact_url_triple_slash_evader_sentinel() {
3977 let redacted = redact_url_for_diagnostics("scheme:///user:pass@evil/");
3978 assert_eq!(
3979 redacted, "[redacted]",
3980 "triple-slash evader must fail closed: {redacted}"
3981 );
3982 }
3983
3984 #[test]
3985 fn redact_url_bare_protocol_relative_userinfo_sentinel() {
3986 let redacted = redact_url_for_diagnostics("//user:pass@evil");
3987 assert_eq!(
3988 redacted, "[redacted]",
3989 "protocol-relative userinfo must fail closed: {redacted}"
3990 );
3991 }
3992
3993 #[test]
3994 fn redact_url_empty_host_userinfo_sentinel() {
3995 let redacted = redact_url_for_diagnostics("scheme://user@");
3998 assert_eq!(
3999 redacted, "[redacted]",
4000 "empty-host userinfo must fail closed: {redacted}"
4001 );
4002 }
4003
4004 #[test]
4005 fn redact_url_unparseable_slash_run_evader_sentinel() {
4006 let raw = "schem e:////user:pass@evil/";
4010 assert!(
4011 url::Url::parse(raw).is_err(),
4012 "fixture must be unparseable: {raw}"
4013 );
4014 let redacted = redact_url_for_diagnostics(raw);
4015 assert_eq!(
4016 redacted, "[redacted]",
4017 "unparseable slash-run evader must fail closed: {redacted}"
4018 );
4019 }
4020
4021 #[test]
4022 fn redact_url_unparseable_later_window_userinfo_sentinel() {
4023 let raw = "http://ho st/a//user:pass@evil/";
4027 assert!(
4028 url::Url::parse(raw).is_err(),
4029 "fixture must be unparseable: {raw}"
4030 );
4031 let redacted = redact_url_for_diagnostics(raw);
4032 assert_eq!(
4033 redacted, "[redacted]",
4034 "userinfo in a later // window must fail closed: {redacted}"
4035 );
4036 }
4037
4038 #[test]
4039 fn redact_url_parsed_later_window_userinfo_masked() {
4040 let redacted = redact_url_for_diagnostics("https://h//user:pass@evil/");
4045 assert!(
4046 !redacted.contains("user:pass"),
4047 "parsed later-window userinfo leaked: {redacted}"
4048 );
4049 assert!(
4050 redacted.contains("h//***@evil/"),
4051 "later window must be masked in place: {redacted}"
4052 );
4053 }
4054
4055 #[test]
4056 fn redact_url_parsed_window_mask_idempotent_with_real_userinfo() {
4057 let redacted = redact_url_for_diagnostics("https://user:pass@h//x@y/");
4061 assert!(
4062 redacted.contains("***@h"),
4063 "accessor mask must survive the window surgery: {redacted}"
4064 );
4065 assert!(
4066 !redacted.contains("user:pass"),
4067 "real userinfo leaked: {redacted}"
4068 );
4069 assert!(
4070 !redacted.contains("x@y"),
4071 "later path window leaked: {redacted}"
4072 );
4073 }
4074
4075 #[test]
4076 fn redact_url_backslash_authority_ruling() {
4077 let redacted = redact_url_for_diagnostics("http:\\\\user:pass@evil\\path");
4083 assert!(
4084 redacted.contains("***@"),
4085 "backslash authority must be userinfo-masked: {redacted}"
4086 );
4087 assert!(
4088 !redacted.contains("user:pass"),
4089 "backslash authority must not leak credentials: {redacted}"
4090 );
4091 }
4092
4093 #[test]
4094 fn non_special_backslash_authority_masked() {
4095 let redacted = redact_url_for_diagnostics("foo:\\user:pass@evil/");
4100 assert!(
4101 !redacted.contains("user:pass"),
4102 "non-special backslash authority leaked: {redacted}"
4103 );
4104 assert!(
4105 !redacted.contains("pass"),
4106 "non-special backslash authority leaked a credential byte: {redacted}"
4107 );
4108 assert_eq!(
4110 redact_url_for_diagnostics("foo:\\clean/path"),
4111 "foo:\\clean/path"
4112 );
4113 }
4114
4115 #[test]
4116 fn one_char_scheme_credential_content_masked() {
4117 let redacted = redact_url_for_diagnostics("x:\\user:pass@evil");
4120 assert!(
4121 !redacted.contains("user:pass"),
4122 "one-char-scheme backslash authority leaked: {redacted}"
4123 );
4124 assert!(
4125 !redacted.contains("pass"),
4126 "one-char-scheme backslash authority leaked a credential byte: {redacted}"
4127 );
4128 }
4129
4130 #[test]
4131 fn drive_and_unc_inputs_stay_visible() {
4132 let drive = redact_url_for_diagnostics("C:\\Users\\x@corp\\file");
4138 assert!(
4139 !drive.contains("[redacted]"),
4140 "drive path must not be sentineled: {drive}"
4141 );
4142 assert!(
4143 !drive.contains("***"),
4144 "drive path must not be masked: {drive}"
4145 );
4146 assert!(
4147 drive.contains("x@corp"),
4148 "drive path keeps its at-sign content visible: {drive}"
4149 );
4150 let unc = redact_url_for_diagnostics("\\\\server\\x@y");
4153 assert_eq!(unc, "\\\\server\\x@y");
4154 assert!(
4155 !unc.contains("[redacted]"),
4156 "UNC path must not be sentineled: {unc}"
4157 );
4158 }
4159
4160 #[test]
4161 fn redact_url_masks_userinfo_and_query() {
4162 let redacted =
4163 redact_url_for_diagnostics("http://user:secretpass@internal.example/api?token=abc123");
4164 assert!(
4165 !redacted.contains("secretpass"),
4166 "password must be masked: {redacted}"
4167 );
4168 assert!(
4169 !redacted.contains("token=abc123"),
4170 "query must be masked: {redacted}"
4171 );
4172 assert!(
4173 !redacted.contains("user@"),
4174 "username must be masked: {redacted}"
4175 );
4176 assert!(
4177 redacted.contains("internal.example"),
4178 "host stays visible: {redacted}"
4179 );
4180 assert!(redacted.contains("[redacted]"), "query marked: {redacted}");
4181 }
4182
4183 #[test]
4184 fn redact_url_keeps_clean_urls_visible() {
4185 let redacted = redact_url_for_diagnostics("https://api.example.com/v1/items");
4186 assert_eq!(redacted, "https://api.example.com/v1/items");
4187 }
4188
4189 #[test]
4190 fn redact_url_masks_password_only_userinfo() {
4191 let redacted = redact_url_for_diagnostics("http://:pwsecret@host.example/");
4192 assert!(
4193 !redacted.contains("pwsecret"),
4194 "password-only userinfo leaked: {redacted}"
4195 );
4196 assert_eq!(redacted, "http://***@host.example/");
4197
4198 let redacted = redact_url_for_diagnostics("http://user:pw2@host.example/api");
4199 assert!(!redacted.contains("pw2"), "password leaked: {redacted}");
4200 assert_eq!(redacted, "http://***@host.example/api");
4201
4202 let redacted = redact_url_for_diagnostics("http://host.example/api");
4203 assert_eq!(redacted, "http://host.example/api");
4204 }
4205
4206 #[test]
4207 fn redact_url_truncates_unparseable() {
4208 let long = "x".repeat(1000);
4209 let redacted = redact_url_for_diagnostics(&long);
4210 assert_eq!(redacted.len(), 256, "unparseable URL must be truncated");
4211 }
4212
4213 #[test]
4218 fn redact_url_keeps_sentinels_intact_under_256_cap() {
4219 let parsed = format!("https://example.com/{}?x=1", "a".repeat(230));
4222 assert!(
4223 url::Url::parse(&parsed).is_ok(),
4224 "fixture must parse: {parsed}"
4225 );
4226 let redacted = redact_url_for_diagnostics(&parsed);
4227 assert!(redacted.len() <= 256, "len={}", redacted.len());
4228 assert!(
4229 redacted.ends_with("?[redacted]"),
4230 "parsed-arm sentinel must render intact: {redacted}"
4231 );
4232
4233 let unparseable = format!("http://{} ?x=1", "a".repeat(240));
4236 assert!(
4237 url::Url::parse(&unparseable).is_err(),
4238 "fixture must not parse: {unparseable}"
4239 );
4240 let redacted = redact_url_for_diagnostics(&unparseable);
4241 assert!(redacted.len() <= 256, "len={}", redacted.len());
4242 assert!(
4243 redacted.ends_with("?[redacted]"),
4244 "unparseable-arm sentinel must render intact: {redacted}"
4245 );
4246 }
4247
4248 #[test]
4249 fn redact_url_suppresses_unparseable_authority_credentials() {
4250 let fixtures = [
4251 "http://u:secretpw@/x",
4252 "http://u:secretpw@host:99999/x",
4253 "http://u:secretpw@host:99999",
4254 "//u:secretpw@h/x",
4255 ];
4256 for fixture in fixtures {
4257 assert!(
4258 url::Url::parse(fixture).is_err(),
4259 "fixture must be unparseable: {fixture}"
4260 );
4261 let redacted = redact_url_for_diagnostics(fixture);
4262 assert_eq!(
4263 redacted, "[redacted]",
4264 "credential-bearing authority must be suppressed: {fixture}"
4265 );
4266 }
4267 }
4268
4269 #[test]
4270 fn redact_url_bd_repro_never_leaks_credentials() {
4271 let redacted = redact_url_for_diagnostics("http://user:pa%ss@host/path");
4272 assert!(
4273 !redacted.contains("user:pa%ss"),
4274 "bd rc-2i5c5 repro leaked userinfo: {redacted}"
4275 );
4276 assert!(
4277 !redacted.contains("pa%ss"),
4278 "bd rc-2i5c5 repro leaked password: {redacted}"
4279 );
4280 }
4281
4282 #[test]
4283 fn redact_url_unparseable_query_redacted_short_and_long() {
4284 let short = "http://host:99999/path?token=shortsecret";
4285 assert!(
4286 url::Url::parse(short).is_err(),
4287 "fixture must be unparseable: {short}"
4288 );
4289 let redacted = redact_url_for_diagnostics(short);
4290 assert_eq!(
4291 redacted, "http://host:99999/path?[redacted]",
4292 "short unparseable query must end with the suffix: {redacted}"
4293 );
4294
4295 let mut long = String::from("http://host:99999/");
4296 long.push_str(&"a".repeat(300));
4297 long.push_str("?token=longsecret");
4298 assert!(
4299 url::Url::parse(&long).is_err(),
4300 "fixture must be unparseable: {long}"
4301 );
4302 let redacted = redact_url_for_diagnostics(&long);
4303 assert!(
4304 !redacted.contains("longsecret"),
4305 "long unparseable query leaked a query byte: {redacted}"
4306 );
4307 assert!(
4308 redacted.len() <= 256,
4309 "long unparseable query must be capped: {} bytes",
4310 redacted.len()
4311 );
4312 }
4313
4314 #[test]
4315 fn redact_url_unparseable_sentinels_compose_both() {
4316 let raw = "ht tp://h.example/p?a=1#tok=x";
4319 assert!(
4320 url::Url::parse(raw).is_err(),
4321 "fixture must be unparseable: {raw}"
4322 );
4323 assert_eq!(
4324 redact_url_for_diagnostics(raw),
4325 "ht tp://h.example/p?[redacted]#[redacted]",
4326 "query and fragment sentinels must compose: {raw}"
4327 );
4328 }
4329
4330 #[test]
4331 fn redact_url_unparseable_sentinels_compose_fragment_first() {
4332 let raw = "ht tp://h.example/p#tok=x?a=1";
4333 assert!(
4334 url::Url::parse(raw).is_err(),
4335 "fixture must be unparseable: {raw}"
4336 );
4337 assert_eq!(
4338 redact_url_for_diagnostics(raw),
4339 "ht tp://h.example/p#[redacted]?[redacted]",
4340 "sentinels must follow the introducers' first-occurrence order: {raw}"
4341 );
4342 }
4343
4344 #[test]
4345 fn redact_url_unparseable_utf8_straddle_no_panic() {
4346 let fixture = format!("a{}", "é".repeat(200));
4347 let redacted = redact_url_for_diagnostics(&fixture);
4348 assert!(
4349 redacted.len() <= 256,
4350 "straddle fixture must be capped: {} bytes",
4351 redacted.len()
4352 );
4353 assert!(
4354 redacted.len() >= 253,
4355 "straddle fixture must not over-truncate: {} bytes",
4356 redacted.len()
4357 );
4358 assert!(
4359 fixture.is_char_boundary(redacted.len()),
4360 "cut must land on a UTF-8 char boundary: {} bytes",
4361 redacted.len()
4362 );
4363 }
4364
4365 #[test]
4366 fn redact_url_at_sign_outside_authority_window_visible() {
4367 let at_sign_in_path = "http://host:99999/x@y";
4368 assert!(
4369 url::Url::parse(at_sign_in_path).is_err(),
4370 "fixture must be unparseable: {at_sign_in_path}"
4371 );
4372 assert_eq!(
4373 redact_url_for_diagnostics(at_sign_in_path),
4374 at_sign_in_path,
4375 "at-sign in path must not be suppressed"
4376 );
4377 assert_eq!(
4379 redact_url_for_diagnostics("mailto:user@example.com"),
4380 "mailto:user@example.com",
4381 "at-sign in mailto must round-trip byte-identically"
4382 );
4383 }
4384
4385 #[test]
4386 fn parse_success_fragment_composes() {
4387 assert_eq!(
4390 redact_url_for_diagnostics("https://h/p#access_token=x"),
4391 "https://h/p#[redacted]"
4392 );
4393 assert_eq!(
4396 redact_url_for_diagnostics("https://h/cb#f?state=x"),
4397 "https://h/cb#[redacted]?[redacted]"
4398 );
4399 }
4400
4401 #[test]
4402 fn err_arm_delegation_pin() {
4403 assert_eq!(
4407 redact_url_for_diagnostics("http://u:secretpw@host:99999/x"),
4408 "[redacted]"
4409 );
4410 assert_eq!(
4413 redact_url_for_diagnostics("http://h:99999/p?token=secret"),
4414 "http://h:99999/p?[redacted]"
4415 );
4416 }
4417
4418 #[test]
4419 fn truncate_error_body_caps_attacker_body() {
4420 let big = vec![b'A'; 10 * 1024 * 1024];
4421 let truncated = truncate_error_body(&big);
4422 assert!(
4423 truncated.len() <= MAX_ERROR_RESPONSE_BODY_BYTES + 20,
4424 "body must be capped near {} bytes, got {}",
4425 MAX_ERROR_RESPONSE_BODY_BYTES,
4426 truncated.len()
4427 );
4428 assert!(truncated.ends_with("...[truncated]"));
4429 }
4430
4431 #[test]
4432 fn truncate_error_body_keeps_small_body() {
4433 assert_eq!(truncate_error_body(b"boom"), "boom");
4434 }
4435
4436 #[test]
4437 fn test_http_config_defaults() {
4438 let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
4439 assert_eq!(config.base_url, "http://localhost:8080/api");
4440 assert!(config.http_method.is_none());
4441 assert!(config.throw_exception_on_failure);
4442 assert_eq!(config.ok_status_code_range, (200, 299));
4443 assert!(config.response_timeout.is_none());
4444 assert!(matches!(config.auth, HttpAuth::None));
4445 assert!(!config.bridge_endpoint);
4446 assert!(!config.connection_close);
4447 }
4448
4449 #[test]
4450 fn test_http_config_scheme() {
4451 assert_eq!(HttpEndpointConfig::scheme(), "http");
4453 }
4454
4455 #[test]
4456 fn test_http_config_from_components() {
4457 let components = camel_component_api::UriComponents {
4459 scheme: "https".to_string(),
4460 path: "//api.example.com/v1".to_string(),
4461 params: std::collections::HashMap::from([(
4462 "httpMethod".to_string(),
4463 "POST".to_string(),
4464 )]),
4465 raw_query: None,
4466 };
4467 let config = HttpEndpointConfig::from_components(components).unwrap();
4468 assert_eq!(config.base_url, "https://api.example.com/v1");
4469 assert_eq!(config.http_method, Some("POST".to_string()));
4470 }
4471
4472 #[test]
4473 fn test_http_config_with_options() {
4474 let config = HttpEndpointConfig::from_uri(
4475 "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
4476 ).unwrap();
4477 assert_eq!(config.base_url, "https://api.example.com/v1");
4478 assert_eq!(config.http_method, Some("PUT".to_string()));
4479 assert!(!config.throw_exception_on_failure);
4480 assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
4481 }
4482
4483 #[test]
4484 fn test_http_endpoint_config_auth_and_headers_options() {
4485 let config = HttpEndpointConfig::from_uri(
4486 "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
4487 )
4488 .unwrap();
4489
4490 assert!(matches!(
4491 config.auth,
4492 HttpAuth::Basic { username, password } if username == "u" && password == "p"
4493 ));
4494 assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
4495 assert!(config.bridge_endpoint);
4496 assert!(config.connection_close);
4497 assert_eq!(
4498 config.skip_request_headers,
4499 vec!["authorization".to_string(), "x-secret".to_string()]
4500 );
4501 assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
4502 }
4503
4504 #[test]
4505 fn test_http_endpoint_config_bearer_auth() {
4506 let config = HttpEndpointConfig::from_uri(
4507 "http://localhost/api?authMethod=Bearer&authBearerToken=t",
4508 )
4509 .unwrap();
4510 assert!(matches!(
4511 config.auth,
4512 HttpAuth::Bearer { token } if token == "t"
4513 ));
4514 }
4515
4516 #[test]
4517 fn rejects_cookie_handling_inmemory() {
4518 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
4519 match result {
4520 Err(CamelError::InvalidUri(msg)) => {
4521 assert!(
4522 msg.contains("cookieHandling is not supported"),
4523 "expected rejection message, got: {msg}"
4524 );
4525 }
4526 other => panic!("expected InvalidUri error, got: {other:?}"),
4527 }
4528 }
4529
4530 #[test]
4531 fn rejects_cookie_handling_disabled() {
4532 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
4533 match result {
4534 Err(CamelError::InvalidUri(msg)) => {
4535 assert!(
4536 msg.contains("cookieHandling is not supported"),
4537 "expected rejection message, got: {msg}"
4538 );
4539 }
4540 other => panic!("expected InvalidUri error, got: {other:?}"),
4541 }
4542 }
4543
4544 #[test]
4545 fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
4546 let config = HttpConfig::default()
4547 .with_response_timeout_ms(999)
4548 .with_allow_internal(true)
4549 .with_blocked_hosts(vec!["evil.com".to_string()])
4550 .with_max_body_size(12345);
4551 let endpoint =
4552 HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
4553 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
4554 assert!(endpoint.allow_internal);
4555 assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
4556 assert_eq!(endpoint.max_body_size, 12345);
4557 }
4558
4559 #[test]
4560 fn test_from_uri_with_defaults_uri_overrides_config() {
4561 let config = HttpConfig::default()
4562 .with_response_timeout_ms(999)
4563 .with_allow_internal(true)
4564 .with_blocked_hosts(vec!["evil.com".to_string()])
4565 .with_max_body_size(12345);
4566 let endpoint = HttpEndpointConfig::from_uri_with_defaults(
4567 "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
4568 &config,
4569 )
4570 .unwrap();
4571 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
4572 assert!(!endpoint.allow_internal);
4573 assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
4574 assert_eq!(endpoint.max_body_size, 99);
4575 }
4576
4577 #[test]
4578 fn test_http_config_ok_status_range() {
4579 let config =
4580 HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
4581 assert_eq!(config.ok_status_code_range, (200, 204));
4582 }
4583
4584 #[test]
4585 fn test_http_config_wrong_scheme() {
4586 let result = HttpEndpointConfig::from_uri("file:/tmp");
4587 assert!(result.is_err());
4588 }
4589
4590 #[test]
4591 fn test_http_component_scheme() {
4592 let component = HttpComponent::new();
4593 assert_eq!(component.scheme(), "http");
4594 }
4595
4596 #[test]
4603 fn tls_strict_defaults_false_on_deserialize() {
4604 let tls: TlsConfig = serde_json::from_value(serde_json::json!({
4605 "enabled": true
4606 }))
4607 .unwrap();
4608 assert!(!tls.strict, "absent strict must default to false");
4609 }
4610
4611 fn strict_config(ca_path: Option<&str>, strict: bool) -> HttpConfig {
4612 HttpConfig {
4613 tls: Some(TlsConfig {
4614 enabled: true,
4615 strict,
4616 ca_cert_path: ca_path.map(|p| p.to_string()),
4617 ..TlsConfig::default()
4618 }),
4619 ..HttpConfig::default()
4620 }
4621 }
4622
4623 #[test]
4624 fn strict_tls_missing_ca_fails_endpoint_creation() {
4625 let component =
4626 HttpComponent::with_config(strict_config(Some("/nonexistent/ca.pem"), true));
4627 let err = component
4628 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4629 .err()
4630 .expect("strict + missing CA must fail endpoint creation");
4631 assert!(
4632 err.to_string().contains("tls.strict"),
4633 "must name the strict knob: {err}"
4634 );
4635 assert!(
4636 err.to_string().contains("unreadable"),
4637 "must name the failure class: {err}"
4638 );
4639 }
4640
4641 #[test]
4642 fn strict_tls_unparseable_ca_fails_endpoint_creation() {
4643 let path = camel_component_api::test_support::tls::write_pem_tmp(
4644 "strict-bad-ca.pem",
4645 "not a certificate",
4646 );
4647 let component =
4648 HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
4649 let err = component
4650 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4651 .err()
4652 .expect("strict + unparseable CA must fail endpoint creation");
4653 assert!(
4654 err.to_string()
4655 .contains("no parseable PEM CERTIFICATE section"),
4656 "must name the failure class: {err}"
4657 );
4658 }
4659
4660 #[test]
4661 fn strict_tls_der_file_rejected_not_certified() {
4662 let path = camel_component_api::test_support::tls::write_pem_tmp(
4667 "strict-der-ca.pem",
4668 "00garbage-bytes",
4669 );
4670 let component =
4671 HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
4672 let err = component
4673 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4674 .err()
4675 .expect("strict + DER file must fail endpoint creation");
4676 assert!(
4677 err.to_string().contains("convert to PEM"),
4678 "must tell the operator to convert: {err}"
4679 );
4680 }
4681
4682 #[test]
4683 fn strict_tls_half_mtls_pair_rejected() {
4684 let cfg = strict_mtls_config(Some("/any/cert.pem"), None);
4687 let component = HttpComponent::with_config(cfg);
4688 let err = component
4689 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4690 .err()
4691 .expect("strict + half mTLS pair must fail endpoint creation");
4692 assert!(
4693 err.to_string().contains("BOTH"),
4694 "must name the pair requirement: {err}"
4695 );
4696 }
4697
4698 #[test]
4699 fn strict_tls_valid_material_allows_endpoint_creation() {
4700 let (ca, _cert, _key) = camel_component_api::test_support::tls::gen_server_cert();
4701 let path = camel_component_api::test_support::tls::write_pem_tmp("strict-ok-ca.pem", &ca);
4702 let component =
4703 HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
4704 assert!(
4705 component
4706 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4707 .is_ok(),
4708 "valid CA under strict must create the endpoint"
4709 );
4710 }
4711
4712 #[test]
4713 fn permissive_missing_ca_keeps_back_compat() {
4714 let component =
4717 HttpComponent::with_config(strict_config(Some("/nonexistent/ca.pem"), false));
4718 assert!(
4719 component
4720 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4721 .is_ok(),
4722 "permissive mode must keep the back-compat fallback"
4723 );
4724 }
4725
4726 fn strict_mtls_config(cert_path: Option<&str>, key_path: Option<&str>) -> HttpConfig {
4727 HttpConfig {
4728 tls: Some(TlsConfig {
4729 enabled: true,
4730 strict: true,
4731 client_cert_path: cert_path.map(|p| p.to_string()),
4732 client_key_path: key_path.map(|p| p.to_string()),
4733 ..TlsConfig::default()
4734 }),
4735 ..HttpConfig::default()
4736 }
4737 }
4738
4739 #[test]
4740 fn strict_tls_missing_mtls_cert_fails_endpoint_creation() {
4741 let (_ca, _cert, key) = camel_component_api::test_support::tls::gen_server_cert();
4744 let key_path =
4745 camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-key.pem", &key);
4746 let component = HttpComponent::with_config(strict_mtls_config(
4747 Some("/nonexistent/cert.pem"),
4748 Some(key_path.to_str().unwrap()),
4749 ));
4750 let err = component
4751 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4752 .err()
4753 .expect("strict + unreadable mTLS pair must fail endpoint creation");
4754 assert!(
4755 err.to_string().contains("tls.strict"),
4756 "must name the strict knob: {err}"
4757 );
4758 assert!(
4759 err.to_string().contains("unreadable"),
4760 "must name the failure class: {err}"
4761 );
4762 }
4763
4764 #[test]
4765 fn strict_tls_valid_mtls_pair_allows_endpoint_creation() {
4766 let (_ca, cert, key) = camel_component_api::test_support::tls::gen_server_cert();
4767 let cert_path =
4768 camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-cert.pem", &cert);
4769 let key_path =
4770 camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-key2.pem", &key);
4771 let component = HttpComponent::with_config(strict_mtls_config(
4772 Some(cert_path.to_str().unwrap()),
4773 Some(key_path.to_str().unwrap()),
4774 ));
4775 assert!(
4776 component
4777 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4778 .is_ok(),
4779 "valid mTLS pair under strict must create the endpoint"
4780 );
4781 }
4782
4783 #[test]
4784 fn test_https_component_scheme() {
4785 let component = HttpsComponent::new();
4786 assert_eq!(component.scheme(), "https");
4787 }
4788
4789 #[test]
4790 fn test_http_endpoint_creates_consumer() {
4791 let component = HttpComponent::new();
4792 let ctx = NoOpComponentContext;
4793 let endpoint = component
4794 .create_endpoint("http://0.0.0.0:19100/test", &ctx)
4795 .unwrap();
4796 assert!(endpoint.create_consumer(rt()).is_ok());
4797 }
4798
4799 #[test]
4800 fn test_https_endpoint_creates_consumer_errors_without_tls() {
4801 let component = HttpsComponent::new();
4802 let ctx = NoOpComponentContext;
4803 let endpoint = component
4804 .create_endpoint("https://0.0.0.0:8443/test", &ctx)
4805 .unwrap();
4806 assert!(endpoint.create_consumer(rt()).is_err());
4808 }
4809
4810 #[test]
4811 fn test_http_endpoint_creates_producer() {
4812 let ctx = test_producer_ctx();
4813 let component = HttpComponent::new();
4814 let endpoint_ctx = NoOpComponentContext;
4815 let endpoint = component
4816 .create_endpoint("http://localhost/api", &endpoint_ctx)
4817 .unwrap();
4818 assert!(endpoint.create_producer(rt(), &ctx).is_ok());
4819 }
4820
4821 #[tokio::test]
4826 async fn test_producer_with_token_provider() {
4827 use camel_auth::oauth2::TokenProvider;
4828 use tower::ServiceExt;
4829
4830 let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
4831 Arc::new(std::sync::Mutex::new(None));
4832 let captured_clone = Arc::clone(&captured_auth);
4833
4834 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4835 let port = listener.local_addr().unwrap().port();
4836
4837 let _handle = tokio::spawn(async move {
4838 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4839 if let Ok((mut stream, _)) = listener.accept().await {
4840 let mut buf = vec![0u8; 8192];
4841 let n = stream.read(&mut buf).await.unwrap_or(0);
4842 let request = String::from_utf8_lossy(&buf[..n]).to_string();
4843 let auth = request
4844 .lines()
4845 .find(|l| l.to_lowercase().starts_with("authorization:"))
4846 .map(|l| {
4847 l.split(':')
4848 .nth(1)
4849 .map(|s| s.trim().to_string())
4850 .unwrap_or_default()
4851 });
4852 *captured_clone.lock().unwrap() = auth;
4853 let body = r#"{"echo":"ok"}"#;
4854 let resp = format!(
4855 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
4856 body.len(),
4857 body
4858 );
4859 let _ = stream.write_all(resp.as_bytes()).await;
4860 }
4861 });
4862
4863 #[derive(Debug)]
4864 struct StaticProvider;
4865 #[async_trait::async_trait]
4866 impl TokenProvider for StaticProvider {
4867 async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
4868 Ok("injected-token".into())
4869 }
4870 }
4871
4872 let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
4873 let ctx = test_producer_ctx();
4874 let component = HttpComponent::new();
4875 let endpoint_ctx = NoOpComponentContext;
4876 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
4877 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4878
4879 let exchange = Exchange::new(Message::new("hello"));
4880
4881 let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
4882 let mut layered = layer.layer(producer);
4883 let result = layered.ready().await.unwrap().call(exchange).await;
4884 assert!(result.is_ok(), "producer call failed: {:?}", result);
4885
4886 tokio::time::sleep(Duration::from_millis(100)).await;
4887 let auth = captured_auth.lock().unwrap().take();
4888 assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
4889 }
4890
4891 async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
4892 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4893 let addr = listener.local_addr().unwrap();
4894 let url = format!("http://127.0.0.1:{}", addr.port());
4895
4896 let handle = tokio::spawn(async move {
4897 loop {
4898 if let Ok((mut stream, _)) = listener.accept().await {
4899 tokio::spawn(async move {
4900 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4901 let mut buf = vec![0u8; 4096];
4902 let n = stream.read(&mut buf).await.unwrap_or(0);
4903 let request = String::from_utf8_lossy(&buf[..n]).to_string();
4904
4905 let method = request.split_whitespace().next().unwrap_or("GET");
4906
4907 let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
4908 let response = format!(
4909 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
4910 body.len(),
4911 body
4912 );
4913 let _ = stream.write_all(response.as_bytes()).await;
4914 });
4915 }
4916 }
4917 });
4918
4919 (url, handle)
4920 }
4921
4922 async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
4923 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4924 let addr = listener.local_addr().unwrap();
4925 let url = format!("http://127.0.0.1:{}", addr.port());
4926
4927 let handle = tokio::spawn(async move {
4928 loop {
4929 if let Ok((mut stream, _)) = listener.accept().await {
4930 let status = status;
4931 tokio::spawn(async move {
4932 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4933 let mut buf = vec![0u8; 4096];
4934 let _ = stream.read(&mut buf).await;
4935
4936 let status_text = match status {
4937 404 => "Not Found",
4938 500 => "Internal Server Error",
4939 _ => "Error",
4940 };
4941 let body = "error body";
4942 let response = format!(
4943 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
4944 status,
4945 status_text,
4946 body.len(),
4947 body
4948 );
4949 let _ = stream.write_all(response.as_bytes()).await;
4950 });
4951 }
4952 }
4953 });
4954
4955 (url, handle)
4956 }
4957
4958 async fn start_request_capturing_server() -> (
4959 String,
4960 Arc<std::sync::Mutex<Option<String>>>,
4961 tokio::task::JoinHandle<()>,
4962 ) {
4963 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4964 let port = listener.local_addr().unwrap().port();
4965 let url = format!("http://127.0.0.1:{port}");
4966 let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
4967 let captured_clone = Arc::clone(&captured);
4968 let handle = tokio::spawn(async move {
4969 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4970 if let Ok((mut stream, _)) = listener.accept().await {
4971 let mut buf = vec![0u8; 16384];
4972 let n = stream.read(&mut buf).await.unwrap_or(0);
4973 let request = String::from_utf8_lossy(&buf[..n]).to_string();
4974 if request.contains("\r\n\r\n") {
4975 *captured_clone.lock().unwrap() = Some(request);
4976 }
4977 let body = r#"{"echo":"ok"}"#;
4978 let resp = format!(
4979 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
4980 body.len(),
4981 body
4982 );
4983 let _ = stream.write_all(resp.as_bytes()).await;
4984 }
4985 });
4986 (url, captured, handle)
4987 }
4988
4989 #[tokio::test]
4990 async fn test_http_producer_get_request() {
4991 use tower::ServiceExt;
4992
4993 let (url, _handle) = start_test_server().await;
4994 let ctx = test_producer_ctx();
4995
4996 let component = HttpComponent::new();
4997 let endpoint_ctx = NoOpComponentContext;
4998 let endpoint = component
4999 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5000 .unwrap();
5001 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5002
5003 let exchange = Exchange::new(Message::default());
5004 let result = producer.oneshot(exchange).await.unwrap();
5005
5006 let status = result
5007 .input
5008 .header("CamelHttpResponseCode")
5009 .and_then(|v| v.as_u64())
5010 .unwrap();
5011 assert_eq!(status, 200);
5012
5013 assert!(!result.input.body.is_empty());
5014 }
5015
5016 #[tokio::test]
5017 async fn producer_excludes_host_and_framing() {
5018 use tower::ServiceExt;
5019
5020 let (url, captured, _handle) = start_request_capturing_server().await;
5021 let ctx = test_producer_ctx();
5022 let component = HttpComponent::new();
5023 let endpoint_ctx = NoOpComponentContext;
5024 let endpoint = component
5025 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5026 .unwrap();
5027 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5028
5029 let mut exchange = Exchange::new(Message::default());
5030 exchange.input.set_header("Host", "localhost");
5031 exchange.input.set_header("Content-Length", "42");
5032 exchange.input.set_header("Connection", "keep-alive");
5033 exchange.input.set_header("Upgrade", "h2c");
5034
5035 let result = producer.oneshot(exchange).await;
5036 assert!(result.is_ok(), "producer call failed: {:?}", result);
5037
5038 tokio::time::sleep(Duration::from_millis(100)).await;
5039 let request = captured
5040 .lock()
5041 .unwrap()
5042 .take()
5043 .expect("no outbound request captured");
5044 let lower = request.to_ascii_lowercase();
5045 assert!(
5046 !lower.contains("\r\nhost: localhost"),
5047 "forwarded Host: localhost must be stripped\n{request}"
5048 );
5049 assert!(
5050 !lower.contains("content-length: 42"),
5051 "exchange Content-Length must not be copied\n{request}"
5052 );
5053 assert!(
5054 !lower.lines().any(|l| l.starts_with("connection:")),
5055 "Connection header must not be forwarded\n{request}"
5056 );
5057 assert!(
5058 !lower.lines().any(|l| l.starts_with("upgrade:")),
5059 "Upgrade header must not be forwarded\n{request}"
5060 );
5061 let host_header = lower
5062 .lines()
5063 .find(|l| l.starts_with("host:"))
5064 .map(|l| l.split_once(':').map(|(_, v)| v).unwrap_or("").trim())
5065 .expect("outbound Host header must be set by reqwest");
5066 assert!(
5067 host_header.starts_with("127.0.0.1:"),
5068 "outbound Host '{host_header}' must match the capture-server address"
5069 );
5070 }
5071
5072 #[tokio::test]
5073 async fn producer_forwards_request_only_headers() {
5074 use tower::ServiceExt;
5075
5076 let (url, captured, _handle) = start_request_capturing_server().await;
5077 let ctx = test_producer_ctx();
5078 let component = HttpComponent::new();
5079 let endpoint_ctx = NoOpComponentContext;
5080 let endpoint = component
5081 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5082 .unwrap();
5083 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5084
5085 let mut exchange = Exchange::new(Message::default());
5086 exchange.input.set_header("Accept", "application/json");
5087 exchange.input.set_header("User-Agent", "myclient/1.0");
5088
5089 let result = producer.oneshot(exchange).await;
5090 assert!(result.is_ok(), "producer call failed: {:?}", result);
5091
5092 tokio::time::sleep(Duration::from_millis(100)).await;
5093 let request = captured
5094 .lock()
5095 .unwrap()
5096 .take()
5097 .expect("no outbound request captured");
5098 let lower = request.to_ascii_lowercase();
5099 assert!(
5100 lower.contains("accept: application/json"),
5101 "request-only Accept header must be forwarded\n{request}"
5102 );
5103 assert!(
5104 lower.contains("user-agent: myclient/1.0"),
5105 "request-only User-Agent header must be forwarded\n{request}"
5106 );
5107 }
5108
5109 fn endpoint_with_config_overrides(
5118 base_url: &str,
5119 user_agent: Option<String>,
5120 auth: HttpAuth,
5121 ) -> HttpEndpoint {
5122 let uri = format!("{base_url}/api/test?allowInternal=true");
5123 let mut config =
5124 HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses");
5125 config.user_agent = user_agent;
5126 config.auth = auth;
5127 HttpEndpoint {
5128 uri: uri.clone(),
5129 config,
5130 server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
5131 client: reqwest::Client::new(),
5132 pinned_cache: Arc::new(PinnedClientCache::new(
5133 PINNED_CLIENT_TTL,
5134 PINNED_CLIENT_MAX_ENTRIES,
5135 )),
5136 http_config: HttpConfig::default(),
5137 }
5138 }
5139
5140 #[tracing_test::traced_test]
5145 #[tokio::test]
5146 async fn producer_invalid_configured_headers_surfaced() {
5147 use tower::ServiceExt;
5148
5149 let (bad_url, bad_captured, _bad_handle) = start_request_capturing_server().await;
5150 let (ok_url, ok_captured, _ok_handle) = start_request_capturing_server().await;
5151 let ctx = test_producer_ctx();
5152
5153 let bad_producer = endpoint_with_config_overrides(
5154 &bad_url,
5155 Some("bad\r\nua".to_string()),
5156 HttpAuth::Bearer {
5157 token: "tok\r\nen".to_string(),
5158 },
5159 )
5160 .create_producer(rt(), &ctx)
5161 .unwrap();
5162 let ok_producer = endpoint_with_config_overrides(
5163 &ok_url,
5164 Some("httpsweep-ok/1".to_string()),
5165 HttpAuth::Bearer {
5166 token: "valid-token".to_string(),
5167 },
5168 )
5169 .create_producer(rt(), &ctx)
5170 .unwrap();
5171
5172 let bad_exchange = Exchange::new(Message::default());
5173 let ok_exchange = Exchange::new(Message::default());
5174 let bad_cid = bad_exchange.correlation_id().to_string();
5175 let ok_cid = ok_exchange.correlation_id().to_string();
5176
5177 let bad_result = bad_producer.oneshot(bad_exchange).await;
5178 assert!(
5179 bad_result.is_ok(),
5180 "invalid-config producer call failed: {bad_result:?}"
5181 );
5182 let ok_result = ok_producer.oneshot(ok_exchange).await;
5183 assert!(
5184 ok_result.is_ok(),
5185 "valid-config producer call failed: {ok_result:?}"
5186 );
5187
5188 tokio::time::sleep(Duration::from_millis(100)).await;
5189 let bad_request = bad_captured
5190 .lock()
5191 .unwrap()
5192 .take()
5193 .expect("no outbound request captured");
5194 let ok_request = ok_captured
5195 .lock()
5196 .unwrap()
5197 .take()
5198 .expect("no outbound request captured");
5199
5200 let bad_lower = bad_request.to_ascii_lowercase();
5203 assert!(
5204 !bad_lower.lines().any(|l| l.starts_with("authorization:")),
5205 "invalid Bearer token must not reach the wire\n{bad_request}"
5206 );
5207 assert!(
5208 !bad_request.contains("bad\r\nua"),
5209 "invalid configured user-agent must not reach the wire\n{bad_request}"
5210 );
5211
5212 logs_assert(|lines: &[&str]| {
5213 let drops: Vec<&&str> = lines
5214 .iter()
5215 .filter(|l| {
5216 l.contains("outbound header dropped")
5217 && l.contains(&format!("correlation_id={bad_cid}"))
5218 })
5219 .collect();
5220 if drops.len() != 2 {
5221 return Err(format!(
5222 "expected exactly 2 drop records for {bad_cid}, found {}",
5223 drops.len()
5224 ));
5225 }
5226 let has_ua = drops.iter().any(|l| l.contains("header=user-agent"));
5227 let has_auth = drops.iter().any(|l| l.contains("header=authorization"));
5228 let reason_ok = drops
5229 .iter()
5230 .all(|l| l.contains("outbound header dropped: invalid header value"));
5231 match (has_ua, has_auth, reason_ok) {
5232 (true, true, true) => Ok(()),
5233 _ => Err(format!(
5234 "drop records mismatched: user-agent={has_ua} \
5235 authorization={has_auth} reason-ok={reason_ok}"
5236 )),
5237 }
5238 });
5239 logs_assert(|lines: &[&str]| {
5240 if lines
5241 .iter()
5242 .any(|l| l.contains("bad\r\nua") || l.contains("tok\r\nen"))
5243 {
5244 Err("sentinel CRLF values leaked into logs".to_string())
5245 } else {
5246 Ok(())
5247 }
5248 });
5249
5250 let ok_lower = ok_request.to_ascii_lowercase();
5253 assert!(
5254 ok_lower.contains("user-agent: httpsweep-ok/1"),
5255 "valid configured user-agent must reach the wire\n{ok_request}"
5256 );
5257 assert!(
5258 ok_lower.contains("authorization: bearer valid-token"),
5259 "valid Bearer token must reach the wire\n{ok_request}"
5260 );
5261 logs_assert(|lines: &[&str]| {
5262 let hits = lines
5263 .iter()
5264 .filter(|l| {
5265 l.contains("outbound header dropped")
5266 && l.contains(&format!("correlation_id={ok_cid}"))
5267 })
5268 .count();
5269 match hits {
5270 0 => Ok(()),
5271 n => Err(format!("expected no drop records for {ok_cid}, found {n}")),
5272 }
5273 });
5274 }
5275
5276 #[tokio::test]
5277 async fn producer_honours_skip_request_headers() {
5278 use tower::ServiceExt;
5279
5280 let (url, captured, _handle) = start_request_capturing_server().await;
5281 let ctx = test_producer_ctx();
5282 let component = HttpComponent::new();
5283 let endpoint_ctx = NoOpComponentContext;
5284 let endpoint = component
5285 .create_endpoint(
5286 &format!("{url}/api/test?allowInternal=true&skipRequestHeaders=Authorization"),
5287 &endpoint_ctx,
5288 )
5289 .unwrap();
5290 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5291
5292 let mut exchange = Exchange::new(Message::default());
5293 exchange.input.set_header("Authorization", "Bearer x");
5294
5295 let result = producer.oneshot(exchange).await;
5296 assert!(result.is_ok(), "producer call failed: {:?}", result);
5297
5298 tokio::time::sleep(Duration::from_millis(100)).await;
5299 let request = captured
5300 .lock()
5301 .unwrap()
5302 .take()
5303 .expect("no outbound request captured");
5304 assert!(
5305 !request.to_ascii_lowercase().contains("authorization"),
5306 "Authorization must be stripped by skipRequestHeaders\n{request}"
5307 );
5308 }
5309
5310 #[tokio::test]
5311 async fn producer_stringifies_scalar_header_values_on_wire() {
5312 use tower::ServiceExt;
5313
5314 let (url, captured, _handle) = start_request_capturing_server().await;
5315 let ctx = test_producer_ctx();
5316 let component = HttpComponent::new();
5317 let endpoint_ctx = NoOpComponentContext;
5318 let endpoint = component
5319 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5320 .unwrap();
5321 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5322
5323 let mut exchange = Exchange::new(Message::default());
5324 exchange.input.set_header("X-Retries", serde_json::json!(3));
5325 exchange
5326 .input
5327 .set_header("X-Enabled", serde_json::json!(true));
5328 exchange
5329 .input
5330 .set_header("X-Obj", serde_json::json!({"a": 1}));
5331
5332 let result = producer.oneshot(exchange).await;
5333 assert!(result.is_ok(), "producer call failed: {:?}", result);
5334
5335 tokio::time::sleep(Duration::from_millis(100)).await;
5336 let request = captured
5337 .lock()
5338 .unwrap()
5339 .take()
5340 .expect("no outbound request captured");
5341 let lower = request.to_ascii_lowercase();
5342 assert!(
5343 lower.contains("x-retries: 3"),
5344 "numeric header must reach the wire stringified\n{request}"
5345 );
5346 assert!(
5347 lower.contains("x-enabled: true"),
5348 "bool header must reach the wire stringified\n{request}"
5349 );
5350 assert!(
5351 !lower.contains("x-obj:"),
5352 "object header has no single-value form and must not reach the wire\n{request}"
5353 );
5354 }
5355
5356 #[tokio::test]
5357 async fn test_http_producer_post_with_body() {
5358 use tower::ServiceExt;
5359
5360 let (url, _handle) = start_test_server().await;
5361 let ctx = test_producer_ctx();
5362
5363 let component = HttpComponent::new();
5364 let endpoint_ctx = NoOpComponentContext;
5365 let endpoint = component
5366 .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
5367 .unwrap();
5368 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5369
5370 let exchange = Exchange::new(Message::new("request body"));
5371 let result = producer.oneshot(exchange).await.unwrap();
5372
5373 let status = result
5374 .input
5375 .header("CamelHttpResponseCode")
5376 .and_then(|v| v.as_u64())
5377 .unwrap();
5378 assert_eq!(status, 200);
5379 }
5380
5381 #[tokio::test]
5382 async fn test_http_producer_method_from_header() {
5383 use tower::ServiceExt;
5384
5385 let (url, _handle) = start_test_server().await;
5386 let ctx = test_producer_ctx();
5387
5388 let component = HttpComponent::new();
5389 let endpoint_ctx = NoOpComponentContext;
5390 let endpoint = component
5391 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5392 .unwrap();
5393 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5394
5395 let mut exchange = Exchange::new(Message::default());
5396 exchange.input.set_header(
5397 "CamelHttpMethod",
5398 serde_json::Value::String("DELETE".to_string()),
5399 );
5400
5401 let result = producer.oneshot(exchange).await.unwrap();
5402 let status = result
5403 .input
5404 .header("CamelHttpResponseCode")
5405 .and_then(|v| v.as_u64())
5406 .unwrap();
5407 assert_eq!(status, 200);
5408 }
5409
5410 #[tokio::test]
5411 async fn test_http_producer_forced_method() {
5412 use tower::ServiceExt;
5413
5414 let (url, _handle) = start_test_server().await;
5415 let ctx = test_producer_ctx();
5416
5417 let component = HttpComponent::new();
5418 let endpoint_ctx = NoOpComponentContext;
5419 let endpoint = component
5420 .create_endpoint(
5421 &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
5422 &endpoint_ctx,
5423 )
5424 .unwrap();
5425 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5426
5427 let exchange = Exchange::new(Message::default());
5428 let result = producer.oneshot(exchange).await.unwrap();
5429
5430 let status = result
5431 .input
5432 .header("CamelHttpResponseCode")
5433 .and_then(|v| v.as_u64())
5434 .unwrap();
5435 assert_eq!(status, 200);
5436 }
5437
5438 #[tokio::test]
5439 async fn test_http_producer_throw_exception_on_failure() {
5440 use tower::ServiceExt;
5441
5442 let (url, _handle) = start_status_server(404).await;
5443 let ctx = test_producer_ctx();
5444
5445 let component = HttpComponent::new();
5446 let endpoint_ctx = NoOpComponentContext;
5447 let endpoint = component
5448 .create_endpoint(
5449 &format!("{url}/not-found?allowInternal=true"),
5450 &endpoint_ctx,
5451 )
5452 .unwrap();
5453 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5454
5455 let exchange = Exchange::new(Message::default());
5456 let result = producer.oneshot(exchange).await;
5457 assert!(result.is_err());
5458
5459 match result.unwrap_err() {
5460 CamelError::HttpOperationFailed { status_code, .. } => {
5461 assert_eq!(status_code, 404);
5462 }
5463 e => panic!("Expected HttpOperationFailed, got: {e}"),
5464 }
5465 }
5466
5467 #[tokio::test]
5468 async fn test_http_producer_no_throw_on_failure() {
5469 use tower::ServiceExt;
5470
5471 let (url, _handle) = start_status_server(500).await;
5472 let ctx = test_producer_ctx();
5473
5474 let component = HttpComponent::new();
5475 let endpoint_ctx = NoOpComponentContext;
5476 let endpoint = component
5477 .create_endpoint(
5478 &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
5479 &endpoint_ctx,
5480 )
5481 .unwrap();
5482 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5483
5484 let exchange = Exchange::new(Message::default());
5485 let result = producer.oneshot(exchange).await.unwrap();
5486
5487 let status = result
5488 .input
5489 .header("CamelHttpResponseCode")
5490 .and_then(|v| v.as_u64())
5491 .unwrap();
5492 assert_eq!(status, 500);
5493 }
5494
5495 #[tokio::test]
5496 async fn test_http_producer_uri_override() {
5497 use tower::ServiceExt;
5498
5499 let (url, _handle) = start_test_server().await;
5500 let ctx = test_producer_ctx();
5501
5502 let component = HttpComponent::new();
5503 let endpoint_ctx = NoOpComponentContext;
5504 let endpoint = component
5505 .create_endpoint(
5506 "http://localhost:1/does-not-exist?allowInternal=true",
5507 &endpoint_ctx,
5508 )
5509 .unwrap();
5510 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5511
5512 let mut exchange = Exchange::new(Message::default());
5513 exchange.input.set_header(
5514 "CamelHttpUri",
5515 serde_json::Value::String(format!("{url}/api")),
5516 );
5517
5518 let result = producer.oneshot(exchange).await.unwrap();
5519 let status = result
5520 .input
5521 .header("CamelHttpResponseCode")
5522 .and_then(|v| v.as_u64())
5523 .unwrap();
5524 assert_eq!(status, 200);
5525 }
5526
5527 #[tokio::test]
5528 async fn test_http_producer_response_headers_mapped() {
5529 use tower::ServiceExt;
5530
5531 let (url, _handle) = start_test_server().await;
5532 let ctx = test_producer_ctx();
5533
5534 let component = HttpComponent::new();
5535 let endpoint_ctx = NoOpComponentContext;
5536 let endpoint = component
5537 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5538 .unwrap();
5539 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5540
5541 let exchange = Exchange::new(Message::default());
5542 let result = producer.oneshot(exchange).await.unwrap();
5543
5544 assert!(
5545 result.input.header("Content-Type").is_some(),
5546 "Response should have Content-Type header"
5547 );
5548 assert!(result.input.header("CamelHttpResponseText").is_some());
5549 }
5550
5551 async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
5556 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5557 let addr = listener.local_addr().unwrap();
5558 let url = format!("http://127.0.0.1:{}", addr.port());
5559
5560 let handle = tokio::spawn(async move {
5561 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5562 loop {
5563 if let Ok((mut stream, _)) = listener.accept().await {
5564 tokio::spawn(async move {
5565 let mut buf = vec![0u8; 4096];
5566 let n = stream.read(&mut buf).await.unwrap_or(0);
5567 let request = String::from_utf8_lossy(&buf[..n]).to_string();
5568
5569 if request.contains("GET /final") {
5571 let body = r#"{"status":"final"}"#;
5572 let response = format!(
5573 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
5574 body.len(),
5575 body
5576 );
5577 let _ = stream.write_all(response.as_bytes()).await;
5578 } else {
5579 let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
5584 let _ = stream.write_all(response.as_bytes()).await;
5585 }
5586 });
5587 }
5588 }
5589 });
5590
5591 (url, handle)
5592 }
5593
5594 struct CapturedRequest {
5595 method: String,
5596 path: String,
5597 body: Vec<u8>,
5598 content_length: Option<String>,
5599 transfer_encoding: Option<String>,
5600 }
5601
5602 async fn capture_request(stream: &mut tokio::net::TcpStream) -> Option<CapturedRequest> {
5608 use tokio::io::AsyncReadExt;
5609
5610 let mut buf: Vec<u8> = Vec::new();
5612 let mut chunk = [0u8; 4096];
5613 let head_end: usize;
5614 loop {
5615 let n = stream.read(&mut chunk).await.unwrap_or(0);
5616 if n == 0 {
5617 return None;
5618 }
5619 buf.extend_from_slice(&chunk[..n]);
5620 if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
5621 head_end = pos + 4;
5622 break;
5623 }
5624 }
5625
5626 let head = String::from_utf8_lossy(&buf[..head_end]);
5628 let mut lines = head.split("\r\n");
5629 let request_line = lines.next().unwrap_or("");
5630 let mut parts = request_line.split_whitespace();
5631 let method = parts.next().unwrap_or("").to_string();
5632 let path = parts.next().unwrap_or("").to_string();
5633
5634 let mut content_length: Option<String> = None;
5635 let mut transfer_encoding: Option<String> = None;
5636 for line in lines {
5637 if let Some((name, value)) = line.split_once(':') {
5638 let name = name.trim().to_ascii_lowercase();
5639 let value = value.trim().to_string();
5640 if name == "content-length" {
5641 content_length = Some(value);
5642 } else if name == "transfer-encoding" {
5643 transfer_encoding = Some(value);
5644 }
5645 }
5646 }
5647
5648 let body_len: usize = content_length
5650 .as_deref()
5651 .and_then(|v| v.parse::<usize>().ok())
5652 .unwrap_or(0);
5653
5654 let mut body: Vec<u8> = buf[head_end..].to_vec();
5655 while body.len() < body_len {
5656 let n = stream.read(&mut chunk).await.unwrap_or(0);
5657 if n == 0 {
5658 break;
5659 }
5660 body.extend_from_slice(&chunk[..n]);
5661 }
5662 body.truncate(body_len);
5663
5664 Some(CapturedRequest {
5665 method,
5666 path,
5667 body,
5668 content_length,
5669 transfer_encoding,
5670 })
5671 }
5672
5673 async fn start_capture_server() -> (
5678 String,
5679 tokio::task::JoinHandle<()>,
5680 Arc<Mutex<Vec<CapturedRequest>>>,
5681 ) {
5682 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5683 let addr = listener.local_addr().unwrap();
5684 let url = format!("http://127.0.0.1:{}", addr.port());
5685
5686 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
5687 let captured_for_return = Arc::clone(&captured);
5688
5689 let handle = tokio::spawn(async move {
5690 use tokio::io::AsyncWriteExt;
5691 loop {
5692 if let Ok((mut stream, _)) = listener.accept().await {
5693 let captured = Arc::clone(&captured);
5694 tokio::spawn(async move {
5695 let Some(req) = capture_request(&mut stream).await else {
5696 return;
5697 };
5698 captured.lock().unwrap().push(req);
5699
5700 let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
5703 let _ = stream.write_all(response.as_bytes()).await;
5704 });
5705 }
5706 }
5707 });
5708
5709 (url, handle, captured_for_return)
5710 }
5711
5712 async fn start_redirect_capture_server() -> (
5718 String,
5719 tokio::task::JoinHandle<()>,
5720 Arc<Mutex<Vec<CapturedRequest>>>,
5721 ) {
5722 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5723 let addr = listener.local_addr().unwrap();
5724 let url = format!("http://127.0.0.1:{}", addr.port());
5725
5726 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
5727 let captured_for_return = Arc::clone(&captured);
5728
5729 let handle = tokio::spawn(async move {
5730 use tokio::io::AsyncWriteExt;
5731 loop {
5732 if let Ok((mut stream, _)) = listener.accept().await {
5733 let captured = Arc::clone(&captured);
5734 tokio::spawn(async move {
5735 let Some(req) = capture_request(&mut stream).await else {
5736 return;
5737 };
5738 let path = req.path.clone();
5739 captured.lock().unwrap().push(req);
5740
5741 let (status_line, location) = match path.as_str() {
5742 "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
5743 "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
5744 "/final" => ("HTTP/1.1 200 OK", None),
5745 _ => ("HTTP/1.1 404 Not Found", None),
5746 };
5747
5748 let response = match location {
5749 Some(loc) => format!(
5753 "{status_line}\r\nLocation: {loc}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
5754 ),
5755 None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
5756 };
5757 let _ = stream.write_all(response.as_bytes()).await;
5758 });
5759 }
5760 }
5761 });
5762
5763 (url, handle, captured_for_return)
5764 }
5765
5766 #[tokio::test]
5767 async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
5768 use tower::ServiceExt;
5769
5770 let (url, _handle, captured) = start_capture_server().await;
5771 let ctx = test_producer_ctx();
5772
5773 let component = HttpComponent::with_config(HttpConfig::default());
5774 let endpoint_ctx = NoOpComponentContext;
5775 let endpoint = component
5776 .create_endpoint(
5777 &format!("{url}?httpMethod=GET&allowInternal=true"),
5778 &endpoint_ctx,
5779 )
5780 .unwrap();
5781 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5782
5783 let mut exchange = Exchange::new(Message::default());
5784 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5785
5786 let result = producer.oneshot(exchange).await.unwrap();
5787
5788 let status = result
5789 .input
5790 .header("CamelHttpResponseCode")
5791 .and_then(|v| v.as_u64())
5792 .unwrap();
5793 assert_eq!(status, 200);
5794
5795 let captured = captured.lock().unwrap();
5796 assert_eq!(captured.len(), 1, "expected exactly one captured request");
5797 let req = &captured[0];
5798 assert_eq!(req.method, "GET");
5799 assert_eq!(req.path, "/");
5802 assert!(req.body.is_empty(), "GET must not carry a body");
5803 assert!(
5804 req.content_length.is_none(),
5805 "suppressed request must not carry Content-Length"
5806 );
5807 assert!(
5808 req.transfer_encoding.is_none(),
5809 "suppressed request must not carry Transfer-Encoding"
5810 );
5811
5812 assert!(
5814 result.input.body.is_empty(),
5815 "exchange body must be consumed"
5816 );
5817 }
5818
5819 #[tokio::test]
5820 async fn test_head_with_body_suppressed_via_header() {
5821 use tower::ServiceExt;
5822
5823 let (url, _handle, captured) = start_capture_server().await;
5824 let ctx = test_producer_ctx();
5825
5826 let component = HttpComponent::with_config(HttpConfig::default());
5827 let endpoint_ctx = NoOpComponentContext;
5828 let endpoint = component
5829 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5830 .unwrap();
5831 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5832
5833 let mut exchange = Exchange::new(Message::default());
5834 exchange.input.set_header(
5835 "CamelHttpMethod",
5836 serde_json::Value::String("HEAD".to_string()),
5837 );
5838 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5839
5840 let result = producer.oneshot(exchange).await.unwrap();
5841 let status = result
5842 .input
5843 .header("CamelHttpResponseCode")
5844 .and_then(|v| v.as_u64())
5845 .unwrap();
5846 assert_eq!(status, 200);
5847
5848 let captured = captured.lock().unwrap();
5849 assert_eq!(captured.len(), 1);
5850 let req = &captured[0];
5851 assert_eq!(req.method, "HEAD");
5852 assert!(req.body.is_empty(), "HEAD must not carry a body");
5853 }
5854
5855 #[tokio::test]
5856 async fn test_delete_options_trace_with_body_suppressed() {
5857 use tower::ServiceExt;
5858
5859 let (url, _handle, captured) = start_capture_server().await;
5860 let ctx = test_producer_ctx();
5861 let component = HttpComponent::with_config(HttpConfig::default());
5862 let endpoint_ctx = NoOpComponentContext;
5863
5864 for method in ["DELETE", "OPTIONS", "TRACE"] {
5865 let endpoint = component
5866 .create_endpoint(
5867 &format!("{url}?httpMethod={method}&allowInternal=true"),
5868 &endpoint_ctx,
5869 )
5870 .unwrap();
5871 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5872
5873 let mut exchange = Exchange::new(Message::default());
5874 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5875
5876 let result = producer.oneshot(exchange).await.unwrap();
5877 let status = result
5878 .input
5879 .header("CamelHttpResponseCode")
5880 .and_then(|v| v.as_u64())
5881 .unwrap();
5882 assert_eq!(status, 200, "method {method} should succeed");
5883 }
5884
5885 let captured = captured.lock().unwrap();
5886 assert_eq!(captured.len(), 3, "expected three captured requests");
5887 for method in ["DELETE", "OPTIONS", "TRACE"] {
5888 let req = captured
5889 .iter()
5890 .find(|r| r.method == method)
5891 .unwrap_or_else(|| panic!("missing captured request for {method}"));
5892 assert!(req.body.is_empty(), "{} must not carry a body", req.method);
5893 }
5894 }
5895
5896 #[tokio::test]
5897 async fn test_post_put_patch_with_body_still_sent() {
5898 use tower::ServiceExt;
5899
5900 let (url, _handle, captured) = start_capture_server().await;
5901 let ctx = test_producer_ctx();
5902 let component = HttpComponent::with_config(HttpConfig::default());
5903 let endpoint_ctx = NoOpComponentContext;
5904
5905 for method in ["POST", "PUT", "PATCH"] {
5906 let endpoint = component
5907 .create_endpoint(
5908 &format!("{url}?httpMethod={method}&allowInternal=true"),
5909 &endpoint_ctx,
5910 )
5911 .unwrap();
5912 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5913
5914 let payload = format!("body-for-{method}");
5915 let mut exchange = Exchange::new(Message::default());
5916 exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
5917
5918 let result = producer.oneshot(exchange).await.unwrap();
5919 let status = result
5920 .input
5921 .header("CamelHttpResponseCode")
5922 .and_then(|v| v.as_u64())
5923 .unwrap();
5924 assert_eq!(status, 200, "method {method} should succeed");
5925 }
5926
5927 let captured = captured.lock().unwrap();
5928 assert_eq!(captured.len(), 3, "expected three captured requests");
5929 for method in ["POST", "PUT", "PATCH"] {
5930 let req = captured
5931 .iter()
5932 .find(|r| r.method == method)
5933 .unwrap_or_else(|| panic!("missing captured request for {method}"));
5934 let expected = format!("body-for-{method}");
5935 assert!(!req.body.is_empty(), "{method} must still carry its body");
5936 assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
5937 }
5938 }
5939
5940 #[tokio::test]
5944 async fn test_stream_body_under_get_not_attached() {
5945 use tower::ServiceExt;
5946
5947 let (url, _handle, captured) = start_capture_server().await;
5948 let ctx = test_producer_ctx();
5949
5950 let component = HttpComponent::with_config(HttpConfig::default());
5951 let endpoint_ctx = NoOpComponentContext;
5952 let endpoint = component
5953 .create_endpoint(
5954 &format!("{url}?httpMethod=GET&allowInternal=true"),
5955 &endpoint_ctx,
5956 )
5957 .unwrap();
5958 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5959
5960 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
5961 vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
5962 let stream = Box::pin(futures::stream::iter(chunks));
5963 let mut exchange = Exchange::new(Message::default());
5964 exchange.input.body = Body::Stream(StreamBody {
5965 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
5966 metadata: StreamMetadata::default(),
5967 });
5968
5969 let result = producer.oneshot(exchange).await.unwrap();
5970
5971 let status = result
5972 .input
5973 .header("CamelHttpResponseCode")
5974 .and_then(|v| v.as_u64())
5975 .unwrap();
5976 assert_eq!(status, 200);
5977
5978 let captured = captured.lock().unwrap();
5979 assert_eq!(captured.len(), 1, "expected exactly one captured request");
5980 assert!(
5981 captured[0].body.is_empty(),
5982 "GET must not carry a stream body"
5983 );
5984 assert!(
5985 captured[0].transfer_encoding.is_none(),
5986 "suppressed request must not carry Transfer-Encoding"
5987 );
5988 assert!(
5989 captured[0].content_length.is_none(),
5990 "suppressed request must not carry Content-Length"
5991 );
5992 assert!(
5993 result.input.body.is_empty(),
5994 "exchange body must be consumed to Empty, not left as a stream"
5995 );
5996 }
5997
5998 #[tokio::test]
6002 async fn test_redirect_hops_never_replay_suppressed_body() {
6003 use tower::ServiceExt;
6004
6005 let (url, _handle, captured) = start_redirect_capture_server().await;
6006 let ctx = test_producer_ctx();
6007
6008 let component =
6009 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6010 let endpoint_ctx = NoOpComponentContext;
6011
6012 for path in ["/hop307", "/hop308"] {
6013 let endpoint = component
6014 .create_endpoint(
6015 &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
6016 &endpoint_ctx,
6017 )
6018 .unwrap();
6019 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6020
6021 let mut exchange = Exchange::new(Message::default());
6022 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6023
6024 let result = producer.oneshot(exchange).await.unwrap();
6025 let status = result
6026 .input
6027 .header("CamelHttpResponseCode")
6028 .and_then(|v| v.as_u64())
6029 .unwrap();
6030 assert_eq!(
6031 status, 200,
6032 "redirect chain for {path} should end at /final"
6033 );
6034 }
6035
6036 let captured = captured.lock().unwrap();
6038 assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
6039 for req in captured.iter() {
6040 assert!(
6041 req.body.is_empty(),
6042 "hop {} {} must not carry a body",
6043 req.method,
6044 req.path
6045 );
6046 }
6047 }
6048
6049 #[tracing_test::traced_test]
6058 #[tokio::test]
6059 async fn test_suppressed_body_logs_exactly_one_warn() {
6060 use tower::ServiceExt;
6061
6062 let (url, _handle, _captured) = start_capture_server().await;
6063 let ctx = test_producer_ctx();
6064
6065 let component = HttpComponent::with_config(HttpConfig::default());
6066 let endpoint_ctx = NoOpComponentContext;
6067 let endpoint = component
6068 .create_endpoint(
6069 &format!("{url}?httpMethod=GET&allowInternal=true"),
6070 &endpoint_ctx,
6071 )
6072 .unwrap();
6073 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6074
6075 let mut exchange = Exchange::new(Message::default());
6076 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6077 let correlation_id = exchange.correlation_id().to_string();
6078
6079 let result = producer.oneshot(exchange).await.unwrap();
6080 let status = result
6081 .input
6082 .header("CamelHttpResponseCode")
6083 .and_then(|v| v.as_u64())
6084 .unwrap();
6085 assert_eq!(status, 200);
6086
6087 logs_assert(|lines: &[&str]| {
6088 let hits = lines
6089 .iter()
6090 .filter(|l| {
6091 l.contains("dropping request body")
6092 && l.contains("method=GET")
6093 && l.contains(&format!("correlation_id={correlation_id}"))
6094 })
6095 .count();
6096 match hits {
6097 1 => Ok(()),
6098 n => Err(format!("expected exactly one body-drop warn, found {n}")),
6099 }
6100 });
6101 }
6102
6103 #[tracing_test::traced_test]
6104 #[tokio::test]
6105 async fn test_empty_body_get_emits_no_warn() {
6106 use tower::ServiceExt;
6107
6108 let (url, _handle, _captured) = start_capture_server().await;
6109 let ctx = test_producer_ctx();
6110
6111 let component = HttpComponent::with_config(HttpConfig::default());
6112 let endpoint_ctx = NoOpComponentContext;
6113 let endpoint = component
6114 .create_endpoint(
6115 &format!("{url}?httpMethod=GET&allowInternal=true"),
6116 &endpoint_ctx,
6117 )
6118 .unwrap();
6119 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6120
6121 let exchange = Exchange::new(Message::default());
6122 let result = producer.oneshot(exchange).await.unwrap();
6123 let status = result
6124 .input
6125 .header("CamelHttpResponseCode")
6126 .and_then(|v| v.as_u64())
6127 .unwrap();
6128 assert_eq!(status, 200);
6129
6130 logs_assert(|lines: &[&str]| {
6131 let hits = lines
6132 .iter()
6133 .filter(|l| l.contains("dropping request body"))
6134 .count();
6135 match hits {
6136 0 => Ok(()),
6137 n => Err(format!("expected no body-drop warn, found {n}")),
6138 }
6139 });
6140 }
6141
6142 #[tokio::test]
6143 async fn test_follow_redirects_false_does_not_follow() {
6144 use tower::ServiceExt;
6145
6146 let (url, _handle) = start_redirect_server().await;
6147 let ctx = test_producer_ctx();
6148
6149 let component =
6150 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
6151 let endpoint_ctx = NoOpComponentContext;
6152 let endpoint = component
6153 .create_endpoint(
6154 &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
6155 &endpoint_ctx,
6156 )
6157 .unwrap();
6158 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6159
6160 let exchange = Exchange::new(Message::default());
6161 let result = producer.oneshot(exchange).await.unwrap();
6162
6163 let status = result
6165 .input
6166 .header("CamelHttpResponseCode")
6167 .and_then(|v| v.as_u64())
6168 .unwrap();
6169 assert_eq!(
6170 status, 302,
6171 "Should NOT follow redirect when followRedirects=false"
6172 );
6173 }
6174
6175 #[tokio::test]
6176 async fn test_follow_redirects_true_follows_redirect() {
6177 use tower::ServiceExt;
6178
6179 let (url, _handle) = start_redirect_server().await;
6180 let ctx = test_producer_ctx();
6181
6182 let component =
6183 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6184 let endpoint_ctx = NoOpComponentContext;
6185 let endpoint = component
6186 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6187 .unwrap();
6188 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6189
6190 let exchange = Exchange::new(Message::default());
6191 let result = producer.oneshot(exchange).await.unwrap();
6192
6193 let status = result
6195 .input
6196 .header("CamelHttpResponseCode")
6197 .and_then(|v| v.as_u64())
6198 .unwrap();
6199 assert_eq!(
6200 status, 200,
6201 "Should follow redirect when followRedirects=true"
6202 );
6203 }
6204
6205 #[tokio::test]
6208 async fn test_redirect_to_private_ip_is_ssrf_blocked() {
6209 use tower::ServiceExt;
6210
6211 let (url, _handle) = start_redirect_server().await;
6213 let ctx = test_producer_ctx();
6214
6215 let component =
6216 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6217 let endpoint_ctx = NoOpComponentContext;
6218 let endpoint = component
6219 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6220 .unwrap();
6221 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6222
6223 let exchange = Exchange::new(Message::default());
6224 let result = producer.oneshot(exchange).await;
6225
6226 assert!(
6228 result.is_ok(),
6229 "Redirect should succeed with allowInternal=true, got: {:?}",
6230 result
6231 );
6232 let exchange = result.unwrap();
6233 let status = exchange
6234 .input
6235 .header("CamelHttpResponseCode")
6236 .and_then(|v| v.as_u64())
6237 .unwrap();
6238 assert_eq!(status, 200, "Should follow redirect to /final");
6239 }
6240
6241 #[tokio::test]
6243 async fn test_redirect_to_private_ip_allowed_when_configured() {
6244 use tower::ServiceExt;
6245
6246 let (url, _handle) = start_redirect_server().await;
6248 let ctx = test_producer_ctx();
6249
6250 let component =
6251 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6252 let endpoint_ctx = NoOpComponentContext;
6253 let endpoint = component
6254 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6255 .unwrap();
6256 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6257
6258 let exchange = Exchange::new(Message::default());
6259 let result = producer.oneshot(exchange).await.unwrap();
6260
6261 let status = result
6262 .input
6263 .header("CamelHttpResponseCode")
6264 .and_then(|v| v.as_u64())
6265 .unwrap();
6266 assert_eq!(
6267 status, 200,
6268 "Should follow redirect to private IP when allowInternal=true"
6269 );
6270 }
6271
6272 #[tokio::test]
6275 async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
6276 use tower::ServiceExt;
6277
6278 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6280 let addr = listener.local_addr().unwrap();
6281 let url = format!("http://127.0.0.1:{}", addr.port());
6282
6283 let handle = tokio::spawn(async move {
6284 use tokio::io::{AsyncReadExt, AsyncWriteExt};
6285 loop {
6286 if let Ok((mut stream, _)) = listener.accept().await {
6287 tokio::spawn(async move {
6288 let mut buf = vec![0u8; 4096];
6289 let _ = stream.read(&mut buf).await;
6290 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";
6292 let _ = stream.write_all(response.as_bytes()).await;
6293 });
6294 }
6295 }
6296 });
6297
6298 let ctx = test_producer_ctx();
6299 let component =
6300 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6301 let endpoint_ctx = NoOpComponentContext;
6302 let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
6304 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6305
6306 let exchange = Exchange::new(Message::default());
6307 let result = producer.oneshot(exchange).await;
6308
6309 assert!(
6311 result.is_err(),
6312 "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
6313 );
6314 let err = result.unwrap_err().to_string();
6315 assert!(
6316 err.contains("blocked IP")
6317 || err.contains("private IP")
6318 || err.contains("SSRF")
6319 || err.contains("not allowed"),
6320 "Error should mention SSRF/IP blocking, got: {err}"
6321 );
6322
6323 handle.abort();
6324 }
6325
6326 #[tokio::test]
6328 async fn test_too_many_redirects_returns_error() {
6329 use tower::ServiceExt;
6330
6331 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6333 let addr = listener.local_addr().unwrap();
6334 let url = format!("http://127.0.0.1:{}", addr.port());
6335
6336 let handle = tokio::spawn(async move {
6337 use tokio::io::{AsyncReadExt, AsyncWriteExt};
6338 loop {
6339 if let Ok((mut stream, _)) = listener.accept().await {
6340 tokio::spawn(async move {
6341 let mut buf = vec![0u8; 4096];
6342 let _ = stream.read(&mut buf).await;
6343 let response = "HTTP/1.1 302 Found\r\nLocation: /loop\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
6348 let _ = stream.write_all(response.as_bytes()).await;
6349 });
6350 }
6351 }
6352 });
6353
6354 let ctx = test_producer_ctx();
6355 let component =
6356 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6357 let endpoint_ctx = NoOpComponentContext;
6358 let endpoint = component
6359 .create_endpoint(
6360 &format!("{url}?allowInternal=true&maxRedirects=2"),
6361 &endpoint_ctx,
6362 )
6363 .unwrap();
6364 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6365
6366 let exchange = Exchange::new(Message::default());
6367 let result = producer.oneshot(exchange).await;
6368
6369 match result {
6377 Err(e) => {
6378 let msg = e.to_string();
6380 assert!(
6381 msg.contains("HTTP operation failed") || msg.contains("302"),
6382 "expected redirect-after-exhaustion error, got: {msg}"
6383 );
6384 }
6385 Ok(ex) => {
6386 let response_code = ex
6387 .input
6388 .header("CamelHttpResponseCode")
6389 .and_then(|v| v.as_u64());
6390 assert_eq!(
6391 response_code,
6392 Some(302),
6393 "expected 302 after exhausting redirects"
6394 );
6395 }
6396 }
6397
6398 handle.abort();
6399 }
6400
6401 #[tokio::test]
6402 async fn test_query_params_forwarded_to_http_request() {
6403 use tower::ServiceExt;
6404
6405 let (url, _handle) = start_test_server().await;
6406 let ctx = test_producer_ctx();
6407
6408 let component = HttpComponent::new();
6409 let endpoint_ctx = NoOpComponentContext;
6410 let endpoint = component
6412 .create_endpoint(
6413 &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
6414 &endpoint_ctx,
6415 )
6416 .unwrap();
6417 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6418
6419 let exchange = Exchange::new(Message::default());
6420 let result = producer.oneshot(exchange).await.unwrap();
6421
6422 let status = result
6425 .input
6426 .header("CamelHttpResponseCode")
6427 .and_then(|v| v.as_u64())
6428 .unwrap();
6429 assert_eq!(status, 200);
6430 }
6431
6432 #[test]
6433 fn test_non_camel_query_params_are_forwarded() {
6434 let config = HttpEndpointConfig::from_uri(
6437 "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
6438 )
6439 .unwrap();
6440
6441 assert_eq!(
6444 config.raw_query.as_deref(),
6445 Some("apiKey=secret123&httpMethod=GET&token=abc456")
6446 );
6447 assert!(config.query_params.is_empty());
6448 }
6449
6450 #[test]
6451 fn test_authored_query_bytes_survive_resolve_url() {
6452 let config =
6453 HttpEndpointConfig::from_uri("http://example.com/api?q=hello%20world&tag=a+b").unwrap();
6454 let exchange = Exchange::new(Message::default());
6455
6456 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
6457
6458 assert!(url.contains("q=hello%20world"), "url was: {url}");
6461 assert!(url.contains("tag=a+b"), "url was: {url}");
6462 }
6463
6464 async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
6469 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6470 let addr = listener.local_addr().unwrap();
6471 let url = format!("http://127.0.0.1:{}", addr.port());
6472
6473 let handle = tokio::spawn(async move {
6474 loop {
6475 if let Ok((mut stream, _)) = listener.accept().await {
6476 let delay = delay_ms;
6477 tokio::spawn(async move {
6478 use tokio::io::{AsyncReadExt, AsyncWriteExt};
6479 let mut buf = vec![0u8; 4096];
6480 let _ = stream.read(&mut buf).await;
6481 let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
6483 let _ = stream.write_all(headers.as_bytes()).await;
6484 tokio::time::sleep(Duration::from_millis(delay)).await;
6486 let body = r#"{"status":"slow"}"#;
6487 let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
6488 let _ = stream.write_all(chunk.as_bytes()).await;
6489 });
6490 }
6491 }
6492 });
6493
6494 (url, handle)
6495 }
6496
6497 #[tokio::test]
6498 async fn test_http_producer_timeout() {
6499 use tower::ServiceExt;
6500
6501 let (url, _handle) = start_slow_server(500).await;
6503 let ctx = test_producer_ctx();
6504
6505 let component = HttpComponent::with_config(
6506 HttpConfig::default()
6507 .with_read_timeout_ms(100)
6508 .with_response_timeout_ms(30_000), );
6510 let endpoint_ctx = NoOpComponentContext;
6511 let endpoint = component
6512 .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
6513 .unwrap();
6514 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6515
6516 let exchange = Exchange::new(Message::default());
6517 let result = producer.oneshot(exchange).await;
6518
6519 assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
6520 let err = result.unwrap_err().to_string();
6521 assert!(
6522 err.contains("Read timeout") || err.contains("timeout"),
6523 "Error should mention timeout, got: {}",
6524 err
6525 );
6526 }
6527
6528 #[tokio::test]
6529 async fn test_http_producer_no_timeout_when_fast() {
6530 use tower::ServiceExt;
6531
6532 let (url, _handle) = start_test_server().await;
6533 let ctx = test_producer_ctx();
6534
6535 let component =
6536 HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
6537 let endpoint_ctx = NoOpComponentContext;
6538 let endpoint = component
6539 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
6540 .unwrap();
6541 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6542
6543 let exchange = Exchange::new(Message::default());
6544 let result = producer.oneshot(exchange).await.unwrap();
6545
6546 let status = result
6547 .input
6548 .header("CamelHttpResponseCode")
6549 .and_then(|v| v.as_u64())
6550 .unwrap();
6551 assert_eq!(status, 200);
6552 }
6553
6554 #[tokio::test]
6559 async fn test_http_producer_blocks_metadata_endpoint() {
6560 use tower::ServiceExt;
6561
6562 let ctx = test_producer_ctx();
6563 let component = HttpComponent::new();
6564 let endpoint_ctx = NoOpComponentContext;
6565 let endpoint = component
6566 .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
6567 .unwrap();
6568 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6569
6570 let mut exchange = Exchange::new(Message::default());
6571 exchange.input.set_header(
6572 "CamelHttpUri",
6573 serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
6574 );
6575
6576 let result = producer.oneshot(exchange).await;
6577 assert!(result.is_err(), "Should block AWS metadata endpoint");
6578
6579 let err = result.unwrap_err();
6580 assert!(
6581 err.to_string().contains("Private IP"),
6582 "Error should mention private IP blocking, got: {}",
6583 err
6584 );
6585 }
6586
6587 #[test]
6588 fn test_ssrf_config_defaults() {
6589 let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
6590 assert!(
6591 !config.allow_internal,
6592 "Private IPs should be blocked by default"
6593 );
6594 assert!(
6595 config.blocked_hosts.is_empty(),
6596 "Blocked hosts should be empty by default"
6597 );
6598 }
6599
6600 #[test]
6601 fn test_ssrf_config_allow_internal() {
6602 let config =
6603 HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
6604 assert!(
6605 config.allow_internal,
6606 "Private IPs should be allowed when explicitly set"
6607 );
6608 }
6609
6610 #[test]
6611 fn test_uri_option_allow_cleartext_parses() {
6612 let config =
6613 HttpEndpointConfig::from_uri("http://example.com/?allowCleartext=true").unwrap();
6614 assert!(
6615 config.allow_cleartext,
6616 "allowCleartext=true must parse into the endpoint config"
6617 );
6618
6619 let plain = HttpEndpointConfig::from_uri("http://example.com/").unwrap();
6620 assert!(
6621 !plain.allow_cleartext,
6622 "cleartext consent must default to false"
6623 );
6624
6625 let err =
6626 HttpEndpointConfig::from_uri("http://example.com/?allowCleartext=banana").unwrap_err();
6627 assert!(
6628 matches!(&err, CamelError::InvalidUri(msg) if msg.contains("allowCleartext")),
6629 "bad allowCleartext value must yield InvalidUri naming the option, got: {err:?}"
6630 );
6631 }
6632
6633 #[test]
6637 fn test_camel_http_uri_override_public_cleartext_follows_endpoint_flags() {
6638 let endpoint =
6639 HttpEndpointConfig::from_uri("http://localhost/?allowCleartext=false").unwrap();
6640 let err = crate::ssrf::validate_url_for_ssrf("http://93.184.216.34/exfil", &endpoint)
6641 .expect_err("public cleartext override must be rejected without consent");
6642 assert!(
6643 err.to_string().contains("allowCleartext"),
6644 "error must name the remedy, got: {err}"
6645 );
6646
6647 let endpoint =
6648 HttpEndpointConfig::from_uri("http://localhost/?allowCleartext=true").unwrap();
6649 assert!(
6650 crate::ssrf::validate_url_for_ssrf("http://93.184.216.34/exfil", &endpoint).is_ok(),
6651 "endpoint consent must admit a public cleartext override"
6652 );
6653 }
6654
6655 #[test]
6656 fn test_ssrf_config_blocked_hosts() {
6657 let config = HttpEndpointConfig::from_uri(
6658 "http://example.com/api?blockedHosts=evil.com,malware.net",
6659 )
6660 .unwrap();
6661 assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
6662 }
6663
6664 #[tokio::test]
6665 async fn test_http_producer_blocks_localhost() {
6666 use tower::ServiceExt;
6667
6668 let ctx = test_producer_ctx();
6669 let component = HttpComponent::new();
6670 let endpoint_ctx = NoOpComponentContext;
6671 let endpoint = component
6672 .create_endpoint("http://example.com/api", &endpoint_ctx)
6673 .unwrap();
6674 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6675
6676 let mut exchange = Exchange::new(Message::default());
6677 exchange.input.set_header(
6678 "CamelHttpUri",
6679 serde_json::Value::String("http://localhost:8080/internal".to_string()),
6680 );
6681
6682 let result = producer.oneshot(exchange).await;
6683 assert!(result.is_err(), "Should block localhost");
6684 }
6685
6686 #[tokio::test]
6687 async fn test_http_producer_blocks_loopback_ip() {
6688 use tower::ServiceExt;
6689
6690 let ctx = test_producer_ctx();
6691 let component = HttpComponent::new();
6692 let endpoint_ctx = NoOpComponentContext;
6693 let endpoint = component
6694 .create_endpoint("http://example.com/api", &endpoint_ctx)
6695 .unwrap();
6696 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6697
6698 let mut exchange = Exchange::new(Message::default());
6699 exchange.input.set_header(
6700 "CamelHttpUri",
6701 serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
6702 );
6703
6704 let result = producer.oneshot(exchange).await;
6705 assert!(result.is_err(), "Should block loopback IP");
6706 }
6707
6708 #[tokio::test]
6709 async fn test_http_producer_allows_private_ip_when_enabled() {
6710 use tower::ServiceExt;
6711
6712 let ctx = test_producer_ctx();
6713 let component = HttpComponent::new();
6714 let endpoint_ctx = NoOpComponentContext;
6715 let endpoint = component
6718 .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
6719 .unwrap();
6720 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6721
6722 let exchange = Exchange::new(Message::default());
6723
6724 let result = producer.oneshot(exchange).await;
6727 if let Err(ref e) = result {
6729 let err_str = e.to_string();
6730 assert!(
6731 !err_str.contains("Private IP") && !err_str.contains("not allowed"),
6732 "Should not be SSRF error, got: {}",
6733 err_str
6734 );
6735 }
6736 }
6737
6738 #[test]
6743 fn test_http_server_config_parse() {
6744 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
6745 assert_eq!(cfg.host, "0.0.0.0");
6746 assert_eq!(cfg.port, 8080);
6747 assert_eq!(cfg.path, "/orders");
6748 assert_eq!(cfg.max_inflight_requests, 1024);
6749 }
6750
6751 #[test]
6752 fn test_http_server_config_scheme() {
6753 assert_eq!(HttpServerConfig::scheme(), "http");
6755 }
6756
6757 #[test]
6758 fn test_http_server_config_from_components() {
6759 let components = camel_component_api::UriComponents {
6761 scheme: "https".to_string(),
6762 path: "//0.0.0.0:8443/api".to_string(),
6763 params: std::collections::HashMap::from([
6764 ("maxRequestBody".to_string(), "5242880".to_string()),
6765 ("maxInflightRequests".to_string(), "7".to_string()),
6766 ]),
6767 raw_query: None,
6768 };
6769 let cfg = HttpServerConfig::from_components(components).unwrap();
6770 assert_eq!(cfg.host, "0.0.0.0");
6771 assert_eq!(cfg.port, 8443);
6772 assert_eq!(cfg.path, "/api");
6773 assert_eq!(cfg.max_request_body, 5242880);
6774 assert_eq!(cfg.max_inflight_requests, 7);
6775 }
6776
6777 #[test]
6778 fn test_http_server_config_default_path() {
6779 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
6780 assert_eq!(cfg.path, "/");
6781 }
6782
6783 #[test]
6784 fn test_http_server_config_wrong_scheme() {
6785 assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
6786 }
6787
6788 #[test]
6789 fn test_http_server_config_invalid_port() {
6790 assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
6791 }
6792
6793 #[test]
6794 fn test_http_server_config_default_port_by_scheme() {
6795 let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
6797 assert_eq!(cfg_http.port, 80);
6798
6799 let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
6801 assert_eq!(cfg_https.port, 443);
6802 }
6803
6804 #[test]
6805 fn test_request_envelope_and_reply_are_send() {
6806 fn assert_send<T: Send>() {}
6807 assert_send::<RequestEnvelope>();
6808 assert_send::<HttpReply>();
6809 }
6810
6811 #[test]
6816 fn test_server_registry_global_is_singleton() {
6817 let r1 = ServerRegistry::global();
6818 let r2 = ServerRegistry::global();
6819 assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
6820 }
6821
6822 #[allow(clippy::await_holding_lock)]
6823 #[tokio::test]
6824 async fn test_concurrent_get_or_spawn_returns_same_registry() {
6825 let _guard = lock_registry_test_mutex();
6826 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6827 let port = listener.local_addr().unwrap().port();
6828 drop(listener);
6829
6830 let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
6831 Arc::new(std::sync::Mutex::new(Vec::new()));
6832
6833 let mut handles = Vec::new();
6834 for _ in 0..4 {
6835 let results = results.clone();
6836 handles.push(tokio::spawn(async move {
6837 let registry = ServerRegistry::global()
6838 .get_or_spawn(
6839 "127.0.0.1",
6840 port,
6841 2 * 1024 * 1024,
6842 10 * 1024 * 1024,
6843 1024,
6844 test_rt(),
6845 "test-route".into(),
6846 None,
6847 )
6848 .await
6849 .unwrap();
6850 results.lock().unwrap().push(registry);
6851 }));
6852 }
6853
6854 for h in handles {
6855 h.await.unwrap();
6856 }
6857
6858 let registries = results.lock().unwrap();
6859 assert_eq!(registries.len(), 4);
6860 for i in 1..registries.len() {
6861 assert!(
6862 Arc::ptr_eq(®istries[0].inner, ®istries[i].inner),
6863 "all concurrent callers should get same route registry"
6864 );
6865 }
6866 }
6867
6868 #[test]
6869 fn test_server_registry_distinguishes_host_and_port() {
6870 let _guard = lock_registry_test_mutex();
6871 let rt = tokio::runtime::Runtime::new().expect("runtime");
6872 rt.block_on(async {
6873 let registry = ServerRegistry::global();
6874 let d1 = registry
6878 .get_or_spawn(
6879 "127.0.0.1",
6880 0,
6881 1024 * 1024,
6882 10 * 1024 * 1024,
6883 1024,
6884 test_rt(),
6885 "test-route-1".into(),
6886 None,
6887 )
6888 .await;
6889 let d2 = registry
6890 .get_or_spawn(
6891 "0.0.0.0",
6892 0,
6893 1024 * 1024,
6894 10 * 1024 * 1024,
6895 1024,
6896 test_rt(),
6897 "test-route-2".into(),
6898 None,
6899 )
6900 .await;
6901 assert!(d1.is_ok());
6902 assert!(d2.is_ok());
6903 assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
6904 });
6905 }
6906
6907 #[allow(clippy::await_holding_lock)]
6908 #[tokio::test]
6909 async fn test_shared_server_max_request_body_policy_is_deterministic() {
6910 let _guard = lock_registry_test_mutex();
6911 let registry = ServerRegistry::global();
6912 let d1 = registry
6914 .get_or_spawn(
6915 "127.0.0.1",
6916 9991,
6917 1024 * 1024,
6918 10 * 1024 * 1024,
6919 1024,
6920 test_rt(),
6921 "test-route".into(),
6922 None,
6923 )
6924 .await;
6925 assert!(d1.is_ok());
6926
6927 let d2 = registry
6930 .get_or_spawn(
6931 "127.0.0.1",
6932 9991,
6933 2 * 1024 * 1024,
6934 10 * 1024 * 1024,
6935 1024,
6936 test_rt(),
6937 "test-route-2".into(),
6938 None,
6939 )
6940 .await;
6941 assert!(d2.is_err());
6942 let err = d2.unwrap_err();
6943 assert!(
6944 err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
6945 "Expected incompatible maxRequestBody error, got: {}",
6946 err
6947 );
6948 }
6949
6950 #[test]
6951 fn test_server_registry_reset_clears_entries() {
6952 let _guard = lock_registry_test_mutex();
6953 let rt = tokio::runtime::Runtime::new().expect("runtime");
6954 rt.block_on(async {
6955 let d1 = ServerRegistry::global()
6957 .get_or_spawn(
6958 "127.0.0.1",
6959 9992,
6960 1024 * 1024,
6961 10 * 1024 * 1024,
6962 1024,
6963 test_rt(),
6964 "test-route".into(),
6965 None,
6966 )
6967 .await;
6968 assert!(d1.is_ok());
6969
6970 let guard = ServerRegistry::global().inner.lock().expect("lock");
6972 assert!(guard.entries.contains_key(&("127.0.0.1".to_string(), 9992)));
6973 drop(guard);
6974
6975 ServerRegistry::reset();
6977
6978 let guard = ServerRegistry::global().inner.lock().expect("lock");
6980 assert!(
6981 guard.entries.is_empty(),
6982 "registry should be empty after reset, has {} entries",
6983 guard.entries.len()
6984 );
6985 });
6986 }
6987
6988 #[allow(clippy::await_holding_lock)]
6989 #[tokio::test]
6990 async fn registry_rejects_tls_on_plain_port() {
6991 let _guard = lock_registry_test_mutex();
6996 ServerRegistry::reset();
6997 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
6998
6999 let _r1 = ServerRegistry::global()
7001 .get_or_spawn(
7002 "127.0.0.1",
7003 0,
7004 1024,
7005 1024,
7006 16,
7007 Arc::clone(&rt),
7008 "route-1".into(),
7009 None, )
7011 .await;
7012
7013 let result = ServerRegistry::global()
7015 .get_or_spawn(
7016 "127.0.0.1",
7017 0,
7018 1024,
7019 1024,
7020 16,
7021 Arc::clone(&rt),
7022 "route-2".into(),
7023 Some(crate::config::ServerTlsConfig {
7024 cert_path: "/x.pem".into(),
7025 key_path: "/y.pem".into(),
7026 }),
7027 )
7028 .await;
7029 assert!(result.is_err(), "must reject TLS on plain port");
7030 }
7031
7032 #[allow(clippy::await_holding_lock)]
7038 #[tokio::test]
7039 async fn test_unregister_last_http_route_keeps_server_alive() {
7040 let _guard = lock_registry_test_mutex();
7041 ServerRegistry::reset();
7042 let registry = ServerRegistry::global();
7043
7044 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7045 let port = listener.local_addr().unwrap().port();
7046 drop(listener); let rt = test_rt();
7048
7049 let _r1 = registry
7052 .get_or_spawn(
7053 "127.0.0.1",
7054 port,
7055 1024 * 1024,
7056 10 * 1024 * 1024,
7057 16,
7058 rt.clone(),
7059 "test-route-1".into(),
7060 None,
7061 )
7062 .await
7063 .unwrap();
7064 let _r2 = registry
7065 .get_or_spawn(
7066 "127.0.0.1",
7067 port,
7068 1024 * 1024,
7069 10 * 1024 * 1024,
7070 16,
7071 rt,
7072 "test-route-2".into(),
7073 None,
7074 )
7075 .await
7076 .unwrap();
7077
7078 let key = ("127.0.0.1".to_string(), port);
7079 let cell = {
7080 let guard = registry.inner.lock().expect("lock");
7081 guard.entries.get(&key).expect("entry should exist").clone()
7082 };
7083
7084 registry.unregister("127.0.0.1", port).await;
7086 {
7087 let handle = cell
7088 .get()
7089 .expect("handle should still exist after first unregister");
7090 assert!(
7091 !handle.monitor_task.is_finished(),
7092 "monitor task should still be alive after first unregister"
7093 );
7094 }
7095
7096 registry.unregister("127.0.0.1", port).await;
7098 tokio::time::sleep(Duration::from_millis(20)).await;
7099 {
7100 let handle = cell
7101 .get()
7102 .expect("handle should still exist after last unregister");
7103 assert!(
7104 !handle.monitor_task.is_finished(),
7105 "monitor task should still be alive — server is process-lifetime"
7106 );
7107 }
7108
7109 {
7111 let guard = registry.inner.lock().expect("lock");
7112 assert!(
7113 guard.entries.contains_key(&key),
7114 "entry should remain in registry — server kept alive for restart"
7115 );
7116 }
7117 }
7118
7119 async fn clone_fixture_listener() -> (
7128 tokio::net::TcpListener,
7129 std::net::TcpListener,
7130 std::net::SocketAddr,
7131 ) {
7132 let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind std listener");
7133 let probe = l.try_clone().expect("clone probe");
7134 l.set_nonblocking(true).expect("set_nonblocking");
7135 let listener = tokio::net::TcpListener::from_std(l).expect("from_std");
7136 let addr = listener.local_addr().expect("local_addr");
7137 (listener, probe, addr)
7138 }
7139
7140 fn staged_limits() -> (usize, usize, usize) {
7142 (1024 * 1024, 10 * 1024 * 1024, 1024)
7143 }
7144
7145 #[allow(clippy::await_holding_lock)]
7146 #[tokio::test]
7147 async fn staged_listener_first_spawn_serves_without_second_bind() {
7148 let _guard = lock_registry_test_mutex();
7149 ServerRegistry::reset();
7150 let registry = ServerRegistry::global();
7151 let (listener, _probe, addr) = clone_fixture_listener().await;
7152 let port = addr.port();
7153 registry
7154 .stage_listener(listener)
7155 .await
7156 .expect("stage listener");
7157
7158 let (max_req, max_res, max_inflight) = staged_limits();
7159 let routes = registry
7160 .get_or_spawn(
7161 "127.0.0.1",
7162 port,
7163 max_req,
7164 max_res,
7165 max_inflight,
7166 test_rt(),
7167 "staged-first-spawn".into(),
7168 None,
7169 )
7170 .await
7171 .expect("spawn from staged listener must succeed");
7172
7173 assert_eq!(
7174 registry.bound_addr("127.0.0.1", port),
7175 Some(addr),
7176 "served socket must be the staged listener's addr"
7177 );
7178 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__staged_probe__"))
7181 .await
7182 .expect("http request against staged listener must connect");
7183 assert!(
7184 resp.status().as_u16() >= 200,
7185 "any status proves the staged socket serves"
7186 );
7187 drop(routes);
7188 }
7189
7190 #[allow(clippy::await_holding_lock)]
7191 #[tokio::test]
7192 async fn staged_entry_reused_by_second_caller() {
7193 let _guard = lock_registry_test_mutex();
7194 ServerRegistry::reset();
7195 let registry = ServerRegistry::global();
7196 let (listener, _probe, addr) = clone_fixture_listener().await;
7197 let port = addr.port();
7198 registry
7199 .stage_listener(listener)
7200 .await
7201 .expect("stage listener");
7202
7203 let (max_req, max_res, max_inflight) = staged_limits();
7204 let first = registry
7205 .get_or_spawn(
7206 "127.0.0.1",
7207 port,
7208 max_req,
7209 max_res,
7210 max_inflight,
7211 test_rt(),
7212 "staged-reuse-1".into(),
7213 None,
7214 )
7215 .await
7216 .expect("first spawn from staged listener");
7217 let second = registry
7218 .get_or_spawn(
7219 "127.0.0.1",
7220 port,
7221 max_req,
7222 max_res,
7223 max_inflight,
7224 test_rt(),
7225 "staged-reuse-2".into(),
7226 None,
7227 )
7228 .await
7229 .expect("second caller must reuse the entry");
7230 assert_eq!(
7231 registry.bound_addr("127.0.0.1", port),
7232 Some(addr),
7233 "entry reused — bound addr unchanged, no second bind"
7234 );
7235 drop(first);
7236 drop(second);
7237 }
7238
7239 #[allow(clippy::await_holding_lock)]
7240 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7241 async fn staged_race_two_callers_single_resolver() {
7242 let _guard = lock_registry_test_mutex();
7243 ServerRegistry::reset();
7244 let registry = ServerRegistry::global();
7245 let (listener, _probe, addr) = clone_fixture_listener().await;
7246 let port = addr.port();
7247 registry
7248 .stage_listener(listener)
7249 .await
7250 .expect("stage listener");
7251
7252 let (max_req, max_res, max_inflight) = staged_limits();
7257 let (first, second) = tokio::join!(
7258 registry.get_or_spawn(
7259 "127.0.0.1",
7260 port,
7261 max_req,
7262 max_res,
7263 max_inflight,
7264 test_rt(),
7265 "staged-race-1".into(),
7266 None,
7267 ),
7268 registry.get_or_spawn(
7269 "127.0.0.1",
7270 port,
7271 max_req,
7272 max_res,
7273 max_inflight,
7274 test_rt(),
7275 "staged-race-2".into(),
7276 None,
7277 ),
7278 );
7279 let first = first.expect("first racing caller must succeed");
7280 let second = second.expect("second racing caller must succeed");
7281 assert_eq!(
7282 registry.bound_addr("127.0.0.1", port),
7283 Some(addr),
7284 "single entry must be served from the staged socket — no EADDRINUSE path"
7285 );
7286 drop(first);
7287 drop(second);
7288 }
7289
7290 #[allow(clippy::await_holding_lock)]
7291 #[tokio::test]
7292 async fn unstaged_spawn_binds_legacy() {
7293 let _guard = lock_registry_test_mutex();
7294 ServerRegistry::reset();
7295 let registry = ServerRegistry::global();
7296 let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe bind");
7298 let port = probe.local_addr().expect("local addr").port();
7299 drop(probe);
7300
7301 let (max_req, max_res, max_inflight) = staged_limits();
7302 registry
7303 .get_or_spawn(
7304 "127.0.0.1",
7305 port,
7306 max_req,
7307 max_res,
7308 max_inflight,
7309 test_rt(),
7310 "legacy-bind".into(),
7311 None,
7312 )
7313 .await
7314 .expect("legacy bind spawn");
7315 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__legacy_probe__"))
7316 .await
7317 .expect("connect to freshly bound port must succeed");
7318 assert!(resp.status().as_u16() >= 200);
7319 assert_eq!(
7320 registry.bound_addr("127.0.0.1", port),
7321 Some(std::net::SocketAddr::from(([127, 0, 0, 1], port))),
7322 "bound addr must be the legacy bound (host, port)"
7323 );
7324 }
7325
7326 #[allow(clippy::await_holding_lock)]
7327 #[tokio::test]
7328 async fn wrong_host_staged_port_fails_deterministically() {
7329 let _guard = lock_registry_test_mutex();
7330 ServerRegistry::reset();
7331 let registry = ServerRegistry::global();
7332 let (listener, _probe, addr) = clone_fixture_listener().await;
7333 let port = addr.port();
7334 registry
7335 .stage_listener(listener)
7336 .await
7337 .expect("stage listener under 127.0.0.1");
7338
7339 let (max_req, max_res, max_inflight) = staged_limits();
7340 let err = registry
7341 .get_or_spawn(
7342 "localhost",
7343 port,
7344 max_req,
7345 max_res,
7346 max_inflight,
7347 test_rt(),
7348 "conflict-probe".into(),
7349 None,
7350 )
7351 .await
7352 .expect_err("wrong host on staged port must fail deterministically");
7353 assert!(
7354 err.to_string().contains("staged listener conflict on port"),
7355 "unexpected error: {err}"
7356 );
7357
7358 registry
7360 .get_or_spawn(
7361 "127.0.0.1",
7362 port,
7363 max_req,
7364 max_res,
7365 max_inflight,
7366 test_rt(),
7367 "conflict-after".into(),
7368 None,
7369 )
7370 .await
7371 .expect("correct host must serve the staged listener");
7372 assert_eq!(
7373 registry.bound_addr("127.0.0.1", port),
7374 Some(addr),
7375 "staged slot must be untouched by the conflicting call"
7376 );
7377 }
7378
7379 #[allow(clippy::await_holding_lock)]
7380 #[tokio::test]
7381 async fn duplicate_stage_same_key_rejected() {
7382 let _guard = lock_registry_test_mutex();
7383 ServerRegistry::reset();
7384 let registry = ServerRegistry::global();
7385 let (listener, probe, addr) = clone_fixture_listener().await;
7386 registry
7387 .stage_listener(listener)
7388 .await
7389 .expect("stage listener A");
7390
7391 let dup = probe.try_clone().expect("clone2");
7393 dup.set_nonblocking(true).expect("set_nonblocking2");
7394 let b = tokio::net::TcpListener::from_std(dup).expect("from_std2");
7395
7396 let err = registry
7397 .stage_listener(b)
7398 .await
7399 .expect_err("duplicate stage must be rejected");
7400 assert!(
7401 err.to_string().contains("listener already staged"),
7402 "unexpected error: {err}"
7403 );
7404
7405 let (max_req, max_res, max_inflight) = staged_limits();
7406 registry
7407 .get_or_spawn(
7408 "127.0.0.1",
7409 addr.port(),
7410 max_req,
7411 max_res,
7412 max_inflight,
7413 test_rt(),
7414 "dup-stage-after".into(),
7415 None,
7416 )
7417 .await
7418 .expect("spawn from first staged listener");
7419 assert_eq!(
7420 registry.bound_addr("127.0.0.1", addr.port()),
7421 Some(addr),
7422 "first staged listener retained"
7423 );
7424 }
7425
7426 #[allow(clippy::await_holding_lock)]
7427 #[tokio::test]
7428 async fn distinct_keys_stage_independently() {
7429 let _guard = lock_registry_test_mutex();
7430 ServerRegistry::reset();
7431 let registry = ServerRegistry::global();
7432 let (l1, _p1, addr1) = clone_fixture_listener().await;
7433 let (l2, _p2, addr2) = clone_fixture_listener().await;
7434 registry.stage_listener(l1).await.expect("stage P1");
7435 registry.stage_listener(l2).await.expect("stage P2");
7436
7437 let (max_req, max_res, max_inflight) = staged_limits();
7438 registry
7439 .get_or_spawn(
7440 "127.0.0.1",
7441 addr1.port(),
7442 max_req,
7443 max_res,
7444 max_inflight,
7445 test_rt(),
7446 "distinct-1".into(),
7447 None,
7448 )
7449 .await
7450 .expect("spawn P1");
7451 registry
7452 .get_or_spawn(
7453 "127.0.0.1",
7454 addr2.port(),
7455 max_req,
7456 max_res,
7457 max_inflight,
7458 test_rt(),
7459 "distinct-2".into(),
7460 None,
7461 )
7462 .await
7463 .expect("spawn P2");
7464 assert_eq!(
7465 registry.bound_addr("127.0.0.1", addr1.port()),
7466 Some(addr1),
7467 "P1 bound addr must be its own listener"
7468 );
7469 assert_eq!(
7470 registry.bound_addr("127.0.0.1", addr2.port()),
7471 Some(addr2),
7472 "P2 bound addr must be its own listener"
7473 );
7474 let r1 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr1.port()))
7475 .await
7476 .expect("connect P1");
7477 assert!(r1.status().as_u16() >= 200);
7478 let r2 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr2.port()))
7479 .await
7480 .expect("connect P2");
7481 assert!(r2.status().as_u16() >= 200);
7482 }
7483
7484 #[allow(clippy::await_holding_lock)]
7485 #[tokio::test]
7486 async fn tls_prebound_listener_served() {
7487 use camel_component_api::test_support::tls;
7488
7489 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7492
7493 let _guard = lock_registry_test_mutex();
7494 ServerRegistry::reset();
7495 let registry = ServerRegistry::global();
7496 let (listener, _probe, addr) = clone_fixture_listener().await;
7497 let port = addr.port();
7498
7499 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
7500 let cert_path = tls::write_pem_tmp("http-staged-tls-cert.pem", &cert_pem);
7501 let key_path = tls::write_pem_tmp("http-staged-tls-key.pem", &key_pem);
7502 let ca_path = tls::write_pem_tmp("http-staged-tls-ca.pem", &ca_pem);
7503
7504 let (max_req, max_res, max_inflight) = staged_limits();
7505 let routes = registry
7506 .get_or_spawn_with_listener(
7507 listener,
7508 max_req,
7509 max_res,
7510 max_inflight,
7511 test_rt(),
7512 "staged-tls".into(),
7513 Some(crate::config::ServerTlsConfig {
7514 cert_path: cert_path.to_string_lossy().into_owned(),
7515 key_path: key_path.to_string_lossy().into_owned(),
7516 }),
7517 )
7518 .await
7519 .expect("spawn TLS server from pre-bound listener");
7520
7521 let ca_bytes = std::fs::read(&ca_path).expect("read ca pem");
7524 let client = reqwest::Client::builder()
7525 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).expect("parse ca pem"))
7526 .build()
7527 .expect("build tls client");
7528
7529 let resp = client
7530 .get(format!("https://127.0.0.1:{port}/__staged_tls__"))
7531 .send()
7532 .await
7533 .expect("TLS handshake + request must succeed");
7534 assert_eq!(resp.status().as_u16(), 404, "unknown path 404s through TLS");
7535 assert_eq!(
7536 registry.bound_addr("127.0.0.1", port),
7537 Some(addr),
7538 "bound addr equals the pre-bound listener addr"
7539 );
7540 drop(routes);
7541 }
7542
7543 #[allow(clippy::await_holding_lock)]
7544 #[tokio::test]
7545 async fn with_listener_direct_spawn_keyed_by_actual_addr() {
7546 let _guard = lock_registry_test_mutex();
7547 ServerRegistry::reset();
7548 let registry = ServerRegistry::global();
7549 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
7550 .await
7551 .expect("bind un-staged listener");
7552 let addr = listener.local_addr().expect("local addr");
7553 let port = addr.port();
7554
7555 let (max_req, max_res, max_inflight) = staged_limits();
7556 registry
7557 .get_or_spawn_with_listener(
7558 listener,
7559 max_req,
7560 max_res,
7561 max_inflight,
7562 test_rt(),
7563 "with-listener".into(),
7564 None,
7565 )
7566 .await
7567 .expect("direct spawn from un-staged listener");
7568 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__with_listener__"))
7569 .await
7570 .expect("connect on actual port");
7571 assert!(resp.status().as_u16() >= 200);
7572 assert_eq!(
7573 registry.bound_addr("127.0.0.1", port),
7574 Some(addr),
7575 "registry key is the listener's actual port"
7576 );
7577
7578 registry
7579 .get_or_spawn(
7580 "127.0.0.1",
7581 port,
7582 max_req,
7583 max_res,
7584 max_inflight,
7585 test_rt(),
7586 "with-listener-reuse".into(),
7587 None,
7588 )
7589 .await
7590 .expect("legacy caller must reuse the entry");
7591 assert_eq!(
7592 registry.bound_addr("127.0.0.1", port),
7593 Some(addr),
7594 "entry reused — no second bind"
7595 );
7596 }
7597
7598 #[tokio::test]
7603 async fn test_dispatch_handler_returns_404_for_unknown_path() {
7604 let registry = HttpRouteRegistry::new();
7605 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7607 let port = listener.local_addr().unwrap().port();
7608 tokio::spawn(run_axum_server(
7609 listener,
7610 registry,
7611 2 * 1024 * 1024,
7612 10 * 1024 * 1024,
7613 Arc::new(tokio::sync::Semaphore::new(1024)),
7614 test_rt(),
7615 "test-route".into(),
7616 ));
7617
7618 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
7620
7621 let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
7622 .await
7623 .unwrap();
7624 assert_eq!(resp.status().as_u16(), 404);
7625 }
7626
7627 #[tokio::test]
7632 async fn test_http_consumer_start_registers_path() {
7633 use camel_component_api::ConsumerContext;
7634
7635 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7637 let port = listener.local_addr().unwrap().port();
7638 drop(listener); let consumer_cfg = HttpServerConfig {
7641 scheme: "http".to_string(),
7642 host: "127.0.0.1".to_string(),
7643 port,
7644 path: "/ping".to_string(),
7645 max_request_body: 2 * 1024 * 1024,
7646 max_response_body: 10 * 1024 * 1024,
7647 max_inflight_requests: 1024,
7648 method: None,
7649 tls_config: None,
7650 };
7651 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7652
7653 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7654 let token = tokio_util::sync::CancellationToken::new();
7655 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7656
7657 tokio::spawn(async move {
7658 consumer.start(ctx).await.unwrap();
7659 });
7660
7661 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7662
7663 let client = reqwest::Client::new();
7664 let resp_future = client
7665 .post(format!("http://127.0.0.1:{port}/ping"))
7666 .body("hello world")
7667 .send();
7668
7669 let (http_result, _) = tokio::join!(resp_future, async {
7670 if let Some(mut envelope) = rx.recv().await {
7671 envelope.exchange.input.set_header(
7673 "CamelHttpResponseCode",
7674 serde_json::Value::Number(201.into()),
7675 );
7676 if let Some(reply_tx) = envelope.reply_tx {
7677 let _ = reply_tx.send(Ok(envelope.exchange));
7678 }
7679 }
7680 });
7681
7682 let resp = http_result.unwrap();
7683 assert_eq!(resp.status().as_u16(), 201);
7684
7685 token.cancel();
7686 }
7687
7688 #[test]
7692 fn test_envelope_channel_capacity_follows_max_inflight() {
7693 assert_eq!(envelope_channel_capacity(0), 1);
7694 assert_eq!(envelope_channel_capacity(1), 1);
7695 assert_eq!(envelope_channel_capacity(7), 7);
7696 assert_eq!(envelope_channel_capacity(64), 64);
7697 assert_eq!(envelope_channel_capacity(1024), 1024);
7698 }
7699
7700 #[tokio::test]
7704 async fn test_http_consumer_start_with_zero_max_inflight_rejects_503() {
7705 use camel_component_api::ConsumerContext;
7706
7707 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7708 let port = listener.local_addr().unwrap().port();
7709 drop(listener);
7710
7711 let consumer_cfg = HttpServerConfig {
7712 scheme: "http".to_string(),
7713 host: "127.0.0.1".to_string(),
7714 port,
7715 path: "/ping".to_string(),
7716 max_request_body: 2 * 1024 * 1024,
7717 max_response_body: 10 * 1024 * 1024,
7718 max_inflight_requests: 0,
7719 method: None,
7720 tls_config: None,
7721 };
7722 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7723
7724 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7725 let token = tokio_util::sync::CancellationToken::new();
7726 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7727
7728 let start_handle = tokio::spawn(async move {
7729 consumer.start(ctx).await.unwrap();
7730 });
7731
7732 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7733
7734 let client = reqwest::Client::new();
7735 let resp = client
7736 .post(format!("http://127.0.0.1:{port}/ping"))
7737 .body("hello world")
7738 .send()
7739 .await
7740 .unwrap();
7741 assert_eq!(resp.status().as_u16(), 503);
7742
7743 token.cancel();
7744 let _ = start_handle.await;
7745 }
7746
7747 #[test]
7750 fn test_http_consumer_startup_mode_is_explicit() {
7751 use camel_component_api::ConsumerStartupMode;
7752 let consumer_cfg = HttpServerConfig {
7753 scheme: "http".to_string(),
7754 host: "127.0.0.1".to_string(),
7755 port: 0,
7756 path: "/x".to_string(),
7757 max_request_body: 2 * 1024 * 1024,
7758 max_response_body: 10 * 1024 * 1024,
7759 max_inflight_requests: 1024,
7760 method: None,
7761 tls_config: None,
7762 };
7763 let consumer = HttpConsumer::new(consumer_cfg, test_rt());
7764 assert_eq!(
7765 consumer.startup_mode(),
7766 ConsumerStartupMode::Explicit,
7767 "HttpConsumer must opt into Explicit startup"
7768 );
7769 }
7770
7771 #[allow(clippy::await_holding_lock)]
7777 #[tokio::test]
7778 async fn test_http_consumer_emits_mark_ready_after_bind() {
7779 use camel_component_api::{ConsumerContext, StartupSignal};
7780
7781 let _guard = lock_registry_test_mutex();
7782
7783 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7784 let port = listener.local_addr().unwrap().port();
7785 drop(listener);
7786
7787 let consumer_cfg = HttpServerConfig {
7788 scheme: "http".to_string(),
7789 host: "127.0.0.1".to_string(),
7790 port,
7791 path: "/ready-probe".to_string(),
7792 max_request_body: 2 * 1024 * 1024,
7793 max_response_body: 10 * 1024 * 1024,
7794 max_inflight_requests: 1024,
7795 method: None,
7796 tls_config: None,
7797 };
7798 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7799
7800 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7801 let token = tokio_util::sync::CancellationToken::new();
7802 let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
7803
7804 let (signal, startup_rx) = StartupSignal::pair();
7806 let ctx = ctx.with_startup(signal);
7807
7808 tokio::spawn(async move {
7811 let _ = consumer.start(ctx).await;
7812 });
7813
7814 let result =
7819 tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
7820 .await
7821 .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
7822 assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
7823
7824 token.cancel();
7826 }
7827
7828 #[derive(Default, Clone)]
7835 struct ErrorRecordingRuntime {
7836 errors: std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>>,
7837 }
7838
7839 impl camel_api::MetricsCollector for ErrorRecordingRuntime {
7840 fn record_exchange_duration(&self, _route_id: &str, _duration: std::time::Duration) {}
7841 fn increment_errors(&self, route_id: &str, error_type: &str) {
7842 self.errors
7843 .lock()
7844 .expect("error recorder lock")
7845 .push((route_id.to_string(), error_type.to_string()));
7846 }
7847 fn increment_exchanges(&self, _route_id: &str) {}
7848 fn set_queue_depth(&self, _queue: &str, _depth: usize) {}
7849 fn record_circuit_breaker_change(&self, _route_id: &str, _from: &str, _to: &str) {}
7850 }
7851
7852 impl camel_component_api::HealthCheckRegistry for ErrorRecordingRuntime {
7853 fn force_unhealthy_for_route(&self, _route_id: &str, _name: &str, _reason: &str) {}
7854 }
7855
7856 impl camel_component_api::RuntimeObservability for ErrorRecordingRuntime {
7857 fn metrics(&self) -> std::sync::Arc<dyn camel_api::MetricsCollector> {
7858 std::sync::Arc::new(self.clone())
7859 }
7860 fn health(&self) -> std::sync::Arc<dyn camel_component_api::HealthCheckRegistry> {
7861 std::sync::Arc::new(self.clone())
7862 }
7863 }
7864
7865 #[allow(clippy::await_holding_lock)]
7880 #[tokio::test]
7881 async fn shared_server_death_fails_every_hosted_consumer() {
7882 use camel_component_api::{ConsumerContext, StartupSignal};
7883
7884 let _guard = lock_registry_test_mutex();
7885 ServerRegistry::reset();
7886
7887 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7889 let port = listener.local_addr().unwrap().port();
7890 drop(listener);
7891
7892 let rt = ErrorRecordingRuntime::default();
7893
7894 let make_consumer = |path: &str| {
7895 HttpConsumer::new(
7896 HttpServerConfig {
7897 scheme: "http".to_string(),
7898 host: "127.0.0.1".to_string(),
7899 port,
7900 path: path.to_string(),
7901 max_request_body: 2 * 1024 * 1024,
7902 max_response_body: 10 * 1024 * 1024,
7903 max_inflight_requests: 16,
7904 method: None,
7905 tls_config: None,
7906 },
7907 std::sync::Arc::new(rt.clone()),
7908 )
7909 };
7910
7911 let spawn_consumer = |path: &str, route_id: &str| {
7912 let mut consumer = make_consumer(path);
7913 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7914 let token = tokio_util::sync::CancellationToken::new();
7915 let ctx = ConsumerContext::new(tx, token, route_id.to_string());
7916 let (signal, startup_rx) = StartupSignal::pair();
7917 let ctx = ctx.with_startup(signal);
7918 let task = tokio::spawn(async move { consumer.start(ctx).await });
7919 (task, startup_rx)
7920 };
7921
7922 let (task_a, ready_a) = spawn_consumer("/zombie-a", "zombie-route-a");
7924 let (task_b, ready_b) = spawn_consumer("/zombie-b", "zombie-route-b");
7925
7926 for (name, ready) in [("a", ready_a), ("b", ready_b)] {
7928 let result =
7929 tokio::time::timeout(std::time::Duration::from_secs(2), ready.await_ready())
7930 .await
7931 .unwrap_or_else(|_| panic!("consumer {name} never became ready"));
7932 assert!(
7933 result.is_ok(),
7934 "consumer {name} readiness must resolve Ok (bind + registration complete)"
7935 );
7936 }
7937
7938 {
7941 let registry = ServerRegistry::global();
7942 let guard = registry.inner.lock().expect("ServerRegistry lock");
7943 let cell = guard
7944 .entries
7945 .get(&("127.0.0.1".to_string(), port))
7946 .expect("shared server entry must exist");
7947 let handle = cell.get().expect("server handle must be initialized");
7948 handle.server_abort.abort();
7949 }
7950
7951 let outcome_a = tokio::time::timeout(std::time::Duration::from_secs(2), task_a)
7954 .await
7955 .expect("ZOMBIE: consumer-a still running after shared server death (rc-szmob)");
7956 let outcome_b = tokio::time::timeout(std::time::Duration::from_secs(2), task_b)
7957 .await
7958 .expect("ZOMBIE: consumer-b still running after shared server death (rc-szmob)");
7959
7960 let err_a = outcome_a
7961 .expect("consumer-a task must join")
7962 .expect_err("consumer-a start() must return Err when the shared server dies");
7963 let err_b = outcome_b
7964 .expect("consumer-b task must join")
7965 .expect_err("consumer-b start() must return Err when the shared server dies");
7966
7967 for (name, err) in [("a", &err_a), ("b", &err_b)] {
7970 assert!(
7971 err.to_string().contains("127.0.0.1")
7972 && err.to_string().contains(&port.to_string()),
7973 "consumer-{name} error must name the dead shared server, got: {err}"
7974 );
7975 }
7976
7977 let recorded = rt.errors.lock().expect("error recorder lock").clone();
7980 assert!(
7981 recorded
7982 .iter()
7983 .any(|(route, label)| label == "e:http:server-task-exited"
7984 && route == "zombie-route-a"),
7985 "expected e:http:server-task-exited for the spawning route, got: {recorded:?}"
7986 );
7987 }
7988
7989 #[tokio::test]
7990 async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
7991 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7992
7993 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7994 let port = listener.local_addr().unwrap().port();
7995 drop(listener);
7996
7997 let consumer_cfg = HttpServerConfig {
7998 scheme: "http".to_string(),
7999 host: "127.0.0.1".to_string(),
8000 port,
8001 path: "/saturation".to_string(),
8002 max_request_body: 2 * 1024 * 1024,
8003 max_response_body: 10 * 1024 * 1024,
8004 max_inflight_requests: 1,
8005 method: None,
8006 tls_config: None,
8007 };
8008 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8009
8010 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8011 let token = tokio_util::sync::CancellationToken::new();
8012 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8013 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8014 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8015
8016 let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
8017 let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
8018
8019 tokio::spawn(async move {
8020 let mut first_seen_tx = Some(first_seen_tx);
8021 let mut unblock_first_rx = Some(unblock_first_rx);
8022
8023 while let Some(envelope) = rx.recv().await {
8024 if let Some(tx) = first_seen_tx.take() {
8025 let _ = tx.send(());
8026 if let Some(rx_unblock) = unblock_first_rx.take() {
8027 let _ = rx_unblock.await;
8028 }
8029 }
8030
8031 if let Some(reply_tx) = envelope.reply_tx {
8032 let _ = reply_tx.send(Ok(envelope.exchange));
8033 }
8034 }
8035 });
8036
8037 let client = reqwest::Client::new();
8038 let first_req = {
8039 let client = client.clone();
8040 async move {
8041 client
8042 .get(format!("http://127.0.0.1:{port}/saturation"))
8043 .send()
8044 .await
8045 .unwrap()
8046 }
8047 };
8048
8049 let first_handle = tokio::spawn(first_req);
8050 first_seen_rx.await.unwrap();
8051
8052 let second_resp = client
8053 .get(format!("http://127.0.0.1:{port}/saturation"))
8054 .send()
8055 .await
8056 .unwrap();
8057
8058 assert_eq!(second_resp.status().as_u16(), 503);
8059
8060 let _ = unblock_first_tx.send(());
8061 let first_resp = first_handle.await.unwrap();
8062 assert_eq!(first_resp.status().as_u16(), 200);
8063
8064 token.cancel();
8065 }
8066
8067 #[tokio::test]
8071 async fn test_http_consumer_chunked_body_is_capped() {
8072 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8073
8074 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8075 let port = listener.local_addr().unwrap().port();
8076 drop(listener);
8077
8078 let consumer_cfg = HttpServerConfig {
8079 scheme: "http".to_string(),
8080 host: "127.0.0.1".to_string(),
8081 port,
8082 path: "/chunked-cap".to_string(),
8083 max_request_body: 1024, max_response_body: 10 * 1024 * 1024,
8085 max_inflight_requests: 16,
8086 method: None,
8087 tls_config: None,
8088 };
8089 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8090
8091 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8092 let token = tokio_util::sync::CancellationToken::new();
8093 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8094 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8095 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8096
8097 let chunks: Vec<Result<bytes::Bytes, std::io::Error>> = (0..8)
8099 .map(|_| Ok(bytes::Bytes::from(vec![b'A'; 512])))
8100 .collect();
8101 let stream_body = reqwest::Body::wrap_stream(futures::stream::iter(chunks));
8102
8103 let client = reqwest::Client::new();
8104 let send_fut = client
8105 .post(format!("http://127.0.0.1:{port}/chunked-cap"))
8106 .body(stream_body)
8107 .send();
8108
8109 let (http_result, _) = tokio::join!(send_fut, async {
8110 if let Some(mut envelope) = rx.recv().await {
8111 let materialized = envelope
8113 .exchange
8114 .input
8115 .body
8116 .clone()
8117 .into_bytes(64 * 1024)
8118 .await;
8119 assert!(
8120 materialized.is_err(),
8121 "materializing a 4 KiB chunked body under a 1 KiB cap must fail"
8122 );
8123 let err = materialized.unwrap_err().to_string();
8124 assert!(
8125 err.contains("limit") || err.contains("exceeds"),
8126 "error should mention the limit: {err}"
8127 );
8128 if let Some(reply_tx) = envelope.reply_tx {
8129 envelope.exchange.input.body =
8130 camel_component_api::Body::Text("handled".to_string());
8131 let _ = reply_tx.send(Ok(envelope.exchange));
8132 }
8133 }
8134 });
8135
8136 let resp = http_result.unwrap();
8137 assert_eq!(resp.status().as_u16(), 200);
8138
8139 token.cancel();
8140 }
8141
8142 #[tokio::test]
8143 #[allow(clippy::await_holding_lock)]
8144 async fn test_http_consumer_enforces_max_response_body_for_bytes() {
8145 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8146
8147 let _guard = lock_registry_test_mutex();
8148
8149 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8150 let port = listener.local_addr().unwrap().port();
8151 drop(listener);
8152
8153 let consumer_cfg = HttpServerConfig {
8154 scheme: "http".to_string(),
8155 host: "127.0.0.1".to_string(),
8156 port,
8157 path: "/limit-bytes".to_string(),
8158 max_request_body: 2 * 1024 * 1024,
8159 max_response_body: 16,
8160 max_inflight_requests: 1024,
8161 method: None,
8162 tls_config: None,
8163 };
8164 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8165
8166 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8167 let token = tokio_util::sync::CancellationToken::new();
8168 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8169 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8170 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8171
8172 let client = reqwest::Client::new();
8173 let send_fut = client
8174 .get(format!("http://127.0.0.1:{port}/limit-bytes"))
8175 .send();
8176
8177 let (http_result, _) = tokio::join!(send_fut, async {
8178 if let Some(mut envelope) = rx.recv().await {
8179 envelope.exchange.input.body =
8180 camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
8181 if let Some(reply_tx) = envelope.reply_tx {
8182 let _ = reply_tx.send(Ok(envelope.exchange));
8183 }
8184 }
8185 });
8186
8187 let resp = http_result.unwrap();
8188 assert_eq!(resp.status().as_u16(), 500);
8189 let body = resp.text().await.unwrap();
8190 assert_eq!(body, "Response body exceeds configured limit");
8191 token.cancel();
8192 }
8193
8194 #[tokio::test]
8195 #[allow(clippy::await_holding_lock)]
8196 async fn test_http_consumer_enforces_max_response_body_for_json() {
8197 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8198
8199 let _guard = lock_registry_test_mutex();
8200
8201 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8202 let port = listener.local_addr().unwrap().port();
8203 drop(listener);
8204
8205 let consumer_cfg = HttpServerConfig {
8206 scheme: "http".to_string(),
8207 host: "127.0.0.1".to_string(),
8208 port,
8209 path: "/limit-json".to_string(),
8210 max_request_body: 2 * 1024 * 1024,
8211 max_response_body: 16,
8212 max_inflight_requests: 1024,
8213 method: None,
8214 tls_config: None,
8215 };
8216 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8217
8218 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8219 let token = tokio_util::sync::CancellationToken::new();
8220 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8221 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8222 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8223
8224 let client = reqwest::Client::new();
8225 let send_fut = client
8226 .get(format!("http://127.0.0.1:{port}/limit-json"))
8227 .send();
8228
8229 let (http_result, _) = tokio::join!(send_fut, async {
8230 if let Some(mut envelope) = rx.recv().await {
8231 envelope.exchange.input.body = camel_component_api::Body::Json(
8232 serde_json::json!({"message":"this response is bigger than sixteen"}),
8233 );
8234 if let Some(reply_tx) = envelope.reply_tx {
8235 let _ = reply_tx.send(Ok(envelope.exchange));
8236 }
8237 }
8238 });
8239
8240 let resp = http_result.unwrap();
8241 assert_eq!(resp.status().as_u16(), 500);
8242 let body = resp.text().await.unwrap();
8243 assert_eq!(body, "Response body exceeds configured limit");
8244 token.cancel();
8245 }
8246
8247 #[tokio::test]
8248 #[allow(clippy::await_holding_lock)]
8249 async fn test_http_consumer_enforces_max_response_body_for_xml() {
8250 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8251
8252 let _guard = lock_registry_test_mutex();
8253
8254 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8255 let port = listener.local_addr().unwrap().port();
8256 drop(listener);
8257
8258 let consumer_cfg = HttpServerConfig {
8259 scheme: "http".to_string(),
8260 host: "127.0.0.1".to_string(),
8261 port,
8262 path: "/limit-xml".to_string(),
8263 max_request_body: 2 * 1024 * 1024,
8264 max_response_body: 16,
8265 max_inflight_requests: 1024,
8266 method: None,
8267 tls_config: None,
8268 };
8269 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8270
8271 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8272 let token = tokio_util::sync::CancellationToken::new();
8273 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8274 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8275 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8276
8277 let client = reqwest::Client::new();
8278 let send_fut = client
8279 .get(format!("http://127.0.0.1:{port}/limit-xml"))
8280 .send();
8281
8282 let (http_result, _) = tokio::join!(send_fut, async {
8283 if let Some(mut envelope) = rx.recv().await {
8284 envelope.exchange.input.body = camel_component_api::Body::Xml(
8285 "<root><value>way-too-large</value></root>".into(),
8286 );
8287 if let Some(reply_tx) = envelope.reply_tx {
8288 let _ = reply_tx.send(Ok(envelope.exchange));
8289 }
8290 }
8291 });
8292
8293 let resp = http_result.unwrap();
8294 assert_eq!(resp.status().as_u16(), 500);
8295 let body = resp.text().await.unwrap();
8296 assert_eq!(body, "Response body exceeds configured limit");
8297 token.cancel();
8298 }
8299
8300 #[tokio::test]
8301 #[allow(clippy::await_holding_lock)]
8302 async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
8303 use camel_component_api::{
8304 CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
8305 };
8306 use futures::stream;
8307
8308 let _guard = lock_registry_test_mutex();
8309
8310 let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
8311 let port = listener.local_addr().unwrap().port();
8312 drop(listener);
8313
8314 let consumer_cfg = HttpServerConfig {
8315 scheme: "http".to_string(),
8316 host: "0.0.0.0".to_string(),
8317 port,
8318 path: "/limit-stream".to_string(),
8319 max_request_body: 2 * 1024 * 1024,
8320 max_response_body: 16,
8321 max_inflight_requests: 1024,
8322 method: None,
8323 tls_config: None,
8324 };
8325 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8326
8327 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8328 let token = tokio_util::sync::CancellationToken::new();
8329 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8330 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8331 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8332
8333 let client = reqwest::Client::new();
8334 let send_fut = client
8335 .get(format!("http://127.0.0.1:{port}/limit-stream"))
8336 .send();
8337
8338 let (http_result, _) = tokio::join!(send_fut, async {
8339 if let Some(mut envelope) = rx.recv().await {
8340 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
8341 vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
8342 let stream = Box::pin(stream::iter(chunks));
8343 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
8344 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
8345 metadata: StreamMetadata {
8346 size_hint: Some(32),
8347 content_type: Some("application/octet-stream".into()),
8348 origin: None,
8349 },
8350 });
8351 if let Some(reply_tx) = envelope.reply_tx {
8352 let _ = reply_tx.send(Ok(envelope.exchange));
8353 }
8354 }
8355 });
8356
8357 let resp = http_result.unwrap();
8358 assert_eq!(resp.status().as_u16(), 200);
8359 let body = resp.bytes().await.unwrap();
8360 assert_eq!(body.len(), 32);
8361 token.cancel();
8362 }
8363
8364 #[tokio::test]
8369 #[allow(clippy::await_holding_lock)]
8370 async fn test_integration_single_consumer_round_trip() {
8371 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8372
8373 let _guard = lock_registry_test_mutex();
8377
8378 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8380 let port = listener.local_addr().unwrap().port();
8381 drop(listener); let component = HttpComponent::new();
8384 let endpoint_ctx = NoOpComponentContext;
8385 let endpoint = component
8386 .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
8387 .unwrap();
8388 let mut consumer = endpoint.create_consumer(rt()).unwrap();
8389
8390 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8391 let token = tokio_util::sync::CancellationToken::new();
8392 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8393
8394 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8395 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8396
8397 let client = reqwest::Client::new();
8398 let send_fut = client
8399 .post(format!("http://127.0.0.1:{port}/echo"))
8400 .header("Content-Type", "text/plain")
8401 .body("ping")
8402 .send();
8403
8404 let (http_result, _) = tokio::join!(send_fut, async {
8405 if let Some(mut envelope) = rx.recv().await {
8406 assert_eq!(
8407 envelope.exchange.input.header("CamelHttpMethod"),
8408 Some(&serde_json::Value::String("POST".into()))
8409 );
8410 assert_eq!(
8411 envelope.exchange.input.header("CamelHttpPath"),
8412 Some(&serde_json::Value::String("/echo".into()))
8413 );
8414 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
8415 if let Some(reply_tx) = envelope.reply_tx {
8416 let _ = reply_tx.send(Ok(envelope.exchange));
8417 }
8418 }
8419 });
8420
8421 let resp = http_result.unwrap();
8422 assert_eq!(resp.status().as_u16(), 200);
8423 let body = resp.text().await.unwrap();
8424 assert_eq!(body, "pong");
8425
8426 token.cancel();
8427 }
8428
8429 #[tokio::test]
8430 #[allow(clippy::await_holding_lock)]
8431 async fn test_integration_two_consumers_shared_port() {
8432 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8433
8434 let _guard = lock_registry_test_mutex();
8435
8436 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8438 let port = listener.local_addr().unwrap().port();
8439 drop(listener);
8440
8441 let component = HttpComponent::new();
8442 let endpoint_ctx = NoOpComponentContext;
8443
8444 let endpoint_a = component
8446 .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
8447 .unwrap();
8448 let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
8449
8450 let endpoint_b = component
8452 .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
8453 .unwrap();
8454 let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
8455
8456 let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8457 let token_a = tokio_util::sync::CancellationToken::new();
8458 let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
8459
8460 let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8461 let token_b = tokio_util::sync::CancellationToken::new();
8462 let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
8463
8464 tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
8465 tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
8466 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8467
8468 let client = reqwest::Client::new();
8469
8470 let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
8472 let (resp_hello, _) = tokio::join!(fut_hello, async {
8473 if let Some(mut envelope) = rx_a.recv().await {
8474 envelope.exchange.input.body =
8475 camel_component_api::Body::Text("hello-response".to_string());
8476 if let Some(reply_tx) = envelope.reply_tx {
8477 let _ = reply_tx.send(Ok(envelope.exchange));
8478 }
8479 }
8480 });
8481
8482 let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
8484 let (resp_world, _) = tokio::join!(fut_world, async {
8485 if let Some(mut envelope) = rx_b.recv().await {
8486 envelope.exchange.input.body =
8487 camel_component_api::Body::Text("world-response".to_string());
8488 if let Some(reply_tx) = envelope.reply_tx {
8489 let _ = reply_tx.send(Ok(envelope.exchange));
8490 }
8491 }
8492 });
8493
8494 let body_a = resp_hello.unwrap().text().await.unwrap();
8495 let body_b = resp_world.unwrap().text().await.unwrap();
8496
8497 assert_eq!(body_a, "hello-response");
8498 assert_eq!(body_b, "world-response");
8499
8500 token_a.cancel();
8501 token_b.cancel();
8502 }
8503
8504 #[tokio::test]
8505 #[allow(clippy::await_holding_lock)]
8506 async fn test_integration_unregistered_path_returns_404() {
8507 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8508
8509 let _guard = lock_registry_test_mutex();
8510
8511 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8513 let port = listener.local_addr().unwrap().port();
8514 drop(listener);
8515
8516 let component = HttpComponent::new();
8517 let endpoint_ctx = NoOpComponentContext;
8518 let endpoint = component
8519 .create_endpoint(
8520 &format!("http://127.0.0.1:{port}/registered"),
8521 &endpoint_ctx,
8522 )
8523 .unwrap();
8524 let mut consumer = endpoint.create_consumer(rt()).unwrap();
8525
8526 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8527 let token = tokio_util::sync::CancellationToken::new();
8528 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8529
8530 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8531
8532 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
8534 loop {
8535 if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
8536 .await
8537 .is_ok()
8538 {
8539 break;
8540 }
8541 if std::time::Instant::now() >= deadline {
8542 panic!("HTTP server did not start within 5s on port {port}");
8543 }
8544 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
8545 }
8546
8547 let client = reqwest::Client::new();
8548 let resp = client
8549 .get(format!("http://127.0.0.1:{port}/not-there"))
8550 .send()
8551 .await
8552 .unwrap();
8553 assert_eq!(resp.status().as_u16(), 404);
8554
8555 token.cancel();
8556 }
8557
8558 #[test]
8559 fn test_http_consumer_declares_concurrent() {
8560 use camel_component_api::ConcurrencyModel;
8561
8562 let config = HttpServerConfig {
8563 scheme: "http".to_string(),
8564 host: "127.0.0.1".to_string(),
8565 port: 19999,
8566 path: "/test".to_string(),
8567 max_request_body: 2 * 1024 * 1024,
8568 max_response_body: 10 * 1024 * 1024,
8569 max_inflight_requests: 1024,
8570 method: None,
8571 tls_config: None,
8572 };
8573 let consumer = HttpConsumer::new(config, test_rt());
8574 assert_eq!(
8575 consumer.concurrency_model(),
8576 ConcurrencyModel::Concurrent { max: None }
8577 );
8578 }
8579
8580 #[test]
8581 fn server_config_parses_tls_cert_and_key() {
8582 let cfg = HttpServerConfig::from_uri(
8583 "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
8584 )
8585 .unwrap();
8586 assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
8587 assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
8588 }
8589
8590 #[test]
8591 fn server_config_no_tls_when_params_absent() {
8592 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
8593 assert!(cfg.tls_config.is_none());
8594 }
8595
8596 #[tokio::test]
8601 async fn test_http_reply_body_stream_variant_exists() {
8602 use bytes::Bytes;
8603 use camel_component_api::CamelError;
8604 use futures::stream;
8605
8606 let chunks: Vec<Result<Bytes, CamelError>> =
8607 vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
8608 let stream = Box::pin(stream::iter(chunks));
8609 let reply_body = HttpReplyBody::Stream(stream);
8610 match reply_body {
8612 HttpReplyBody::Stream(_) => {}
8613 HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
8614 }
8615 }
8616
8617 #[cfg(feature = "otel")]
8622 mod otel_tests {
8623 use super::*;
8624 use camel_component_api::Message;
8625 use tower::ServiceExt;
8626
8627 #[tokio::test]
8628 async fn test_producer_injects_traceparent_header() {
8629 let (url, _handle) = start_test_server_with_header_capture().await;
8630 let ctx = test_producer_ctx();
8631
8632 let component = HttpComponent::new();
8633 let endpoint_ctx = NoOpComponentContext;
8634 let endpoint = component
8635 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
8636 .unwrap();
8637 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
8638
8639 let mut exchange = Exchange::new(Message::default());
8641 let mut headers = std::collections::HashMap::new();
8642 headers.insert(
8643 "traceparent".to_string(),
8644 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
8645 );
8646 camel_otel::extract_into_exchange(&mut exchange, &headers);
8647
8648 let result = producer.oneshot(exchange).await.unwrap();
8649
8650 let status = result
8652 .input
8653 .header("CamelHttpResponseCode")
8654 .and_then(|v| v.as_u64())
8655 .unwrap();
8656 assert_eq!(status, 200);
8657
8658 let traceparent = result.input.header("X-Received-Traceparent");
8660 assert!(
8661 traceparent.is_some(),
8662 "traceparent header should have been sent"
8663 );
8664
8665 let traceparent_str = traceparent.unwrap().as_str().unwrap();
8666 let parts: Vec<&str> = traceparent_str.split('-').collect();
8668 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
8669 assert_eq!(parts[0], "00", "version should be 00");
8670 assert_eq!(
8671 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
8672 "trace-id should match"
8673 );
8674 assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
8675 assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
8676 }
8677
8678 #[tokio::test]
8679 async fn test_consumer_extracts_traceparent_header() {
8680 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8681
8682 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8684 let port = listener.local_addr().unwrap().port();
8685 drop(listener);
8686
8687 let component = HttpComponent::new();
8688 let endpoint_ctx = NoOpComponentContext;
8689 let endpoint = component
8690 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
8691 .unwrap();
8692 let mut consumer = endpoint.create_consumer(rt()).unwrap();
8693
8694 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8695 let token = tokio_util::sync::CancellationToken::new();
8696 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8697
8698 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8699 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8700
8701 let client = reqwest::Client::new();
8703 let send_fut = client
8704 .post(format!("http://127.0.0.1:{port}/trace"))
8705 .header(
8706 "traceparent",
8707 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
8708 )
8709 .body("test")
8710 .send();
8711
8712 let (http_result, _) = tokio::join!(send_fut, async {
8713 if let Some(envelope) = rx.recv().await {
8714 let mut injected_headers = std::collections::HashMap::new();
8717 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
8718
8719 assert!(
8720 injected_headers.contains_key("traceparent"),
8721 "Exchange should have traceparent after extraction"
8722 );
8723
8724 let traceparent = injected_headers.get("traceparent").unwrap();
8725 let parts: Vec<&str> = traceparent.split('-').collect();
8726 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
8727 assert_eq!(
8728 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
8729 "Trace ID should match the original traceparent header"
8730 );
8731
8732 if let Some(reply_tx) = envelope.reply_tx {
8733 let _ = reply_tx.send(Ok(envelope.exchange));
8734 }
8735 }
8736 });
8737
8738 let resp = http_result.unwrap();
8739 assert_eq!(resp.status().as_u16(), 200);
8740
8741 token.cancel();
8742 }
8743
8744 #[tokio::test]
8745 async fn test_consumer_extracts_mixed_case_traceparent_header() {
8746 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8747
8748 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8750 let port = listener.local_addr().unwrap().port();
8751 drop(listener);
8752
8753 let component = HttpComponent::new();
8754 let endpoint_ctx = NoOpComponentContext;
8755 let endpoint = component
8756 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
8757 .unwrap();
8758 let mut consumer = endpoint.create_consumer(rt()).unwrap();
8759
8760 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8761 let token = tokio_util::sync::CancellationToken::new();
8762 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8763
8764 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8765 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8766
8767 let client = reqwest::Client::new();
8769 let send_fut = client
8770 .post(format!("http://127.0.0.1:{port}/trace"))
8771 .header(
8772 "TraceParent",
8773 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
8774 )
8775 .body("test")
8776 .send();
8777
8778 let (http_result, _) = tokio::join!(send_fut, async {
8779 if let Some(envelope) = rx.recv().await {
8780 let mut injected_headers = HashMap::new();
8783 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
8784
8785 assert!(
8786 injected_headers.contains_key("traceparent"),
8787 "Exchange should have traceparent after extraction from mixed-case header"
8788 );
8789
8790 let traceparent = injected_headers.get("traceparent").unwrap();
8791 let parts: Vec<&str> = traceparent.split('-').collect();
8792 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
8793 assert_eq!(
8794 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
8795 "Trace ID should match the original mixed-case TraceParent header"
8796 );
8797
8798 if let Some(reply_tx) = envelope.reply_tx {
8799 let _ = reply_tx.send(Ok(envelope.exchange));
8800 }
8801 }
8802 });
8803
8804 let resp = http_result.unwrap();
8805 assert_eq!(resp.status().as_u16(), 200);
8806
8807 token.cancel();
8808 }
8809
8810 #[tokio::test]
8811 async fn test_producer_no_trace_context_no_crash() {
8812 let (url, _handle) = start_test_server().await;
8813 let ctx = test_producer_ctx();
8814
8815 let component = HttpComponent::new();
8816 let endpoint_ctx = NoOpComponentContext;
8817 let endpoint = component
8818 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
8819 .unwrap();
8820 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
8821
8822 let exchange = Exchange::new(Message::default());
8824
8825 let result = producer.oneshot(exchange).await.unwrap();
8827
8828 let status = result
8830 .input
8831 .header("CamelHttpResponseCode")
8832 .and_then(|v| v.as_u64())
8833 .unwrap();
8834 assert_eq!(status, 200);
8835 }
8836
8837 async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
8839 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8840 let addr = listener.local_addr().unwrap();
8841 let url = format!("http://127.0.0.1:{}", addr.port());
8842
8843 let handle = tokio::spawn(async move {
8844 loop {
8845 if let Ok((mut stream, _)) = listener.accept().await {
8846 tokio::spawn(async move {
8847 use tokio::io::{AsyncReadExt, AsyncWriteExt};
8848 let mut buf = vec![0u8; 8192];
8849 let n = stream.read(&mut buf).await.unwrap_or(0);
8850 let request = String::from_utf8_lossy(&buf[..n]).to_string();
8851
8852 let traceparent = request
8854 .lines()
8855 .find(|line| line.to_lowercase().starts_with("traceparent:"))
8856 .map(|line| {
8857 line.split(':')
8858 .nth(1)
8859 .map(|s| s.trim().to_string())
8860 .unwrap_or_default()
8861 })
8862 .unwrap_or_default();
8863
8864 let body =
8865 format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
8866 let response = format!(
8867 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
8868 body.len(),
8869 traceparent,
8870 body
8871 );
8872 let _ = stream.write_all(response.as_bytes()).await;
8873 });
8874 }
8875 }
8876 });
8877
8878 (url, handle)
8879 }
8880 }
8881
8882 #[tokio::test]
8891 async fn test_request_body_arrives_as_stream() {
8892 use camel_component_api::Body;
8893 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8894
8895 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8896 let port = listener.local_addr().unwrap().port();
8897 drop(listener);
8898
8899 let component = HttpComponent::new();
8900 let endpoint_ctx = NoOpComponentContext;
8901 let endpoint = component
8902 .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
8903 .unwrap();
8904 let mut consumer = endpoint.create_consumer(rt()).unwrap();
8905
8906 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8907 let token = tokio_util::sync::CancellationToken::new();
8908 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8909
8910 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8911 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8912
8913 let client = reqwest::Client::new();
8914 let send_fut = client
8915 .post(format!("http://127.0.0.1:{port}/upload"))
8916 .body("hello streaming world")
8917 .send();
8918
8919 let (http_result, _) = tokio::join!(send_fut, async {
8920 if let Some(mut envelope) = rx.recv().await {
8921 assert!(
8923 matches!(envelope.exchange.input.body, Body::Stream(_)),
8924 "expected Body::Stream, got discriminant {:?}",
8925 std::mem::discriminant(&envelope.exchange.input.body)
8926 );
8927 let bytes = envelope
8929 .exchange
8930 .input
8931 .body
8932 .into_bytes(1024 * 1024)
8933 .await
8934 .unwrap();
8935 assert_eq!(&bytes[..], b"hello streaming world");
8936
8937 envelope.exchange.input.body = camel_component_api::Body::Empty;
8938 if let Some(reply_tx) = envelope.reply_tx {
8939 let _ = reply_tx.send(Ok(envelope.exchange));
8940 }
8941 }
8942 });
8943
8944 let resp = http_result.unwrap();
8945 assert_eq!(resp.status().as_u16(), 200);
8946
8947 token.cancel();
8948 }
8949
8950 #[tokio::test]
8955 async fn test_streaming_response_chunked() {
8956 use bytes::Bytes;
8957 use camel_component_api::Body;
8958 use camel_component_api::CamelError;
8959 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8960 use camel_component_api::{StreamBody, StreamMetadata};
8961 use futures::stream;
8962 use std::sync::Arc;
8963 use tokio::sync::Mutex;
8964
8965 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8966 let port = listener.local_addr().unwrap().port();
8967 drop(listener);
8968
8969 let component = HttpComponent::new();
8970 let endpoint_ctx = NoOpComponentContext;
8971 let endpoint = component
8972 .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
8973 .unwrap();
8974 let mut consumer = endpoint.create_consumer(rt()).unwrap();
8975
8976 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8977 let token = tokio_util::sync::CancellationToken::new();
8978 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8979
8980 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8981 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8982
8983 let client = reqwest::Client::new();
8984 let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
8985
8986 let (http_result, _) = tokio::join!(send_fut, async {
8987 if let Some(mut envelope) = rx.recv().await {
8988 let chunks: Vec<Result<Bytes, CamelError>> =
8990 vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
8991 let stream = Box::pin(stream::iter(chunks));
8992 envelope.exchange.input.body = Body::Stream(StreamBody {
8993 stream: Arc::new(Mutex::new(Some(stream))),
8994 metadata: StreamMetadata::default(),
8995 });
8996 if let Some(reply_tx) = envelope.reply_tx {
8997 let _ = reply_tx.send(Ok(envelope.exchange));
8998 }
8999 }
9000 });
9001
9002 let resp = http_result.unwrap();
9003 assert_eq!(resp.status().as_u16(), 200);
9004 let body = resp.text().await.unwrap();
9005 assert_eq!(body, "chunk1chunk2");
9006
9007 token.cancel();
9008 }
9009
9010 #[tokio::test]
9015 async fn test_413_when_content_length_exceeds_limit() {
9016 use camel_component_api::ConsumerContext;
9017
9018 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9019 let port = listener.local_addr().unwrap().port();
9020 drop(listener);
9021
9022 let component = HttpComponent::new();
9024 let endpoint_ctx = NoOpComponentContext;
9025 let endpoint = component
9026 .create_endpoint(
9027 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
9028 &endpoint_ctx,
9029 )
9030 .unwrap();
9031 let mut consumer = endpoint.create_consumer(rt()).unwrap();
9032
9033 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
9034 let token = tokio_util::sync::CancellationToken::new();
9035 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9036
9037 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9038 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9039
9040 let client = reqwest::Client::new();
9041 let resp = client
9042 .post(format!("http://127.0.0.1:{port}/upload"))
9043 .header("Content-Length", "1000") .body("x".repeat(1000))
9045 .send()
9046 .await
9047 .unwrap();
9048
9049 assert_eq!(resp.status().as_u16(), 413);
9050
9051 token.cancel();
9052 }
9053
9054 #[tokio::test]
9058 async fn test_chunked_upload_without_content_length_bypasses_limit() {
9059 use bytes::Bytes;
9060 use camel_component_api::Body;
9061 use camel_component_api::ConsumerContext;
9062 use futures::stream;
9063
9064 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9065 let port = listener.local_addr().unwrap().port();
9066 drop(listener);
9067
9068 let component = HttpComponent::new();
9070 let endpoint_ctx = NoOpComponentContext;
9071 let endpoint = component
9072 .create_endpoint(
9073 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
9074 &endpoint_ctx,
9075 )
9076 .unwrap();
9077 let mut consumer = endpoint.create_consumer(rt()).unwrap();
9078
9079 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
9080 let token = tokio_util::sync::CancellationToken::new();
9081 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9082
9083 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9084 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9085
9086 let client = reqwest::Client::new();
9087
9088 let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
9092 Ok(Bytes::from("y".repeat(50))),
9093 Ok(Bytes::from("y".repeat(50))),
9094 ];
9095 let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
9096 let send_fut = client
9097 .post(format!("http://127.0.0.1:{port}/upload"))
9098 .body(stream_body)
9099 .send();
9100
9101 let consumer_fut = async {
9102 match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
9104 Ok(Some(mut envelope)) => {
9105 assert!(
9106 matches!(envelope.exchange.input.body, Body::Stream(_)),
9107 "expected Body::Stream"
9108 );
9109 envelope.exchange.input.body = camel_component_api::Body::Empty;
9110 if let Some(reply_tx) = envelope.reply_tx {
9111 let _ = reply_tx.send(Ok(envelope.exchange));
9112 }
9113 }
9114 Ok(None) => panic!("consumer channel closed unexpectedly"),
9115 Err(_) => {
9116 }
9119 }
9120 };
9121
9122 let (http_result, _) = tokio::join!(send_fut, consumer_fut);
9123
9124 let resp = http_result.unwrap();
9125 assert_ne!(
9132 resp.status().as_u16(),
9133 413,
9134 "chunked upload has no Content-Length to pre-check"
9135 );
9136 assert_eq!(resp.status().as_u16(), 200);
9137
9138 token.cancel();
9139 }
9140
9141 #[test]
9142 fn test_is_private_ip_ranges() {
9143 use camel_api::is_ssrf_blocked_ip;
9144 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(
9163 &"2001:4860:4860::8888".parse().unwrap()
9164 )); }
9166
9167 #[test]
9168 fn test_title_case_header() {
9169 assert_eq!(title_case_header("content-type"), "Content-Type");
9170 assert_eq!(title_case_header("authorization"), "Authorization");
9171 assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
9172 assert_eq!(title_case_header("host"), "Host");
9173 assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
9174 assert_eq!(title_case_header("single"), "Single");
9175 assert_eq!(title_case_header(""), "");
9176 }
9177
9178 #[test]
9179 fn test_resolve_url_combines_path_and_query_sources() {
9180 let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
9181 let mut exchange = Exchange::new(Message::default());
9182 exchange.input.set_header(
9183 "CamelHttpPath",
9184 serde_json::Value::String("next".to_string()),
9185 );
9186 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9187 assert!(url.starts_with("http://example.com/base/next?"));
9188 assert!(url.contains("foo=bar"));
9189
9190 exchange.input.set_header(
9191 "CamelHttpUri",
9192 serde_json::Value::String("http://other.test/root".to_string()),
9193 );
9194 exchange.input.set_header(
9195 "CamelHttpQuery",
9196 serde_json::Value::String("a=1&b=2".to_string()),
9197 );
9198
9199 let override_url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9200 assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
9201 }
9202
9203 fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
9204 let mut exchange = Exchange::new(Message::default());
9205 exchange
9206 .input
9207 .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
9208 exchange.input.set_header(
9209 "CamelHttpQuery",
9210 serde_json::Value::String(query.to_string()),
9211 );
9212 exchange
9213 }
9214
9215 #[test]
9216 fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
9217 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9218 cfg.bridge_endpoint = true;
9219 cfg.query_params
9220 .push(("token".to_string(), "secret".to_string()));
9221 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
9222 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9223 assert_eq!(url, "http://x?token=secret");
9227 assert!(!url.contains("/foo"));
9228 assert!(!url.contains("dropme"));
9229 }
9230
9231 #[test]
9232 fn resolve_url_bridge_endpoint_false_merges_path() {
9233 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9234 cfg.bridge_endpoint = false;
9235 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
9236 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9237 assert!(url.contains("/foo"), "url should contain /foo: {url}");
9238 assert!(
9239 url.contains("dropme=1"),
9240 "url should contain dropme=1: {url}"
9241 );
9242 }
9243
9244 #[test]
9245 fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
9246 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9247 cfg.bridge_endpoint = true;
9248 let mut exchange = Exchange::new(Message::default());
9249 exchange.input.set_header(
9250 "CamelHttpPath",
9251 serde_json::Value::String("/foo".to_string()),
9252 );
9253 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9254 assert_eq!(url, "http://x");
9255 assert!(!url.contains("/foo"));
9256 }
9257
9258 #[test]
9259 fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
9260 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9261 cfg.bridge_endpoint = true;
9262 let mut exchange = Exchange::new(Message::default());
9264 exchange.input.set_header(
9265 "CamelHttpUri",
9266 serde_json::Value::String("http://dest/explicit".to_string()),
9267 );
9268 exchange.input.set_header(
9269 "CamelHttpPath",
9270 serde_json::Value::String("/foo".to_string()),
9271 );
9272 exchange.input.set_header(
9273 "CamelHttpQuery",
9274 serde_json::Value::String("x=1".to_string()),
9275 );
9276 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9277 assert_eq!(url, "http://x");
9281 }
9282
9283 #[test]
9284 fn bridge_programmatic_params_use_percent20() {
9285 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9286 cfg.bridge_endpoint = true;
9287 cfg.query_params = vec![("b".to_string(), "x y".to_string())];
9288 let exchange = Exchange::new(Message::default());
9289
9290 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9291
9292 assert_eq!(url, "http://x?b=x%20y");
9296 assert!(!url.contains('+'));
9297 }
9298
9299 #[test]
9300 fn bridge_arm_carries_authored_raw_query() {
9301 let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
9302 let exchange = exchange_with_path_and_query("/ignored", "dropme=1");
9305
9306 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9307
9308 assert_eq!(url, "http://h/p?a=1");
9311 assert!(!url.contains("dropme"), "exchange query leaked: {url}");
9312 assert!(!url.contains("/ignored"), "exchange path leaked: {url}");
9313 }
9314
9315 #[test]
9322 fn resolve_url_bridge_preserves_dot_segments() {
9323 let mut cfg = HttpEndpointConfig::from_uri("http://h/a/../b").unwrap();
9324 cfg.bridge_endpoint = true;
9325 cfg.query_params.push(("k".to_string(), "1".to_string()));
9326 let exchange = Exchange::new(Message::default());
9327
9328 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9329
9330 assert_eq!(url, "http://h/a/../b?k=1");
9333 }
9334
9335 #[test]
9336 fn resolve_url_bridge_preserves_default_port() {
9337 let mut cfg = HttpEndpointConfig::from_uri("http://h:80/p").unwrap();
9338 cfg.bridge_endpoint = true;
9339 cfg.query_params.push(("k".to_string(), "1".to_string()));
9340 let exchange = Exchange::new(Message::default());
9341
9342 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9343
9344 assert_eq!(url, "http://h:80/p?k=1");
9347 }
9348
9349 #[test]
9350 fn resolve_url_bridge_preserves_scheme_and_host_case() {
9351 let mut cfg = HttpEndpointConfig::from_uri("http://ExAMPLE.COM/p").unwrap();
9352 cfg.bridge_endpoint = true;
9353 cfg.query_params.push(("k".to_string(), "1".to_string()));
9354 cfg.base_url = "HTTP://ExAMPLE.COM/p".to_string();
9358 let exchange = Exchange::new(Message::default());
9359
9360 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9361
9362 assert_eq!(url, "HTTP://ExAMPLE.COM/p?k=1");
9365 }
9366
9367 #[test]
9368 fn resolve_url_bridge_no_query_emits_base_verbatim() {
9369 let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9370 cfg.bridge_endpoint = true;
9371 let exchange = Exchange::new(Message::default());
9372
9373 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9374
9375 assert_eq!(url, "http://h/p");
9378 }
9379
9380 #[test]
9381 fn resolve_url_bridge_and_non_bridge_byte_identical() {
9382 let mut bridged = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
9385 bridged.bridge_endpoint = true;
9386 bridged
9387 .query_params
9388 .push(("k".to_string(), "1".to_string()));
9389 let bridge_url =
9390 HttpProducer::resolve_url(&Exchange::new(Message::default()), &bridged).unwrap();
9391
9392 let plain = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
9395 let mut exchange = Exchange::new(Message::default());
9396 exchange.input.set_header(
9397 "CamelHttpQuery",
9398 serde_json::Value::String("k=1".to_string()),
9399 );
9400 let plain_url = HttpProducer::resolve_url(&exchange, &plain).unwrap();
9401
9402 assert_eq!(bridge_url, plain_url);
9403 assert_eq!(bridge_url, "http://H:80/a/../b?k=1");
9404 }
9405
9406 #[test]
9407 fn resolve_url_bridge_preserves_ipv6_authority_verbatim() {
9408 let mut cfg = HttpEndpointConfig::from_uri("http://[::1]:8080/p").unwrap();
9409 cfg.bridge_endpoint = true;
9410 cfg.query_params.push(("k".to_string(), "1".to_string()));
9411 let exchange = Exchange::new(Message::default());
9412
9413 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9414
9415 assert_eq!(url, "http://[::1]:8080/p?k=1");
9416 }
9417
9418 #[test]
9419 fn resolve_url_bridge_empty_base_path_keeps_no_synthetic_slash() {
9420 let cfg = HttpEndpointConfig::from_uri("http://h?x=1&bridgeEndpoint=true").unwrap();
9421 let exchange = Exchange::new(Message::default());
9422
9423 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9424
9425 assert_eq!(url, "http://h?x=1");
9428 }
9429
9430 #[test]
9435 fn resolve_url_preserves_authored_query_order_and_bytes() {
9436 let config =
9437 HttpEndpointConfig::from_uri("http://h/p?a=1&b=x,y&c=t:1&connectTimeout=5000").unwrap();
9438 let exchange = Exchange::new(Message::default());
9439
9440 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9441
9442 assert_eq!(url, "http://h/p?a=1&b=x,y&c=t:1");
9445 }
9446
9447 #[test]
9448 fn resolve_url_consumes_encoded_option_key() {
9449 let config = HttpEndpointConfig::from_uri("http://h/p?connect%54imeout=5000&a=1").unwrap();
9450 let exchange = Exchange::new(Message::default());
9451
9452 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9453
9454 assert_eq!(url, "http://h/p?a=1");
9456 }
9457
9458 #[test]
9459 fn resolve_url_all_options_consumed_drops_query() {
9460 let config = HttpEndpointConfig::from_uri("http://h/p?connectTimeout=5000").unwrap();
9461 let exchange = Exchange::new(Message::default());
9462
9463 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9464
9465 assert_eq!(url, "http://h/p");
9468 assert!(!url.contains('?'));
9469 }
9470
9471 #[test]
9472 fn resolve_url_preserves_empty_query_marker() {
9473 let config = HttpEndpointConfig::from_uri("http://h/p?").unwrap();
9474 let exchange = Exchange::new(Message::default());
9475
9476 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9477
9478 assert_eq!(url, "http://h/p?");
9481 }
9482
9483 #[test]
9484 fn resolve_url_raw_wrapper_not_re_encoded() {
9485 let config = HttpEndpointConfig::from_uri("http://h/p?token=RAW(abc)").unwrap();
9486 let exchange = Exchange::new(Message::default());
9487
9488 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9489
9490 assert_eq!(url, "http://h/p?token=RAW(abc)");
9492 assert!(!url.contains("%28"), "RAW( wrapper re-encoded: {url}");
9493 }
9494
9495 #[test]
9496 fn resolve_url_camel_http_query_composes_verbatim_span() {
9497 let config = HttpEndpointConfig::from_uri("http://h/p?x=1").unwrap();
9498 let mut exchange = Exchange::new(Message::default());
9499 exchange.input.set_header(
9500 "CamelHttpQuery",
9501 serde_json::Value::String("userFilter=a%2Cb".to_string()),
9502 );
9503
9504 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9505
9506 assert_eq!(url, "http://h/p?x=1&userFilter=a%2Cb");
9511 assert!(!url.contains("%252C"), "header bytes re-encoded: {url}");
9512 }
9513
9514 #[test]
9519 fn header_composes_with_endpoint_query() {
9520 let config =
9521 HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret&lang=en").unwrap();
9522 let mut exchange = Exchange::new(Message::default());
9523 exchange.input.set_header(
9524 "CamelHttpQuery",
9525 serde_json::Value::String("lang=es&page=2".to_string()),
9526 );
9527
9528 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9529
9530 assert_eq!(url, "http://upstream/api?apiKey=secret&lang=en&page=2");
9533 }
9534
9535 #[test]
9536 fn header_alone_still_rides() {
9537 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9538 let mut exchange = Exchange::new(Message::default());
9539 exchange.input.set_header(
9540 "CamelHttpQuery",
9541 serde_json::Value::String("page=2".to_string()),
9542 );
9543
9544 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9545
9546 assert_eq!(url, "http://upstream/api?page=2");
9548 }
9549
9550 #[test]
9551 fn empty_reflected_query_leaves_endpoint_query_intact() {
9552 let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
9553 let mut exchange = Exchange::new(Message::default());
9554 exchange
9557 .input
9558 .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
9559
9560 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9561
9562 assert_eq!(url, "http://upstream/api?apiKey=secret");
9564 assert!(!url.ends_with('?'), "dangling '?' marker: {url}");
9565 }
9566
9567 #[test]
9568 fn forbidden_byte_in_header_query_errors() {
9569 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9570 let mut exchange = Exchange::new(Message::default());
9571 exchange.input.set_header(
9572 "CamelHttpQuery",
9573 serde_json::Value::String("q=ab<cd".to_string()),
9574 );
9575
9576 let err = HttpProducer::resolve_url(&exchange, &config)
9577 .unwrap_err()
9578 .to_string();
9579
9580 assert!(err.contains("0x3C"), "error must name the byte: {err}");
9583 }
9584
9585 #[test]
9586 fn override_uri_with_query_plus_header_query() {
9587 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9588 let mut exchange = Exchange::new(Message::default());
9589 exchange.input.set_header(
9590 "CamelHttpUri",
9591 serde_json::Value::String("http://host/api?a=1".to_string()),
9592 );
9593 exchange.input.set_header(
9594 "CamelHttpQuery",
9595 serde_json::Value::String("a=2&b=3".to_string()),
9596 );
9597
9598 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9599
9600 assert_eq!(url, "http://host/api?a=1&b=3");
9603 }
9604
9605 #[test]
9606 fn path_applies_before_query_composition() {
9607 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9608 let mut exchange = Exchange::new(Message::default());
9609 exchange.input.set_header(
9610 "CamelHttpUri",
9611 serde_json::Value::String("http://host/api?a=1".to_string()),
9612 );
9613 exchange.input.set_header(
9614 "CamelHttpPath",
9615 serde_json::Value::String("/extra".to_string()),
9616 );
9617 exchange.input.set_header(
9618 "CamelHttpQuery",
9619 serde_json::Value::String("b=2".to_string()),
9620 );
9621
9622 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9623
9624 assert_eq!(url, "http://host/api/extra?a=1&b=2");
9627 }
9628
9629 #[test]
9630 fn plain_proxy_reflection_composes() {
9631 let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
9632 let exchange = exchange_with_path_and_query("/in/extra", "page=2");
9634
9635 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9636
9637 assert_eq!(url, "http://upstream/api/in/extra?apiKey=secret&page=2");
9640 }
9641
9642 #[test]
9643 fn bridge_endpoint_ignores_url_headers() {
9644 let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
9645 let mut exchange = Exchange::new(Message::default());
9646 exchange.input.set_header(
9647 "CamelHttpUri",
9648 serde_json::Value::String("http://evil.test/x".to_string()),
9649 );
9650 exchange.input.set_header(
9651 "CamelHttpPath",
9652 serde_json::Value::String("/foo".to_string()),
9653 );
9654 exchange.input.set_header(
9655 "CamelHttpQuery",
9656 serde_json::Value::String("z=9".to_string()),
9657 );
9658
9659 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9660
9661 assert_eq!(url, "http://h/p?a=1");
9664 assert!(!url.contains("evil"), "override leaked: {url}");
9665 assert!(!url.contains("z=9"), "header query leaked: {url}");
9666 assert!(!url.contains("/foo"), "header path leaked: {url}");
9667 }
9668
9669 #[test]
9670 fn resolve_url_programmatic_params_use_percent20_deterministic() {
9671 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9672 config.query_params = vec![
9673 ("b".to_string(), "x y".to_string()),
9674 ("a".to_string(), "1".to_string()),
9675 ];
9676 let exchange = Exchange::new(Message::default());
9677
9678 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9679
9680 assert_eq!(url, "http://h/p?b=x%20y&a=1");
9683 assert!(!url.contains('+'));
9684 }
9685
9686 #[test]
9687 fn resolve_url_authored_and_programmatic_merge() {
9688 let mut config = HttpEndpointConfig::from_uri("http://h/p?a=1&c=t:1").unwrap();
9689 config.query_params = vec![
9690 ("b".to_string(), "2".to_string()),
9691 ("a".to_string(), "9".to_string()),
9692 ];
9693 let exchange = Exchange::new(Message::default());
9694
9695 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9696
9697 assert_eq!(url, "http://h/p?a=1&c=t:1&b=2");
9700 }
9701
9702 #[test]
9703 fn from_uri_no_longer_fills_query_params_from_uri() {
9704 let config = HttpEndpointConfig::from_uri("http://h/p?a=1&connectTimeout=5000").unwrap();
9705
9706 assert!(
9708 config.query_params.is_empty(),
9709 "query_params is programmatic-only: {:?}",
9710 config.query_params
9711 );
9712 assert_eq!(config.raw_query.as_deref(), Some("a=1&connectTimeout=5000"));
9713 }
9714
9715 #[test]
9716 fn resolve_url_forbidden_raw_byte_errors() {
9717 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9718 config.raw_query = Some("a=x y".to_string());
9719 let exchange = Exchange::new(Message::default());
9720
9721 let err = HttpProducer::resolve_url(&exchange, &config)
9722 .expect_err("literal space in raw query must error");
9723
9724 assert!(
9726 err.to_string().contains("0x20"),
9727 "error must name the forbidden byte: {err}"
9728 );
9729 }
9730
9731 #[test]
9735 fn resolve_url_override_query_forbidden_byte_errors() {
9736 let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9737 let mut exchange = Exchange::new(Message::default());
9738 exchange.input.set_header(
9739 "CamelHttpUri",
9740 serde_json::Value::String("http://h2/p?a=x y".to_string()),
9741 );
9742
9743 let err = HttpProducer::resolve_url(&exchange, &config)
9744 .expect_err("literal space in the override URI's query must error");
9745
9746 assert!(
9747 err.to_string().contains("0x20"),
9748 "error must name the forbidden byte from the override query: {err}"
9749 );
9750 }
9751
9752 #[test]
9757 fn merge_header_query_decoded_key_collision_drops_header_pair() {
9758 let merged = merge_header_query(Some("a=1"), "%61=2")
9759 .expect("decoded-key collision must not be a parse error");
9760 assert_eq!(
9761 merged.as_deref(),
9762 Some("a=1"),
9763 "the higher-precedence span wins and the colliding header pair is dropped"
9764 );
9765 }
9766
9767 #[test]
9770 fn merge_header_query_duplicate_keys_within_header_ride_verbatim() {
9771 let merged = merge_header_query(None, "k=1&k=2")
9772 .expect("duplicate header keys must not be a parse error");
9773 assert_eq!(
9774 merged.as_deref(),
9775 Some("k=1&k=2"),
9776 "intra-header duplicate keys ride verbatim"
9777 );
9778 }
9779
9780 #[test]
9787 fn endpoint_config_debug_masks_base_url_userinfo() {
9788 let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
9789 config.base_url = "http://user:pass@h.example/p".to_string();
9790 let rendered = format!("{config:?}");
9791 assert!(
9792 rendered.contains("***@h.example"),
9793 "userinfo must render masked: {rendered}"
9794 );
9795 assert!(
9796 !rendered.contains("user:pass"),
9797 "no credentials in Debug output: {rendered}"
9798 );
9799
9800 let plain = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
9801 let rendered_plain = format!("{plain:?}");
9802 assert!(
9803 rendered_plain.contains("http://h.example/p"),
9804 "a base without userinfo renders unchanged: {rendered_plain}"
9805 );
9806 }
9807
9808 #[test]
9813 fn endpoint_config_debug_base_url_converges_on_canonical_redact() {
9814 let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
9815
9816 config.base_url = "http://h.example/p?token=secret#access_token=x".to_string();
9817 let rendered = format!("{config:?}");
9818 assert!(
9819 rendered.contains("base_url: \"http://h.example/p?[redacted]#[redacted]\""),
9820 "query and fragment must render as composed sentinels: {rendered}"
9821 );
9822 assert!(
9823 !rendered.contains("token=secret") && !rendered.contains("access_token"),
9824 "query/fragment credential bytes must not render: {rendered}"
9825 );
9826
9827 config.base_url = "http://h.example//u2:p2@evil/".to_string();
9828 let rendered = format!("{config:?}");
9829 assert!(
9830 rendered.contains("base_url: \"http://h.example//***@evil/\""),
9831 "later //window userinfo must mask (canonical window rule): {rendered}"
9832 );
9833 assert!(
9834 !rendered.contains("u2:p2"),
9835 "later-window credentials must not render: {rendered}"
9836 );
9837
9838 config.base_url = "http://user:pass@h.example/p?token=x".to_string();
9841 let canonical = camel_api::redact::redact_url(&config.base_url);
9842 assert_eq!(canonical, "http://***@h.example/p?[redacted]");
9843 let rendered = format!("{config:?}");
9844 assert!(
9845 rendered.contains(&format!("base_url: \"{canonical}\"")),
9846 "Debug base_url must equal canonical redact_url output: {rendered}"
9847 );
9848 }
9849
9850 #[test]
9856 fn resolve_url_authored_apostrophe_rejected_percent_escape_rides() {
9857 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9858
9859 config.raw_query = Some("q=it's".to_string());
9860 let exchange = Exchange::new(Message::default());
9861 let err = HttpProducer::resolve_url(&exchange, &config)
9862 .expect_err("authored apostrophe must be rejected, not silently %27-normalized");
9863 assert!(
9864 err.to_string().contains("0x27"),
9865 "error must name the apostrophe byte: {err}"
9866 );
9867
9868 config.raw_query = Some("q=it%27s".to_string());
9869 let url = HttpProducer::resolve_url(&exchange, &config)
9870 .expect("authored %27 escape is wire-legal");
9871 assert!(
9872 url.contains("q=it%27s"),
9873 "the authored escape must ride byte-for-byte: {url}"
9874 );
9875
9876 for &byte in b"\"`<>" {
9880 config.raw_query = Some(format!("k={}x", byte as char));
9881 let err = HttpProducer::resolve_url(&exchange, &config)
9882 .expect_err("WHATWG special-query byte must be rejected");
9883 assert!(
9884 err.to_string().contains(&format!("0x{byte:02X}")),
9885 "error must name byte 0x{byte:02X}: {err}"
9886 );
9887 }
9888 }
9889
9890 #[test]
9891 fn armed_fence_rejects_unknown_host_redacted() {
9892 let cfg = HttpEndpointConfig::from_uri(
9893 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
9894 )
9895 .unwrap();
9896 let mut exchange = Exchange::new(Message::default());
9897 exchange.input.set_header(
9898 "CamelHttpUri",
9899 serde_json::Value::String(
9900 "http://user:pass@evil.example.com/x?token=s3cret".to_string(),
9901 ),
9902 );
9903
9904 let err = HttpProducer::resolve_url(&exchange, &cfg)
9905 .expect_err("override host outside the fence must fail resolution");
9906
9907 let message = err.to_string();
9908 assert!(!message.contains("pass"), "userinfo leaked: {message}");
9909 assert!(!message.contains("s3cret"), "query leaked: {message}");
9910 }
9911
9912 #[test]
9913 fn armed_fence_rejects_unparseable_override_redacted() {
9914 let cfg = HttpEndpointConfig::from_uri(
9915 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
9916 )
9917 .unwrap();
9918 let mut exchange = Exchange::new(Message::default());
9919 exchange.input.set_header(
9920 "CamelHttpUri",
9921 serde_json::Value::String("http://u:fencesecret@evil.example.com:99999/x".to_string()),
9922 );
9923
9924 let err = HttpProducer::resolve_url(&exchange, &cfg)
9925 .expect_err("unparseable override outside the fence must fail resolution");
9926
9927 let message = err.to_string();
9928 assert!(
9929 message.contains("allowedUriHosts fence"),
9930 "fence must be named: {message}"
9931 );
9932 assert!(
9933 message.contains("[redacted]"),
9934 "suppression sentinel missing: {message}"
9935 );
9936 assert!(
9937 !message.contains("evil.example.com"),
9938 "host leaked: fail-closed arm must render only the sentinel: {message}"
9939 );
9940 assert!(
9941 !message.contains("fencesecret"),
9942 "password leaked: {message}"
9943 );
9944 assert!(!message.contains("u:"), "userinfo leaked: {message}");
9945 }
9946
9947 #[test]
9948 fn armed_fence_rejects_password_only_userinfo_redacted() {
9949 let cfg = HttpEndpointConfig::from_uri(
9950 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
9951 )
9952 .unwrap();
9953 let mut exchange = Exchange::new(Message::default());
9954 exchange.input.set_header(
9955 "CamelHttpUri",
9956 serde_json::Value::String(
9957 "http://:passwordonly@evil.example.com/x?token=querysecret".to_string(),
9958 ),
9959 );
9960
9961 let err = HttpProducer::resolve_url(&exchange, &cfg)
9962 .expect_err("password-only override outside the fence must fail resolution");
9963
9964 let message = err.to_string();
9965 assert!(
9966 !message.contains("passwordonly"),
9967 "password-only userinfo leaked: {message}"
9968 );
9969 assert!(!message.contains("querysecret"), "query leaked: {message}");
9970 assert!(
9971 message.contains("http://***@evil.example.com/x?[redacted]"),
9972 "masked shape missing: {message}"
9973 );
9974 }
9975
9976 #[test]
9977 fn armed_fence_allows_listed_host() {
9978 let cfg = HttpEndpointConfig::from_uri(
9979 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
9980 )
9981 .unwrap();
9982 let mut exchange = Exchange::new(Message::default());
9983 exchange.input.set_header(
9984 "CamelHttpUri",
9985 serde_json::Value::String("http://cdn.example.com/x".to_string()),
9986 );
9987
9988 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9989 assert_eq!(url, "http://cdn.example.com/x");
9990 }
9991
9992 #[test]
9993 fn host_only_entry_permits_any_port() {
9994 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=cdn.example.com").unwrap();
9995 let mut exchange = Exchange::new(Message::default());
9996 exchange.input.set_header(
9997 "CamelHttpUri",
9998 serde_json::Value::String("http://cdn.example.com:9443/x".to_string()),
9999 );
10000
10001 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10002 assert_eq!(url, "http://cdn.example.com:9443/x");
10003 }
10004
10005 #[test]
10006 fn unarmed_endpoint_unchanged() {
10007 let cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
10008 let mut exchange = Exchange::new(Message::default());
10009 exchange.input.set_header(
10010 "CamelHttpUri",
10011 serde_json::Value::String("http://any.example.com/path".to_string()),
10012 );
10013
10014 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10015 assert_eq!(url, "http://any.example.com/path");
10016 }
10017
10018 #[test]
10019 fn empty_allowlist_fails_endpoint_creation() {
10020 assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=,,").is_err());
10021 }
10022
10023 #[test]
10024 fn malformed_entry_fails_endpoint_creation() {
10025 assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=not a host!").is_err());
10026 }
10027
10028 #[test]
10029 fn fence_entry_with_path_fails_creation() {
10030 assert!(
10033 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=api.internal:8443/v2").is_err()
10034 );
10035 }
10036
10037 #[test]
10038 fn fence_entry_with_userinfo_fails_creation() {
10039 assert!(
10040 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=user@cdn.example.com").is_err()
10041 );
10042 }
10043
10044 #[test]
10045 fn ipv6_fence_entry_allows_bracketed_host() {
10046 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=[::1]:8443").unwrap();
10047 for uri in ["http://[::1]:8443/x", "http://[0:0:0:0:0:0:0:1]:8443/x"] {
10050 let mut exchange = Exchange::new(Message::default());
10051 exchange
10052 .input
10053 .set_header("CamelHttpUri", serde_json::Value::String(uri.to_string()));
10054 let url = HttpProducer::resolve_url(&exchange, &cfg)
10055 .unwrap_or_else(|e| panic!("override {uri} must be honored: {e}"));
10056 assert_eq!(url, uri, "bracketed IPv6 override not honored");
10057 }
10058 }
10059
10060 #[test]
10061 fn dns_case_insensitive_fence_match() {
10062 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=CDN.Example.COM").unwrap();
10065 let mut exchange = Exchange::new(Message::default());
10066 exchange.input.set_header(
10067 "CamelHttpUri",
10068 serde_json::Value::String("http://cdn.example.com/x".to_string()),
10069 );
10070 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10071 assert_eq!(url, "http://cdn.example.com/x");
10072 }
10073
10074 #[test]
10075 fn fence_allowed_override_query_merges_with_header() {
10076 let cfg =
10079 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example&k=v").unwrap();
10080 let mut exchange = Exchange::new(Message::default());
10081 exchange.input.set_header(
10082 "CamelHttpUri",
10083 serde_json::Value::String("http://host.example/api?a=1".to_string()),
10084 );
10085 exchange.input.set_header(
10086 "CamelHttpQuery",
10087 serde_json::Value::String("b=2".to_string()),
10088 );
10089
10090 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10091 assert_eq!(url, "http://host.example/api?a=1&b=2");
10092 }
10093
10094 #[test]
10095 fn empty_header_with_armed_fence_leaves_no_query() {
10096 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example").unwrap();
10097 let mut exchange = Exchange::new(Message::default());
10098 exchange.input.set_header(
10099 "CamelHttpUri",
10100 serde_json::Value::String("http://host.example/api".to_string()),
10101 );
10102 exchange
10103 .input
10104 .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
10105
10106 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10107 assert_eq!(url, "http://host.example/api");
10108 assert!(!url.contains('?'), "query marker leaked: {url}");
10109 }
10110
10111 #[test]
10112 fn fence_option_is_consumed() {
10113 let cfg =
10117 HttpEndpointConfig::from_uri("http://h/p?x=1&allowedUriHosts=cdn.example.com").unwrap();
10118 let exchange = Exchange::new(Message::default());
10119
10120 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10121 assert!(!url.contains("allowedUriHosts"), "option leaked: {url}");
10122 assert!(url.contains("x=1"), "authored query lost: {url}");
10123 }
10124
10125 #[tokio::test]
10126 async fn resolve_url_malformed_base_url_errors_no_panic() {
10127 use tower::ServiceExt;
10128
10129 let (url, _handle) = start_test_server().await;
10130 let mut config = HttpEndpointConfig::from_uri("http://[::1:bad").unwrap();
10131 config.allow_internal = true; let producer = HttpProducer {
10133 config: Arc::new(config),
10134 client: build_client(&HttpConfig::default(), None),
10135 pinned_cache: Arc::new(PinnedClientCache::new(
10136 PINNED_CLIENT_TTL,
10137 PINNED_CLIENT_MAX_ENTRIES,
10138 )),
10139 http_config: Arc::new(HttpConfig::default()),
10140 runtime: rt(),
10141 };
10142
10143 let first = producer
10146 .clone()
10147 .oneshot(Exchange::new(Message::default()))
10148 .await;
10149 let err = first.expect_err("malformed base URL must error, not panic");
10150 assert!(
10151 err.to_string().to_lowercase().contains("url"),
10152 "error must name the malformed URL: {err}"
10153 );
10154
10155 let mut exchange = Exchange::new(Message::default());
10158 exchange.input.set_header(
10159 "CamelHttpUri",
10160 serde_json::Value::String(format!("{url}/api")),
10161 );
10162 let response = producer
10163 .oneshot(exchange)
10164 .await
10165 .expect("valid request through same producer must succeed");
10166 let status = response
10167 .input
10168 .header("CamelHttpResponseCode")
10169 .and_then(|v| v.as_u64())
10170 .unwrap();
10171 assert_eq!(status, 200);
10172 }
10173
10174 #[test]
10175 fn resolve_url_bridge_malformed_base_errors_no_panic() {
10176 let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10177 cfg.bridge_endpoint = true;
10178 cfg.query_params.push(("k".to_string(), "1".to_string()));
10179 cfg.base_url = "http://[::1:bad".to_string();
10184 let exchange = Exchange::new(Message::default());
10185
10186 let err = HttpProducer::resolve_url(&exchange, &cfg)
10187 .expect_err("malformed bridge base URL must error");
10188 assert!(
10189 err.to_string().contains("invalid base URL"),
10190 "error must name the invalid base URL: {err}"
10191 );
10192 }
10193
10194 #[test]
10195 fn test_http_producer_helpers_status_and_size_boundaries() {
10196 assert!(HttpProducer::is_ok_status(200, (200, 299)));
10197 assert!(HttpProducer::is_ok_status(299, (200, 299)));
10198 assert!(!HttpProducer::is_ok_status(199, (200, 299)));
10199 assert!(!HttpProducer::is_ok_status(300, (200, 299)));
10200
10201 assert!(!exceeds_max_response_body(10, 10));
10202 assert!(exceeds_max_response_body(11, 10));
10203 }
10204
10205 #[allow(clippy::await_holding_lock)]
10210 async fn setup_consumer_on_free_port(
10211 path: &str,
10212 ) -> (
10213 u16,
10214 tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
10215 tokio_util::sync::CancellationToken,
10216 ) {
10217 use camel_component_api::ConsumerContext;
10218
10219 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10224 let port = listener.local_addr().unwrap().port();
10225
10226 let _registry_guard = lock_registry_test_mutex();
10235
10236 ServerRegistry::global()
10237 .stage_listener(listener)
10238 .await
10239 .expect("stage consumer test listener");
10240
10241 let consumer_cfg = HttpServerConfig {
10242 scheme: "http".to_string(),
10243 host: "127.0.0.1".to_string(),
10244 port,
10245 path: path.to_string(),
10246 max_request_body: 2 * 1024 * 1024,
10247 max_response_body: 10 * 1024 * 1024,
10248 max_inflight_requests: 1024,
10249 method: None,
10250 tls_config: None,
10251 };
10252 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
10253
10254 let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
10255 let token = tokio_util::sync::CancellationToken::new();
10256 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
10257
10258 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10259
10260 wait_for_registry_ready("127.0.0.1", port).await;
10266 for _ in 0..8 {
10267 tokio::task::yield_now().await;
10268 }
10269
10270 (port, rx, token)
10271 }
10272
10273 async fn wait_for_registry_ready(host: &str, port: u16) {
10278 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
10279 let mut backoff = std::time::Duration::from_millis(1);
10280 while ServerRegistry::global().bound_addr(host, port).is_none() {
10281 assert!(
10282 tokio::time::Instant::now() < deadline,
10283 "consumer server did not become ready on port {port} — registry entry absent (concurrent reset or starvation)"
10284 );
10285 tokio::time::sleep(backoff).await;
10286 backoff = (backoff * 2).min(std::time::Duration::from_millis(64));
10287 }
10288 }
10289
10290 #[tokio::test]
10291 #[should_panic(expected = "registry entry absent (concurrent reset or starvation)")]
10292 async fn readiness_deadline_fires_loud_with_hint() {
10293 let held = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10304 let port = held.local_addr().unwrap().port();
10305 wait_for_registry_ready("httpflake-unreachable-host", port).await;
10306 }
10307
10308 #[tokio::test]
10313 async fn readiness_survives_concurrent_registry_reset() {
10314 let contended = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
10315 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
10316
10317 let contended_hammer = std::sync::Arc::clone(&contended);
10321 let stop_hammer = std::sync::Arc::clone(&stop);
10322 let handle = std::thread::spawn(move || {
10323 while !stop_hammer.load(std::sync::atomic::Ordering::Relaxed) {
10324 let _guard = match REGISTRY_TEST_MUTEX.try_lock() {
10325 Err(_) => {
10326 contended_hammer.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
10327 lock_registry_test_mutex()
10328 }
10329 Ok(guard) => guard,
10330 };
10331 ServerRegistry::reset();
10332 }
10333 });
10334
10335 struct StopHammerOnDrop {
10338 handle: Option<std::thread::JoinHandle<()>>,
10339 stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
10340 }
10341 impl Drop for StopHammerOnDrop {
10342 fn drop(&mut self) {
10343 self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
10344 if let Some(handle) = self.handle.take() {
10345 let _ = handle.join();
10346 }
10347 }
10348 }
10349 let _hammer_guard = StopHammerOnDrop {
10350 handle: Some(handle),
10351 stop,
10352 };
10353
10354 let mut setups = 0;
10357 loop {
10358 setups += 1;
10359 let (_port, rx, token) = setup_consumer_on_free_port("/reset-hammer").await;
10360 drop(rx);
10361 token.cancel();
10362 if (setups >= 25 && contended.load(std::sync::atomic::Ordering::SeqCst) >= 1)
10363 || setups >= 50
10364 {
10365 break;
10366 }
10367 }
10368
10369 let contended_hits = contended.load(std::sync::atomic::Ordering::SeqCst);
10370 assert!(
10371 contended_hits >= 1,
10372 "expected at least one contended registry reset across {setups} setups, got {contended_hits}"
10373 );
10374 }
10375
10376 #[tokio::test]
10377 async fn test_content_type_inferred_for_json_body() {
10378 let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
10379
10380 let client = reqwest::Client::new();
10381 let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
10382
10383 let (http_result, _) = tokio::join!(send_fut, async {
10384 if let Some(mut envelope) = rx.recv().await {
10385 envelope.exchange.input.body =
10386 camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
10387 if let Some(reply_tx) = envelope.reply_tx {
10388 let _ = reply_tx.send(Ok(envelope.exchange));
10389 }
10390 }
10391 });
10392
10393 let resp = http_result.unwrap();
10394 assert_eq!(resp.status().as_u16(), 200);
10395 let ct = resp
10396 .headers()
10397 .get("content-type")
10398 .expect("Content-Type header should be present");
10399 assert_eq!(ct, "application/json");
10400 let body = resp.text().await.unwrap();
10401 assert_eq!(body, r#"{"message":"hello"}"#);
10402
10403 token.cancel();
10404 }
10405
10406 #[tokio::test]
10407 async fn test_content_type_inferred_for_text_body() {
10408 let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
10409
10410 let client = reqwest::Client::new();
10411 let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
10412
10413 let (http_result, _) = tokio::join!(send_fut, async {
10414 if let Some(mut envelope) = rx.recv().await {
10415 envelope.exchange.input.body =
10416 camel_component_api::Body::Text("plain text response".to_string());
10417 if let Some(reply_tx) = envelope.reply_tx {
10418 let _ = reply_tx.send(Ok(envelope.exchange));
10419 }
10420 }
10421 });
10422
10423 let resp = http_result.unwrap();
10424 assert_eq!(resp.status().as_u16(), 200);
10425 let ct = resp
10426 .headers()
10427 .get("content-type")
10428 .expect("Content-Type header should be present");
10429 assert_eq!(ct, "text/plain; charset=utf-8");
10430 let body = resp.text().await.unwrap();
10431 assert_eq!(body, "plain text response");
10432
10433 token.cancel();
10434 }
10435
10436 #[tokio::test]
10437 async fn test_content_type_inferred_for_xml_body() {
10438 let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
10439
10440 let client = reqwest::Client::new();
10441 let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
10442
10443 let (http_result, _) = tokio::join!(send_fut, async {
10444 if let Some(mut envelope) = rx.recv().await {
10445 envelope.exchange.input.body =
10446 camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
10447 if let Some(reply_tx) = envelope.reply_tx {
10448 let _ = reply_tx.send(Ok(envelope.exchange));
10449 }
10450 }
10451 });
10452
10453 let resp = http_result.unwrap();
10454 assert_eq!(resp.status().as_u16(), 200);
10455 let ct = resp
10456 .headers()
10457 .get("content-type")
10458 .expect("Content-Type header should be present");
10459 assert_eq!(ct, "application/xml");
10460 let body = resp.text().await.unwrap();
10461 assert_eq!(body, "<root><item>value</item></root>");
10462
10463 token.cancel();
10464 }
10465
10466 #[tokio::test]
10467 async fn test_no_content_type_for_empty_body() {
10468 let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
10469
10470 let client = reqwest::Client::new();
10471 let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
10472
10473 let (http_result, _) = tokio::join!(send_fut, async {
10474 if let Some(mut envelope) = rx.recv().await {
10475 envelope.exchange.input.body = camel_component_api::Body::Empty;
10476 if let Some(reply_tx) = envelope.reply_tx {
10477 let _ = reply_tx.send(Ok(envelope.exchange));
10478 }
10479 }
10480 });
10481
10482 let resp = http_result.unwrap();
10483 assert_eq!(resp.status().as_u16(), 200);
10484 assert!(
10485 resp.headers().get("content-type").is_none(),
10486 "Empty body should not set Content-Type"
10487 );
10488
10489 token.cancel();
10490 }
10491
10492 #[tokio::test]
10493 async fn test_no_content_type_for_raw_bytes_body() {
10494 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
10495
10496 let client = reqwest::Client::new();
10497 let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
10498
10499 let (http_result, _) = tokio::join!(send_fut, async {
10500 if let Some(mut envelope) = rx.recv().await {
10501 envelope.exchange.input.body =
10502 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
10503 if let Some(reply_tx) = envelope.reply_tx {
10504 let _ = reply_tx.send(Ok(envelope.exchange));
10505 }
10506 }
10507 });
10508
10509 let resp = http_result.unwrap();
10510 assert_eq!(resp.status().as_u16(), 200);
10511 assert!(
10512 resp.headers().get("content-type").is_none(),
10513 "Raw Bytes body should not set Content-Type"
10514 );
10515
10516 token.cancel();
10517 }
10518
10519 #[tokio::test]
10520 async fn test_content_type_from_stream_metadata() {
10521 use camel_component_api::{StreamBody, StreamMetadata};
10522 use futures::stream;
10523
10524 let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
10525
10526 let client = reqwest::Client::new();
10527 let send_fut = client
10528 .get(format!("http://127.0.0.1:{port}/stream-ct"))
10529 .send();
10530
10531 let (http_result, _) = tokio::join!(send_fut, async {
10532 if let Some(mut envelope) = rx.recv().await {
10533 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
10534 vec![Ok(bytes::Bytes::from("audio data"))];
10535 let stream = Box::pin(stream::iter(chunks));
10536 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
10537 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
10538 metadata: StreamMetadata {
10539 size_hint: None,
10540 content_type: Some("audio/mpeg".to_string()),
10541 origin: None,
10542 },
10543 });
10544 if let Some(reply_tx) = envelope.reply_tx {
10545 let _ = reply_tx.send(Ok(envelope.exchange));
10546 }
10547 }
10548 });
10549
10550 let resp = http_result.unwrap();
10551 assert_eq!(resp.status().as_u16(), 200);
10552 let ct = resp
10553 .headers()
10554 .get("content-type")
10555 .expect("Content-Type header should be present");
10556 assert_eq!(ct, "audio/mpeg");
10557 let body = resp.text().await.unwrap();
10558 assert_eq!(body, "audio data");
10559
10560 token.cancel();
10561 }
10562
10563 #[tokio::test]
10564 async fn test_user_content_type_overrides_inferred() {
10565 let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
10566
10567 let client = reqwest::Client::new();
10568 let send_fut = client
10569 .get(format!("http://127.0.0.1:{port}/override-ct"))
10570 .send();
10571
10572 let (http_result, _) = tokio::join!(send_fut, async {
10573 if let Some(mut envelope) = rx.recv().await {
10574 envelope.exchange.input.body =
10575 camel_component_api::Body::Json(serde_json::json!({"ok": true}));
10576 envelope.exchange.input.set_header(
10577 "Content-Type",
10578 serde_json::Value::String("text/html".to_string()),
10579 );
10580 if let Some(reply_tx) = envelope.reply_tx {
10581 let _ = reply_tx.send(Ok(envelope.exchange));
10582 }
10583 }
10584 });
10585
10586 let resp = http_result.unwrap();
10587 assert_eq!(resp.status().as_u16(), 200);
10588 let ct = resp
10589 .headers()
10590 .get("content-type")
10591 .expect("Content-Type header should be present");
10592 assert_eq!(
10593 ct, "text/html",
10594 "User-set Content-Type should take precedence over inferred type"
10595 );
10596
10597 token.cancel();
10598 }
10599
10600 #[tokio::test]
10601 async fn test_user_content_type_with_bytes_body() {
10602 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
10603
10604 let client = reqwest::Client::new();
10605 let send_fut = client
10606 .get(format!("http://127.0.0.1:{port}/bytes-ct"))
10607 .send();
10608
10609 let (http_result, _) = tokio::join!(send_fut, async {
10610 if let Some(mut envelope) = rx.recv().await {
10611 envelope.exchange.input.body =
10612 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
10613 envelope.exchange.input.set_header(
10614 "Content-Type",
10615 serde_json::Value::String("application/json".to_string()),
10616 );
10617 if let Some(reply_tx) = envelope.reply_tx {
10618 let _ = reply_tx.send(Ok(envelope.exchange));
10619 }
10620 }
10621 });
10622
10623 let resp = http_result.unwrap();
10624 assert_eq!(resp.status().as_u16(), 200);
10625 let ct = resp
10626 .headers()
10627 .get("content-type")
10628 .expect("Content-Type header should be present for Bytes body with user header");
10629 assert_eq!(
10630 ct, "application/json",
10631 "User Content-Type should be sent for Bytes body"
10632 );
10633
10634 token.cancel();
10635 }
10636
10637 #[tokio::test]
10642 async fn monitor_task_silent_on_clean_exit() {
10643 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
10644 let server_exited = tokio_util::sync::CancellationToken::new();
10645 monitor_axum_task(
10647 handle,
10648 "127.0.0.1:0".to_string(),
10649 noop_rt(),
10650 "test-monitor".into(),
10651 server_exited.clone(),
10652 )
10653 .await;
10654 assert!(
10658 !server_exited.is_cancelled(),
10659 "clean server exit must not cancel server_exited"
10660 );
10661 }
10662
10663 #[tokio::test]
10664 async fn monitor_task_handles_panicked_task() {
10665 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
10666 panic!("simulated server crash");
10667 });
10668 let server_exited = tokio_util::sync::CancellationToken::new();
10669 monitor_axum_task(
10671 handle,
10672 "127.0.0.1:9999".to_string(),
10673 noop_rt(),
10674 "test-monitor".into(),
10675 server_exited.clone(),
10676 )
10677 .await;
10678 assert!(
10681 server_exited.is_cancelled(),
10682 "crashed server must cancel server_exited"
10683 );
10684 }
10685
10686 #[test]
10691 fn http_auth_basic_debug_redacts_password() {
10692 let auth = HttpAuth::Basic {
10693 username: "admin".to_string(),
10694 password: "hunter2".to_string(),
10695 };
10696 let debug = format!("{:?}", auth);
10697 assert!(
10698 !debug.contains("hunter2"),
10699 "password must be redacted: {debug}"
10700 );
10701 assert!(debug.contains("admin"), "username should appear: {debug}");
10702 }
10703
10704 #[test]
10705 fn http_auth_bearer_debug_redacts_token() {
10706 let auth = HttpAuth::Bearer {
10707 token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
10708 };
10709 let debug = format!("{:?}", auth);
10710 assert!(
10711 !debug.contains("eyJhbGci"),
10712 "token must be redacted: {debug}"
10713 );
10714 }
10715
10716 #[test]
10717 fn http_auth_none_debug_shows_variant() {
10718 let debug = format!("{:?}", HttpAuth::None);
10719 assert!(
10720 debug.contains("None"),
10721 "None variant should appear: {debug}"
10722 );
10723 }
10724
10725 #[test]
10726 fn http_endpoint_config_debug_redacts_auth_credentials() {
10727 let config = HttpEndpointConfig::from_uri(
10728 "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
10729 )
10730 .unwrap();
10731 let debug = format!("{:?}", config);
10732 assert!(
10733 !debug.contains("secret123"),
10734 "password must be redacted in HttpEndpointConfig debug: {debug}"
10735 );
10736 }
10737
10738 #[test]
10739 fn debug_lists_all_public_fields() {
10740 let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10741 let debug = format!("{:?}", config);
10742 for field in [
10743 "base_url",
10744 "http_method",
10745 "throw_exception_on_failure",
10746 "ok_status_code_range",
10747 "response_timeout",
10748 "query_params",
10749 "raw_query",
10750 "allow_internal",
10751 "allow_cleartext",
10752 "blocked_hosts",
10753 "max_body_size",
10754 "read_timeout_ms",
10755 "max_response_bytes",
10756 "auth",
10757 "token_provider",
10758 "user_agent",
10759 "bridge_endpoint",
10760 "connection_close",
10761 "skip_request_headers",
10762 "skip_response_headers",
10763 "follow_redirects",
10764 "max_redirects",
10765 ] {
10766 assert!(
10767 debug.contains(field),
10768 "Debug output missing field '{field}': {debug}"
10769 );
10770 }
10771 }
10772
10773 use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
10778 use tower_http::services::ServeDir;
10779
10780 fn make_test_registry() -> HttpRouteRegistry {
10781 HttpRouteRegistry::new()
10782 }
10783
10784 fn make_test_state(registry: HttpRouteRegistry) -> AppState {
10785 AppState {
10786 registry,
10787 max_request_body: 2 * 1024 * 1024,
10788 max_response_body: 10 * 1024 * 1024,
10789 inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
10790 }
10791 }
10792
10793 #[allow(clippy::await_holding_lock)]
10794 #[tokio::test]
10795 async fn test_static_file_serving_serves_file_contents() {
10796 let _guard = lock_registry_test_mutex();
10797 ServerRegistry::reset();
10798
10799 let temp_dir =
10801 std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
10802 std::fs::create_dir_all(&temp_dir).unwrap();
10803 std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
10804 std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
10805
10806 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
10807
10808 let registry = make_test_registry();
10809 let serve_dir = ServeDir::new(&canonical_dir)
10810 .precompressed_gzip()
10811 .precompressed_br()
10812 .append_index_html_on_directories(true);
10813
10814 let mount = StaticMount {
10815 mount_path: "/".to_string(),
10816 mode: MountMode::Static,
10817 dir: canonical_dir.clone(),
10818 cache_control: "public, max-age=3600".to_string(),
10819 error_pages: std::collections::HashMap::new(),
10820 serve_dir,
10821 };
10822 registry.register_static_mount(mount).await.unwrap();
10823
10824 let state = make_test_state(registry);
10825
10826 let req = Request::builder()
10828 .uri("/hello.txt")
10829 .body(AxumBody::empty())
10830 .unwrap();
10831 let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
10832 assert_eq!(resp.status(), StatusCode::OK);
10833 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10834 .await
10835 .unwrap();
10836 assert_eq!(&body[..], b"Hello, static world!");
10837
10838 let req = Request::builder()
10840 .uri("/style.css")
10841 .body(AxumBody::empty())
10842 .unwrap();
10843 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
10844 assert_eq!(resp.status(), StatusCode::OK);
10845 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10846 .await
10847 .unwrap();
10848 assert_eq!(&body[..], b"body { color: red; }");
10849
10850 let req = Request::builder()
10852 .uri("/missing.txt")
10853 .body(AxumBody::empty())
10854 .unwrap();
10855 let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
10856 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
10857
10858 std::fs::remove_dir_all(&temp_dir).ok();
10860 }
10861
10862 #[allow(clippy::await_holding_lock)]
10863 #[tokio::test]
10864 async fn test_spa_fallback_serves_index_for_unknown_paths() {
10865 let _guard = lock_registry_test_mutex();
10866 ServerRegistry::reset();
10867
10868 let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
10869 std::fs::create_dir_all(&temp_dir).unwrap();
10870 std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
10871 std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
10872
10873 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
10874
10875 let registry = make_test_registry();
10876 let serve_dir = ServeDir::new(&canonical_dir)
10877 .precompressed_gzip()
10878 .precompressed_br()
10879 .append_index_html_on_directories(true);
10880
10881 let mount = StaticMount {
10882 mount_path: "/".to_string(),
10883 mode: MountMode::Spa,
10884 dir: canonical_dir.clone(),
10885 cache_control: "public, max-age=0".to_string(),
10886 error_pages: std::collections::HashMap::new(),
10887 serve_dir,
10888 };
10889 registry.register_static_mount(mount).await.unwrap();
10891
10892 let state = make_test_state(registry);
10893
10894 let req = Request::builder()
10896 .method("GET")
10897 .uri("/dashboard")
10898 .header("Accept", "text/html")
10899 .body(AxumBody::empty())
10900 .unwrap();
10901 let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
10902 assert_eq!(resp.status(), StatusCode::OK);
10903 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10904 .await
10905 .unwrap();
10906 assert_eq!(&body[..], b"<h1>SPA App</h1>");
10907
10908 let req = Request::builder()
10910 .method("GET")
10911 .uri("/app.js")
10912 .body(AxumBody::empty())
10913 .unwrap();
10914 let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
10915 assert_eq!(resp.status(), StatusCode::OK);
10916 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10917 .await
10918 .unwrap();
10919 assert_eq!(&body[..], b"console.log('app')");
10920
10921 let req = Request::builder()
10923 .method("GET")
10924 .uri("/api/data")
10925 .header("Accept", "application/json")
10926 .body(AxumBody::empty())
10927 .unwrap();
10928 let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
10929 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
10930
10931 let req = Request::builder()
10933 .method("GET")
10934 .uri("/style.css")
10935 .header("Accept", "text/html")
10936 .body(AxumBody::empty())
10937 .unwrap();
10938 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
10939 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
10940
10941 std::fs::remove_dir_all(&temp_dir).ok();
10943 }
10944
10945 #[allow(clippy::await_holding_lock)]
10952 async fn run_conditional_get_returns_304(mode: MountMode) {
10953 let _guard = lock_registry_test_mutex();
10954 ServerRegistry::reset();
10955
10956 let temp_dir = std::env::temp_dir().join(format!(
10957 "http_cond_get_{}_{}",
10958 if mode == MountMode::Spa {
10959 "spa"
10960 } else {
10961 "static"
10962 },
10963 std::process::id()
10964 ));
10965 std::fs::create_dir_all(&temp_dir).unwrap();
10966 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
10967
10968 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
10969
10970 let registry = make_test_registry();
10971 let serve_dir = ServeDir::new(&canonical_dir)
10972 .precompressed_gzip()
10973 .precompressed_br()
10974 .append_index_html_on_directories(true);
10975
10976 let mount = StaticMount {
10977 mount_path: "/".to_string(),
10978 mode,
10979 dir: canonical_dir.clone(),
10980 cache_control: "public, max-age=3600".to_string(),
10981 error_pages: std::collections::HashMap::new(),
10982 serve_dir,
10983 };
10984 registry.register_static_mount(mount).await.unwrap();
10985
10986 let state = make_test_state(registry);
10987
10988 let req = Request::builder()
10990 .method("GET")
10991 .uri("/index.html")
10992 .body(AxumBody::empty())
10993 .unwrap();
10994 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
10995 assert_eq!(
10996 resp.status(),
10997 StatusCode::OK,
10998 "first GET should return 200, got {}",
10999 resp.status()
11000 );
11001 assert!(
11003 resp.headers().contains_key(http::header::CACHE_CONTROL),
11004 "200 response missing Cache-Control"
11005 );
11006 let etag = resp
11007 .headers()
11008 .get(http::header::ETAG)
11009 .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
11010 .clone();
11011 let last_modified = resp
11012 .headers()
11013 .get(http::header::LAST_MODIFIED)
11014 .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
11015 .clone();
11016 let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
11018 .await
11019 .unwrap();
11020
11021 let req = Request::builder()
11025 .method("GET")
11026 .uri("/index.html")
11027 .header(http::header::IF_NONE_MATCH, etag.clone())
11028 .body(AxumBody::empty())
11029 .unwrap();
11030 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11031 assert_eq!(
11032 resp.status(),
11033 StatusCode::NOT_MODIFIED,
11034 "If-None-Match with matching ETag should return 304, got {}",
11035 resp.status()
11036 );
11037 assert!(
11039 resp.headers().contains_key(http::header::CACHE_CONTROL),
11040 "304 (If-None-Match) missing Cache-Control"
11041 );
11042 assert_eq!(
11045 resp.headers().get(http::header::ETAG),
11046 Some(&etag),
11047 "304 (If-None-Match) must echo the ETag validator"
11048 );
11049 assert_eq!(
11050 resp.headers().get(http::header::LAST_MODIFIED),
11051 Some(&last_modified),
11052 "304 (If-None-Match) must carry Last-Modified"
11053 );
11054
11055 let req = Request::builder()
11057 .method("GET")
11058 .uri("/index.html")
11059 .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
11060 .body(AxumBody::empty())
11061 .unwrap();
11062 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11063 assert_eq!(
11064 resp.status(),
11065 StatusCode::NOT_MODIFIED,
11066 "If-Modified-Since with matching timestamp should return 304, got {}",
11067 resp.status()
11068 );
11069 assert!(
11070 resp.headers().contains_key(http::header::CACHE_CONTROL),
11071 "304 (If-Modified-Since) missing Cache-Control"
11072 );
11073 assert_eq!(
11074 resp.headers().get(http::header::ETAG),
11075 Some(&etag),
11076 "304 (If-Modified-Since) must carry the ETag validator"
11077 );
11078 assert_eq!(
11079 resp.headers().get(http::header::LAST_MODIFIED),
11080 Some(&last_modified),
11081 "304 (If-Modified-Since) must echo Last-Modified"
11082 );
11083
11084 let req = Request::builder()
11090 .method("GET")
11091 .uri("/index.html")
11092 .header(
11093 http::header::IF_MODIFIED_SINCE,
11094 "Wed, 21 Oct 2000 07:28:00 GMT",
11095 )
11096 .body(AxumBody::empty())
11097 .unwrap();
11098 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11099 assert_eq!(
11100 resp.status(),
11101 StatusCode::OK,
11102 "past If-Modified-Since should return 200 (file modified after it), got {}",
11103 resp.status()
11104 );
11105
11106 std::fs::remove_dir_all(&temp_dir).ok();
11108 }
11109
11110 #[tokio::test]
11111 async fn test_conditional_get_returns_304_static_mode() {
11112 run_conditional_get_returns_304(MountMode::Static).await;
11113 }
11114
11115 #[tokio::test]
11116 async fn test_conditional_get_returns_304_spa_mode() {
11117 run_conditional_get_returns_304(MountMode::Spa).await;
11118 }
11119
11120 #[allow(clippy::await_holding_lock)]
11121 #[tokio::test]
11122 async fn test_error_page_mapping_serves_custom_404() {
11123 let _guard = lock_registry_test_mutex();
11124 ServerRegistry::reset();
11125
11126 let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
11127 let errors_dir = temp_dir.join("errors");
11128 std::fs::create_dir_all(&errors_dir).unwrap();
11129 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
11130 std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
11131
11132 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11133 let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
11134
11135 let registry = make_test_registry();
11136 let serve_dir = ServeDir::new(&canonical_dir)
11137 .precompressed_gzip()
11138 .precompressed_br()
11139 .append_index_html_on_directories(true);
11140
11141 let mut error_pages = std::collections::HashMap::new();
11142 error_pages.insert(404, canonical_404);
11143
11144 let mount = StaticMount {
11145 mount_path: "/".to_string(),
11146 mode: MountMode::Static,
11147 dir: canonical_dir.clone(),
11148 cache_control: "public, max-age=0".to_string(),
11149 error_pages,
11150 serve_dir,
11151 };
11152 registry.register_static_mount(mount).await.unwrap();
11153
11154 let state = make_test_state(registry);
11155
11156 let req = Request::builder()
11158 .method("GET")
11159 .uri("/missing.html")
11160 .body(AxumBody::empty())
11161 .unwrap();
11162 let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
11163 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11164 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11165 .await
11166 .unwrap();
11167 assert_eq!(&body[..], b"<h1>Custom 404</h1>");
11168
11169 let req = Request::builder()
11171 .method("GET")
11172 .uri("/index.html")
11173 .body(AxumBody::empty())
11174 .unwrap();
11175 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11176 assert_eq!(resp.status(), StatusCode::OK);
11177 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11178 .await
11179 .unwrap();
11180 assert_eq!(&body[..], b"<h1>Home</h1>");
11181
11182 std::fs::remove_dir_all(&temp_dir).ok();
11184 }
11185
11186 #[tokio::test]
11187 async fn http_consumer_returns_body_and_code_on_stop() {
11188 use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
11189 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
11190 use tower::ServiceExt;
11191
11192 let set_body_step = CompiledStep::Process {
11194 kind_hint: camel_api::SpanKindHint::Internal,
11195 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
11196 ex.input.body = Body::Text("nope".into());
11197 Box::pin(async move { Ok(ex) })
11198 }),
11199 body_contract: None,
11200 lifecycle: None,
11201 label: None,
11202 to_uri: None,
11203 };
11204 let set_status_step = CompiledStep::Process {
11205 kind_hint: camel_api::SpanKindHint::Internal,
11206 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
11207 ex.input.set_header(
11208 "CamelHttpResponseCode",
11209 serde_json::Value::Number(409.into()),
11210 );
11211 Box::pin(async move { Ok(ex) })
11212 }),
11213 body_contract: None,
11214 lifecycle: None,
11215 label: None,
11216 to_uri: None,
11217 };
11218 let pipeline = compose_pipeline_with_handler(
11219 vec![set_body_step, set_status_step, CompiledStep::Stop],
11220 None,
11221 PipelineRuntimeCtx::compile_time(),
11222 );
11223
11224 let ex = Exchange::new(Message::default());
11225 let result = pipeline.oneshot(ex).await;
11226 assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
11227 let returned = result.unwrap();
11228 assert_eq!(returned.input.body.as_text(), Some("nope"));
11229 assert_eq!(
11230 returned
11231 .input
11232 .header("CamelHttpResponseCode")
11233 .and_then(|v| v.as_u64()),
11234 Some(409)
11235 );
11236 }
11237
11238 #[tokio::test]
11239 async fn http_consumer_returns_200_when_body_empty_on_stop() {
11240 use camel_api::{Exchange, Message};
11248 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
11249 use tower::ServiceExt;
11250
11251 let pipeline = compose_pipeline_with_handler(
11252 vec![CompiledStep::Stop],
11253 None,
11254 PipelineRuntimeCtx::compile_time(),
11255 );
11256 let ex = Exchange::new(Message::default());
11257 let result = pipeline.oneshot(ex).await;
11258 assert!(result.is_ok(), "Stop with empty body arrives as Ok");
11259 }
11262
11263 async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
11271 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11272 let port = listener.local_addr().unwrap().port();
11273 let registry = HttpRouteRegistry::new();
11274 tokio::spawn(run_axum_server(
11275 listener,
11276 registry.clone(),
11277 2 * 1024 * 1024,
11278 10 * 1024 * 1024,
11279 Arc::new(tokio::sync::Semaphore::new(1024)),
11280 test_rt(),
11281 "test-route".into(),
11282 ));
11283 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
11285 (port, registry)
11286 }
11287
11288 fn spawn_responder(
11293 mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
11294 status: u16,
11295 body: String,
11296 ) -> tokio::task::JoinHandle<()> {
11297 tokio::spawn(async move {
11298 if let Some(envelope) = rx.recv().await {
11299 let _ = envelope.reply_tx.send(HttpReply {
11300 status,
11301 headers: vec![],
11302 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
11303 });
11304 }
11305 })
11306 }
11307
11308 #[tokio::test]
11309 async fn method_aware_dispatch_same_path_different_verbs() {
11310 let (port, registry) = spawn_test_server().await;
11311
11312 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11317 registry
11318 .register_rest_endpoint(
11319 "GET".into(),
11320 vec![PathSegment::Literal("users".into())],
11321 get_tx,
11322 )
11323 .await;
11324
11325 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11326 registry
11327 .register_rest_endpoint(
11328 "POST".into(),
11329 vec![PathSegment::Literal("users".into())],
11330 post_tx,
11331 )
11332 .await;
11333
11334 let get_handle = spawn_responder(get_rx, 200, "list".into());
11335 let post_handle = spawn_responder(post_rx, 201, "create".into());
11336
11337 let client = reqwest::Client::new();
11338
11339 let resp = client
11341 .get(format!("http://127.0.0.1:{port}/users"))
11342 .send()
11343 .await
11344 .unwrap();
11345 assert_eq!(resp.status().as_u16(), 200);
11346 let body = resp.text().await.unwrap();
11347 assert_eq!(body, "list");
11348
11349 let resp = client
11351 .post(format!("http://127.0.0.1:{port}/users"))
11352 .send()
11353 .await
11354 .unwrap();
11355 assert_eq!(resp.status().as_u16(), 201);
11356 let body = resp.text().await.unwrap();
11357 assert_eq!(body, "create");
11358
11359 let _ = tokio::join!(get_handle, post_handle);
11360 }
11361
11362 #[tokio::test]
11363 async fn method_aware_dispatch_templated_path_extracts_params() {
11364 let (port, registry) = spawn_test_server().await;
11365
11366 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11370 registry
11371 .register_rest_endpoint(
11372 "GET".into(),
11373 vec![
11374 PathSegment::Literal("users".into()),
11375 PathSegment::Param("id".into()),
11376 ],
11377 tx,
11378 )
11379 .await;
11380
11381 let handle = tokio::spawn(async move {
11384 if let Some(envelope) = rx.recv().await {
11385 let id = envelope.path_params.get("id").cloned().unwrap_or_default();
11386 let _ = envelope.reply_tx.send(HttpReply {
11387 status: 200,
11388 headers: vec![],
11389 body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
11390 });
11391 }
11392 });
11393
11394 let client = reqwest::Client::new();
11395 let resp = client
11396 .get(format!("http://127.0.0.1:{port}/users/42"))
11397 .send()
11398 .await
11399 .unwrap();
11400 assert_eq!(resp.status().as_u16(), 200);
11401 let body = resp.text().await.unwrap();
11402 assert_eq!(body, "id=42");
11403
11404 let _ = handle.await;
11405 }
11406
11407 #[tokio::test]
11408 async fn method_aware_dispatch_unmatched_method_falls_through() {
11409 let (port, _registry) = spawn_test_server().await;
11414
11415 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11417 _registry
11418 .register_rest_endpoint(
11419 "GET".into(),
11420 vec![PathSegment::Literal("users".into())],
11421 get_tx,
11422 )
11423 .await;
11424
11425 let drain = tokio::spawn(async move {
11428 let mut get_rx = get_rx;
11429 while get_rx.recv().await.is_some() {}
11430 });
11431
11432 let client = reqwest::Client::new();
11433 let resp = client
11434 .delete(format!("http://127.0.0.1:{port}/users"))
11435 .send()
11436 .await
11437 .unwrap();
11438 assert_eq!(resp.status().as_u16(), 404);
11439
11440 drop(drain);
11441 }
11442
11443 #[tokio::test]
11444 async fn regression_legacy_exact_api_route_still_works() {
11445 let (port, registry) = spawn_test_server().await;
11450
11451 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11452 registry.register_api_route("/legacy/path".into(), tx).await;
11453
11454 let handle = tokio::spawn(async move {
11455 if let Some(envelope) = rx.recv().await {
11456 let _ = envelope.reply_tx.send(HttpReply {
11457 status: 200,
11458 headers: vec![],
11459 body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
11460 });
11461 }
11462 });
11463
11464 let client = reqwest::Client::new();
11465 let resp = client
11466 .get(format!("http://127.0.0.1:{port}/legacy/path"))
11467 .send()
11468 .await
11469 .unwrap();
11470 assert_eq!(resp.status().as_u16(), 200);
11471 let body = resp.text().await.unwrap();
11472 assert_eq!(body, "legacy ok");
11473
11474 let _ = handle.await;
11475 }
11476
11477 #[allow(clippy::await_holding_lock)]
11478 #[tokio::test]
11479 async fn regression_static_mount_still_works() {
11480 let _guard = lock_registry_test_mutex();
11484 ServerRegistry::reset();
11485
11486 let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
11487 std::fs::create_dir_all(&temp_dir).unwrap();
11488 std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
11489 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11490
11491 let registry = make_test_registry();
11492 let serve_dir = ServeDir::new(&canonical_dir)
11493 .precompressed_gzip()
11494 .precompressed_br()
11495 .append_index_html_on_directories(true);
11496 let mount = StaticMount {
11497 mount_path: "/".to_string(),
11498 mode: MountMode::Static,
11499 dir: canonical_dir.clone(),
11500 cache_control: "public, max-age=3600".to_string(),
11501 error_pages: std::collections::HashMap::new(),
11502 serve_dir,
11503 };
11504 registry.register_static_mount(mount).await.unwrap();
11505
11506 let state = make_test_state(registry);
11507 let req = Request::builder()
11508 .uri("/regress.txt")
11509 .body(AxumBody::empty())
11510 .unwrap();
11511 let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
11512 assert_eq!(resp.status(), StatusCode::OK);
11513 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11514 .await
11515 .unwrap();
11516 assert_eq!(&body[..], b"static works");
11517
11518 std::fs::remove_dir_all(&temp_dir).ok();
11519 }
11520
11521 #[tokio::test]
11530 async fn deregister_one_method_keeps_sibling_verbs() {
11531 let (port, registry) = spawn_test_server().await;
11535
11536 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11537 registry
11538 .register_rest_endpoint(
11539 "GET".into(),
11540 vec![PathSegment::Literal("users".into())],
11541 get_tx,
11542 )
11543 .await;
11544
11545 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11546 registry
11547 .register_rest_endpoint(
11548 "POST".into(),
11549 vec![PathSegment::Literal("users".into())],
11550 post_tx,
11551 )
11552 .await;
11553
11554 let drain = tokio::spawn(async move {
11556 let mut get_rx = get_rx;
11557 while get_rx.recv().await.is_some() {}
11558 });
11559
11560 registry.unregister_rest_endpoint("GET", "/users").await;
11562 drop(drain);
11563
11564 let post_handle = spawn_responder(post_rx, 201, "create".into());
11565
11566 let client = reqwest::Client::new();
11567 let resp = client
11569 .post(format!("http://127.0.0.1:{port}/users"))
11570 .send()
11571 .await
11572 .unwrap();
11573 assert_eq!(resp.status().as_u16(), 201);
11574 assert_eq!(resp.text().await.unwrap(), "create");
11575
11576 let _ = post_handle.await;
11577 }
11578
11579 #[tokio::test]
11580 async fn dispatch_exact_legacy_beats_rest_template() {
11581 let (port, registry) = spawn_test_server().await;
11586
11587 let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11589 registry
11590 .register_api_route("/api/users".into(), exact_tx)
11591 .await;
11592 let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
11593
11594 let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11596 registry
11597 .register_rest_endpoint(
11598 "GET".into(),
11599 vec![
11600 PathSegment::Literal("api".into()),
11601 PathSegment::Param("resource".into()),
11602 ],
11603 tpl_tx,
11604 )
11605 .await;
11606 let _tpl_drain = tokio::spawn(async move {
11612 let mut tpl_rx = tpl_rx;
11613 if let Some(env) = tpl_rx.recv().await {
11614 let _ = env.reply_tx.send(HttpReply {
11615 status: 200,
11616 headers: vec![],
11617 body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
11618 });
11619 }
11620 });
11621
11622 let client = reqwest::Client::new();
11623 let resp = client
11624 .get(format!("http://127.0.0.1:{port}/api/users"))
11625 .send()
11626 .await
11627 .unwrap();
11628 assert_eq!(resp.status().as_u16(), 200);
11629 assert_eq!(resp.text().await.unwrap(), "exact");
11631
11632 let _ = exact_handle.await;
11633 }
11634
11635 #[tokio::test]
11636 async fn ambiguous_rest_templates_return_500_not_silent_404() {
11637 let (port, registry) = spawn_test_server().await;
11642
11643 let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11644 registry
11645 .register_rest_endpoint(
11646 "GET".into(),
11647 vec![
11648 PathSegment::Literal("users".into()),
11649 PathSegment::Param("id".into()),
11650 ],
11651 a_tx,
11652 )
11653 .await;
11654
11655 let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11656 registry
11657 .register_rest_endpoint(
11658 "GET".into(),
11659 vec![
11660 PathSegment::Literal("users".into()),
11661 PathSegment::Param("name".into()),
11662 ],
11663 b_tx,
11664 )
11665 .await;
11666
11667 let client = reqwest::Client::new();
11668 let resp = client
11669 .get(format!("http://127.0.0.1:{port}/users/42"))
11670 .send()
11671 .await
11672 .unwrap();
11673 assert_eq!(resp.status().as_u16(), 500);
11675 }
11676
11677 #[test]
11678 fn from_uri_round_trips_templated_path_with_http_method() {
11679 use crate::UriConfig;
11685 let cfg =
11686 HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
11687 assert_eq!(cfg.host, "0.0.0.0");
11688 assert_eq!(cfg.port, 8080);
11689 assert_eq!(cfg.path, "/users/{id}");
11690 assert_eq!(cfg.method.as_deref(), Some("GET"));
11691
11692 let cfg_lc =
11694 HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
11695 assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
11696 assert_eq!(cfg_lc.path, "/orders");
11697 }
11698
11699 #[test]
11704 fn type_conversion_failed_maps_to_400() {
11705 let reply = pipeline_error_to_reply(
11706 CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
11707 "/api/users",
11708 );
11709 assert_eq!(reply.status, 400);
11710 let json_ct = reply
11712 .headers
11713 .iter()
11714 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11715 .count();
11716 assert_eq!(json_ct, 1);
11717 let body = match &reply.body {
11719 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11720 _ => panic!("expected bytes body"),
11721 };
11722 let parsed: serde_json::Value =
11723 serde_json::from_str(&body).expect("body must be valid JSON");
11724 assert_eq!(parsed["error"], "bad_request");
11725 assert_eq!(parsed["message"], "invalid JSON at line 1");
11726 }
11727
11728 #[test]
11729 fn other_error_still_maps_to_500() {
11730 let reply =
11731 pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
11732 assert_eq!(reply.status, 500);
11733 }
11734
11735 #[test]
11736 fn unauthenticated_maps_to_401() {
11737 let reply = pipeline_error_to_reply(
11738 CamelError::Unauthenticated("no token".to_string()),
11739 "/api/users",
11740 );
11741 assert_eq!(reply.status, 401);
11742 }
11743
11744 #[test]
11745 fn unauthorized_maps_to_403() {
11746 let reply = pipeline_error_to_reply(
11747 CamelError::Unauthorized("forbidden".to_string()),
11748 "/api/users",
11749 );
11750 assert_eq!(reply.status, 403);
11751 }
11752
11753 #[test]
11754 fn validation_error_maps_to_400() {
11755 let reply = pipeline_error_to_reply(
11756 CamelError::ValidationError("body does not match schema".to_string()),
11757 "/api/users",
11758 );
11759 assert_eq!(reply.status, 400);
11760 let json_ct = reply
11761 .headers
11762 .iter()
11763 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11764 .count();
11765 assert_eq!(json_ct, 1);
11766 let body = match &reply.body {
11767 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11768 _ => panic!("expected bytes body"),
11769 };
11770 let parsed: serde_json::Value =
11771 serde_json::from_str(&body).expect("body must be valid JSON");
11772 assert_eq!(parsed["error"], "validation_error");
11773 assert_eq!(parsed["message"], "body does not match schema");
11774 }
11775
11776 #[test]
11781 fn finalizer_maps_unsupported_media_type() {
11782 let reply = pipeline_error_to_reply(
11783 CamelError::UnsupportedMediaType {
11784 consumed: "text/plain".to_string(),
11785 declared: "application/json".to_string(),
11786 },
11787 "/x",
11788 );
11789 assert_eq!(reply.status, 415);
11790 let json_ct = reply
11791 .headers
11792 .iter()
11793 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11794 .count();
11795 assert_eq!(json_ct, 1);
11796 let body = match &reply.body {
11797 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11798 _ => panic!("expected bytes body"),
11799 };
11800 let parsed: serde_json::Value =
11801 serde_json::from_str(&body).expect("body must be valid JSON");
11802 assert_eq!(parsed["error"], "unsupported_media_type");
11803 assert_eq!(
11804 parsed["message"],
11805 "consumed text/plain, declared application/json"
11806 );
11807 }
11808
11809 #[test]
11810 fn finalizer_maps_not_acceptable() {
11811 let reply = pipeline_error_to_reply(
11812 CamelError::NotAcceptable {
11813 accept: "application/xml".to_string(),
11814 produced: "application/json".to_string(),
11815 },
11816 "/x",
11817 );
11818 assert_eq!(reply.status, 406);
11819 let json_ct = reply
11820 .headers
11821 .iter()
11822 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11823 .count();
11824 assert_eq!(json_ct, 1);
11825 let body = match &reply.body {
11826 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11827 _ => panic!("expected bytes body"),
11828 };
11829 let parsed: serde_json::Value =
11830 serde_json::from_str(&body).expect("body must be valid JSON");
11831 assert_eq!(parsed["error"], "not_acceptable");
11832 assert_eq!(
11833 parsed["message"],
11834 "accept application/xml, produced application/json"
11835 );
11836 }
11837
11838 #[test]
11839 fn json_error_reply_preserves_empty_message() {
11840 let reply = json_error_reply(400, "bad_request", "".to_string());
11841 assert_eq!(reply.status, 400);
11842 let json_ct = reply
11843 .headers
11844 .iter()
11845 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11846 .count();
11847 assert_eq!(json_ct, 1);
11848 let body = match &reply.body {
11849 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11850 _ => panic!("expected bytes body"),
11851 };
11852 let parsed: serde_json::Value =
11853 serde_json::from_str(&body).expect("body must be valid JSON");
11854 assert_eq!(parsed["error"], "bad_request");
11855 assert_eq!(parsed["message"], "");
11856 }
11857
11858 #[test]
11859 fn https_consumer_without_tls_cert_errors() {
11860 let endpoint = HttpEndpoint {
11861 uri: "https://0.0.0.0:8443/api".to_string(),
11862 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
11863 server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
11864 client: reqwest::Client::new(),
11865 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
11866 PINNED_CLIENT_TTL,
11867 PINNED_CLIENT_MAX_ENTRIES,
11868 )),
11869 http_config: HttpConfig::default(),
11870 };
11871 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
11872 let result = endpoint.create_consumer(rt);
11873 assert!(result.is_err(), "expected error for https without tls cert");
11874 if let Err(e) = result {
11875 let msg = e.to_string();
11876 assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
11877 }
11878 }
11879
11880 #[test]
11881 fn http_consumer_with_tls_config_errors() {
11882 let endpoint = HttpEndpoint {
11883 uri: "http://0.0.0.0:8080/api".to_string(),
11884 config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
11885 server_config: HttpServerConfig::from_uri(
11886 "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
11887 )
11888 .unwrap(),
11889 client: reqwest::Client::new(),
11890 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
11891 PINNED_CLIENT_TTL,
11892 PINNED_CLIENT_MAX_ENTRIES,
11893 )),
11894 http_config: HttpConfig::default(),
11895 };
11896 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
11897 let result = endpoint.create_consumer(rt);
11898 assert!(result.is_err(), "expected error for http with tls config");
11899 if let Err(e) = result {
11900 let msg = e.to_string();
11901 assert!(msg.contains("https"), "error must mention https: {msg}");
11902 }
11903 }
11904
11905 #[test]
11906 fn https_consumer_with_partial_tls_cert_only_errors() {
11907 let server_config =
11910 HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
11911 assert!(
11912 server_config.tls_config.is_none(),
11913 "partial tlsCert must not create ServerTlsConfig"
11914 );
11915 let endpoint = HttpEndpoint {
11916 uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
11917 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
11918 .unwrap(),
11919 server_config,
11920 client: reqwest::Client::new(),
11921 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
11922 PINNED_CLIENT_TTL,
11923 PINNED_CLIENT_MAX_ENTRIES,
11924 )),
11925 http_config: HttpConfig::default(),
11926 };
11927 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
11928 let result = endpoint.create_consumer(rt);
11929 assert!(
11930 result.is_err(),
11931 "must error: https:// requires both tlsCert and tlsKey"
11932 );
11933 }
11934
11935 #[test]
11936 fn load_tls_config_parses_valid_pem() {
11937 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
11939 use camel_component_api::test_support::tls;
11940 let (_, cert_pem, key_pem) = tls::gen_server_cert();
11941 let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
11942 let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
11943
11944 let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
11945 assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
11946 }
11947
11948 #[tokio::test(flavor = "multi_thread")]
11949 #[allow(clippy::await_holding_lock)]
11950 async fn consumer_tls_handshake_roundtrip() {
11951 use camel_component_api::test_support::tls;
11952 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
11953
11954 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
11956
11957 let _guard = lock_registry_test_mutex();
11959
11960 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
11962 let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
11963 let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
11964 let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
11965
11966 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11968 let port = probe.local_addr().unwrap().port();
11969 drop(probe);
11970
11971 ServerRegistry::reset();
11972
11973 let component = HttpComponent::new();
11975 let endpoint_ctx = NoOpComponentContext;
11976 let uri = format!(
11977 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
11978 cert_path.to_string_lossy(),
11979 key_path.to_string_lossy(),
11980 );
11981 let endpoint = component
11982 .create_endpoint(&uri, &endpoint_ctx)
11983 .expect("create TLS endpoint");
11984 let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
11985
11986 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
11988 let token = tokio_util::sync::CancellationToken::new();
11989 let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
11990 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
11991
11992 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
11994
11995 let ca_bytes = std::fs::read(&ca_path).unwrap();
11997 let client = reqwest::Client::builder()
11998 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
11999 .build()
12000 .unwrap();
12001
12002 let send_fut = client
12003 .post(format!("https://localhost:{port}/test"))
12004 .body("ping")
12005 .send();
12006
12007 let (http_result, _) = tokio::join!(send_fut, async {
12009 if let Some(mut envelope) = rx.recv().await {
12010 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
12011 if let Some(reply_tx) = envelope.reply_tx {
12012 let _ = reply_tx.send(Ok(envelope.exchange));
12013 }
12014 }
12015 });
12016
12017 let resp = http_result.expect("TLS handshake + request must succeed");
12018
12019 assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
12020 let body = resp.text().await.unwrap();
12021 assert_eq!(body, "pong");
12022
12023 token.cancel();
12024 }
12025
12026 #[tokio::test(flavor = "multi_thread")]
12027 #[allow(clippy::await_holding_lock)]
12028 async fn consumer_tls_rejects_client_without_ca() {
12029 use camel_component_api::test_support::tls;
12030 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
12031
12032 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
12033
12034 let _guard = lock_registry_test_mutex();
12036
12037 let (_, cert_pem, key_pem) = tls::gen_server_cert();
12038 let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
12039 let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
12040
12041 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12042 let port = probe.local_addr().unwrap().port();
12043 drop(probe);
12044
12045 ServerRegistry::reset();
12046
12047 let component = HttpComponent::new();
12049 let endpoint_ctx = NoOpComponentContext;
12050 let uri = format!(
12051 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
12052 cert_path.to_string_lossy(),
12053 key_path.to_string_lossy(),
12054 );
12055 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
12056 let mut consumer = endpoint.create_consumer(rt()).unwrap();
12057 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
12058 let token = tokio_util::sync::CancellationToken::new();
12059 let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
12060 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
12061
12062 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
12063
12064 let client = reqwest::Client::builder().build().unwrap();
12066
12067 let result = client
12068 .get(format!("https://localhost:{port}/test"))
12069 .send()
12070 .await;
12071
12072 assert!(
12073 result.is_err(),
12074 "must reject without CA — proves real verification"
12075 );
12076
12077 token.cancel();
12078 }
12079
12080 #[test]
12081 fn server_config_partial_tls_cert_without_key() {
12082 let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
12084 assert!(cfg.tls_config.is_none());
12086 }
12087
12088 #[test]
12089 fn endpoint_uri_options_count_parity() {
12090 assert_eq!(
12092 HttpEndpointConfig::uri_options().len(),
12093 23,
12094 "HttpEndpointUriConfig #[uri_param] count drifted from parser"
12095 );
12096 }
12097
12098 fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
12099 pairs
12100 .iter()
12101 .map(|(k, v)| {
12102 (
12103 (*k).to_string(),
12104 serde_json::Value::String((*v).to_string()),
12105 )
12106 })
12107 .collect()
12108 }
12109
12110 #[test]
12111 fn response_emits_cache_control_via_pragma_warning() {
12112 let headers = make_headers(&[
12113 ("Cache-Control", "public, max-age=3600"),
12114 ("Via", "1.1 myproxy"),
12115 ("Pragma", "no-cache"),
12116 ("Warning", "199 misc"),
12117 ]);
12118 let selected = select_response_headers(&headers, None, None);
12119 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12120 for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
12121 assert!(
12122 names.contains(&expected),
12123 "{expected} should pass through to the response"
12124 );
12125 }
12126 }
12127
12128 #[test]
12129 fn response_excludes_request_only_and_server_owned() {
12130 let headers = make_headers(&[
12131 ("User-Agent", "x"),
12132 ("Accept", "*/*"),
12133 ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
12134 ]);
12135 let selected = select_response_headers(&headers, None, None);
12136 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12137 for excluded in ["User-Agent", "Accept", "Date"] {
12138 assert!(
12139 !names.contains(&excluded),
12140 "{excluded} should NOT appear in the response"
12141 );
12142 }
12143 }
12144
12145 #[test]
12146 fn response_re_derives_content_type() {
12147 let headers = make_headers(&[("Content-Type", "text/plain")]);
12148 let selected = select_response_headers(&headers, Some("application/json".into()), None);
12149 let ct_entries: Vec<&str> = selected
12150 .iter()
12151 .filter(|(k, _)| k == "Content-Type")
12152 .map(|(_, v)| v.as_str())
12153 .collect();
12154 assert_eq!(
12155 ct_entries,
12156 ["application/json"],
12157 "exactly one Content-Type entry, re-derived from user_content_type"
12158 );
12159 }
12160
12161 #[test]
12162 fn response_excludes_camel_headers() {
12163 let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
12164 let selected = select_response_headers(&headers, None, None);
12165 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12166 assert!(
12167 !names.contains(&"CamelHttpPath"),
12168 "Camel-namespace headers must be excluded"
12169 );
12170 assert!(
12171 names.contains(&"Cache-Control"),
12172 "Cache-Control must pass through"
12173 );
12174 }
12175
12176 #[test]
12177 fn response_stringifies_scalar_header_values() {
12178 let mut headers = make_headers(&[("X-Label", "keep")]);
12179 headers.insert("X-Retries".to_string(), serde_json::json!(3));
12180 headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
12181 headers.insert("X-Enabled".to_string(), serde_json::json!(true));
12182 let selected = select_response_headers(&headers, None, None);
12183 let get = |name: &str| -> Option<&str> {
12184 selected
12185 .iter()
12186 .find(|(k, _)| k == name)
12187 .map(|(_, v)| v.as_str())
12188 };
12189 assert_eq!(
12190 get("X-Retries"),
12191 Some("3"),
12192 "integer header must be stringified"
12193 );
12194 assert_eq!(
12195 get("X-Ratio"),
12196 Some("3.5"),
12197 "float header must be stringified"
12198 );
12199 assert_eq!(
12200 get("X-Enabled"),
12201 Some("true"),
12202 "bool header must be stringified"
12203 );
12204 assert_eq!(
12205 get("X-Label"),
12206 Some("keep"),
12207 "string header must pass through"
12208 );
12209 }
12210
12211 #[test]
12212 fn response_drops_null_and_structured_header_values() {
12213 let mut headers = make_headers(&[("X-Keep", "yes")]);
12214 headers.insert("X-Null".to_string(), serde_json::Value::Null);
12215 headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
12216 headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
12217 let selected = select_response_headers(&headers, None, None);
12218 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12219 for dropped in ["X-Null", "X-Obj", "X-Arr"] {
12220 assert!(
12221 !names.contains(&dropped),
12222 "{dropped} must not be emitted: no single-value form"
12223 );
12224 }
12225 assert!(names.contains(&"X-Keep"), "scalar headers must survive");
12226 }
12227
12228 #[test]
12229 fn response_stringifies_scalars_despite_excluded_names() {
12230 let mut headers = HashMap::new();
12234 headers.insert("Content-Length".to_string(), serde_json::json!(999));
12235 headers.insert("Date".to_string(), serde_json::json!(12345));
12236 let selected = select_response_headers(&headers, None, None);
12237 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12238 assert!(
12239 !names.contains(&"Content-Length"),
12240 "content-length is re-derived by the server"
12241 );
12242 assert!(!names.contains(&"Date"), "date is server-owned");
12243 }
12244
12245 #[test]
12246 fn outbound_stringifies_scalar_header_values() {
12247 let mut headers = make_headers(&[("X-Label", "keep")]);
12248 headers.insert("X-Retries".to_string(), serde_json::json!(3));
12249 headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
12250 headers.insert("X-Enabled".to_string(), serde_json::json!(true));
12251 let outbound = select_outbound_headers(&headers, &[], &[]);
12252 let get = |name: &str| -> Option<String> {
12254 outbound
12255 .accepted
12256 .iter()
12257 .find(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12258 .map(|(_, v)| v.to_str().unwrap().to_string())
12259 };
12260 assert_eq!(
12261 get("X-Retries").as_deref(),
12262 Some("3"),
12263 "integer header must be stringified"
12264 );
12265 assert_eq!(
12266 get("X-Ratio").as_deref(),
12267 Some("3.5"),
12268 "float header must be stringified"
12269 );
12270 assert_eq!(
12271 get("X-Enabled").as_deref(),
12272 Some("true"),
12273 "bool header must be stringified"
12274 );
12275 assert_eq!(
12276 get("X-Label").as_deref(),
12277 Some("keep"),
12278 "string header must pass through"
12279 );
12280 assert!(outbound.drops.is_empty(), "scalar headers must not drop");
12281 }
12282
12283 #[test]
12284 fn outbound_drops_null_and_structured_header_values() {
12285 let mut headers = make_headers(&[("X-Keep", "yes")]);
12286 headers.insert("X-Null".to_string(), serde_json::Value::Null);
12287 headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
12288 headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
12289 let outbound = select_outbound_headers(&headers, &[], &[]);
12290 let has = |name: &str| {
12291 outbound
12292 .accepted
12293 .iter()
12294 .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12295 };
12296 assert!(has("X-Keep"), "scalar headers must survive");
12297 for (name, kind) in [("X-Null", "null"), ("X-Obj", "object"), ("X-Arr", "array")] {
12298 let dropped = outbound
12299 .drops
12300 .iter()
12301 .find(|d| d.name == name)
12302 .unwrap_or_else(|| panic!("{name} must have a drop record: {:?}", outbound.drops));
12303 assert_eq!(
12304 dropped.reason, "no scalar string form",
12305 "{name} drop reason must name the value kind absence"
12306 );
12307 assert_eq!(dropped.value_kind, Some(kind), "{name} kind recorded");
12308 }
12309 }
12310
12311 #[test]
12312 fn outbound_stringifies_scalars_despite_excluded_names() {
12313 let mut headers = HashMap::new();
12317 headers.insert("Transfer-Encoding".to_string(), serde_json::json!(7));
12318 headers.insert("Host".to_string(), serde_json::json!(12345));
12319 headers.insert("X-Ok".to_string(), serde_json::json!(7));
12320 let outbound = select_outbound_headers(&headers, &[], &[]);
12321 let has = |name: &str| {
12322 outbound
12323 .accepted
12324 .iter()
12325 .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12326 };
12327 assert!(
12328 !has("Transfer-Encoding"),
12329 "hop-by-hop header must stay excluded"
12330 );
12331 assert!(!has("Host"), "host is destination-derived");
12332 assert!(has("X-Ok"), "non-excluded scalar must be stringified");
12333 assert!(
12334 outbound
12335 .drops
12336 .iter()
12337 .any(|d| d.name == "Transfer-Encoding" && d.reason == "outbound emission policy"),
12338 "policy drop must be recorded before coercion"
12339 );
12340 }
12341
12342 #[test]
12343 fn outbound_drops_invalid_names_values_and_skip_config() {
12344 let mut headers = make_headers(&[("X-Good", "fine")]);
12345 headers.insert("X Bad Name".to_string(), serde_json::json!("v"));
12346 headers.insert(
12347 "X-Control-Value".to_string(),
12348 serde_json::json!("line1\nline2"),
12349 );
12350 headers.insert("X-Secret".to_string(), serde_json::json!("s3cr3t"));
12351 headers.insert("CamelHttpQuery".to_string(), serde_json::json!("q=1"));
12352 let skip = vec!["x-secret".to_string()];
12353 let outbound = select_outbound_headers(&headers, &skip, &[]);
12354 let has = |name: &str| {
12355 outbound
12356 .accepted
12357 .iter()
12358 .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12359 };
12360 assert!(has("X-Good"), "valid header must survive");
12361 assert!(!has("X Bad Name"), "invalid header name must drop");
12362 assert!(!has("X-Control-Value"), "control-char value must drop");
12363 assert!(!has("X-Secret"), "skipped header must drop");
12364 assert!(!has("CamelHttpQuery"), "Camel-namespace header must drop");
12365 let reason = |n: &str| {
12366 outbound
12367 .drops
12368 .iter()
12369 .find(|d| d.name == n)
12370 .map(|d| d.reason)
12371 };
12372 assert_eq!(reason("X Bad Name"), Some("invalid header name"));
12373 assert_eq!(reason("X-Control-Value"), Some("invalid header value"));
12374 assert_eq!(reason("X-Secret"), Some("skip_request_headers"));
12375 assert_eq!(reason("CamelHttpQuery"), Some("Camel namespace"));
12376 }
12377
12378 #[test]
12379 fn constructed_header_invalid_value_returns_drop_record() {
12380 let result = constructed_header("user-agent", "bad\r\ns3nt1nel");
12381 let Err(record) = result else {
12382 panic!("invalid value must produce a drop record");
12383 };
12384 assert_eq!(record.reason, "invalid header value");
12385 assert_eq!(record.name, "user-agent");
12386 assert!(record.value_kind.is_none());
12387 let debug = format!("{record:?}");
12388 assert!(
12389 !debug.contains("bad\r\n") && !debug.contains("s3nt1nel"),
12390 "drop record debug must not leak the value"
12391 );
12392 }
12393
12394 #[test]
12395 fn constructed_header_invalid_name_returns_drop_record() {
12396 let result = constructed_header("bad name", "ok");
12397 let Err(record) = result else {
12398 panic!("invalid name must produce a drop record");
12399 };
12400 assert_eq!(record.reason, "invalid header name");
12401 assert_eq!(record.name, "bad name");
12402 let debug = format!("{record:?}");
12403 assert!(
12404 !debug.contains("ok"),
12405 "drop record debug must not leak the value"
12406 );
12407 }
12408
12409 #[test]
12410 fn constructed_header_valid_pair_roundtrip() {
12411 let result = constructed_header("authorization", "Bearer abc123");
12412 let Ok((name, val)) = result else {
12413 panic!("valid pair must construct");
12414 };
12415 assert_eq!(name.as_str(), "authorization");
12416 let Ok(roundtrip) = val.to_str() else {
12417 panic!("valid value must roundtrip to str");
12418 };
12419 assert_eq!(roundtrip, "Bearer abc123");
12420 }
12421
12422 async fn start_host_capturing_destination() -> (
12434 String,
12435 Arc<std::sync::Mutex<Option<(String, String)>>>,
12436 tokio::task::JoinHandle<()>,
12437 ) {
12438 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12439 let port = listener.local_addr().unwrap().port();
12440 let url = format!("http://127.0.0.1:{port}");
12441 let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
12442 Arc::new(std::sync::Mutex::new(None));
12443 let captured_clone = Arc::clone(&captured);
12444 let handle = tokio::spawn(async move {
12445 use tokio::io::{AsyncReadExt, AsyncWriteExt};
12446 if let Ok((mut stream, _)) = listener.accept().await {
12447 let mut buf = vec![0u8; 16384];
12448 let n = stream.read(&mut buf).await.unwrap_or(0);
12449 let request = String::from_utf8_lossy(&buf[..n]).to_string();
12450 if request.contains("\r\n\r\n") {
12451 let request_line = request.lines().next().unwrap_or("").to_string();
12452 let host_value = request
12453 .lines()
12454 .find(|l| l.to_lowercase().starts_with("host:"))
12455 .and_then(|l| l.split_once(':'))
12456 .map(|(_, v)| v.trim().to_string())
12457 .unwrap_or_default();
12458 *captured_clone.lock().unwrap() = Some((host_value, request_line));
12459 }
12460 let body = r#"{"echo":"ok"}"#;
12461 let resp = format!(
12462 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
12463 body.len(),
12464 body
12465 );
12466 let _ = stream.write_all(resp.as_bytes()).await;
12467 }
12468 });
12469 (url, captured, handle)
12470 }
12471
12472 #[tokio::test]
12477 async fn bridge_proxy_outbound_host_matches_destination() {
12478 use tower::ServiceExt;
12479
12480 let (url, captured, _handle) = start_host_capturing_destination().await;
12481 let expected_host = url.strip_prefix("http://").unwrap();
12484
12485 let ctx = test_producer_ctx();
12486 let component = HttpComponent::new();
12487 let endpoint_ctx = NoOpComponentContext;
12488 let endpoint = component
12489 .create_endpoint(
12490 &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
12491 &endpoint_ctx,
12492 )
12493 .unwrap();
12494 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
12495
12496 let mut exchange = Exchange::new(Message::default());
12499 exchange.input.set_header("Host", "localhost");
12500 exchange.input.set_header("CamelHttpPath", "/foo");
12501
12502 let result = producer.oneshot(exchange).await;
12503 assert!(result.is_ok(), "producer call failed: {:?}", result);
12504
12505 tokio::time::sleep(Duration::from_millis(100)).await;
12506 let (host_value, request_line) = captured
12507 .lock()
12508 .unwrap()
12509 .take()
12510 .expect("destination capture mutex empty — producer did not reach the destination");
12511
12512 assert_ne!(
12513 host_value, "localhost",
12514 "bridge producer must not forward the exchange Host: localhost"
12515 );
12516 assert_eq!(
12517 host_value, expected_host,
12518 "Host must be derived from the destination authority (no scheme)"
12519 );
12520 assert!(
12521 !request_line.contains("/foo"),
12522 "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
12523 );
12524 }
12525
12526 #[tokio::test]
12531 async fn bridge_proxy_route_set_response_header_survives() {
12532 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
12533
12534 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12535 let port = listener.local_addr().unwrap().port();
12536 drop(listener);
12537
12538 let component = HttpComponent::new();
12539 let endpoint_ctx = NoOpComponentContext;
12540 let endpoint = component
12541 .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
12542 .unwrap();
12543 let mut consumer = endpoint.create_consumer(rt()).unwrap();
12544
12545 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
12546 let token = tokio_util::sync::CancellationToken::new();
12547 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
12548
12549 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
12550 tokio::time::sleep(Duration::from_millis(50)).await;
12551
12552 let client = reqwest::Client::new();
12553 let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
12554
12555 let (http_result, _) = tokio::join!(send_fut, async {
12559 if let Some(mut envelope) = rx.recv().await {
12560 envelope
12561 .exchange
12562 .input
12563 .set_header("Cache-Control", "public, max-age=3600");
12564 if let Some(reply_tx) = envelope.reply_tx {
12565 let _ = reply_tx.send(Ok(envelope.exchange));
12566 }
12567 }
12568 });
12569
12570 let resp = http_result.unwrap();
12571 assert_eq!(resp.status().as_u16(), 200);
12572
12573 let cache_control = resp.headers().get("cache-control");
12574 assert!(
12575 cache_control.is_some(),
12576 "Cache-Control header must survive to the wire response"
12577 );
12578 assert_eq!(
12579 cache_control.unwrap().to_str().unwrap(),
12580 "public, max-age=3600"
12581 );
12582
12583 token.cancel();
12584 }
12585
12586 use camel_api::security_policy::CredentialSource;
12605 use camel_auth::credential_source::extract_token_from_exchange;
12606 use camel_auth::native_auth::NativeCredentialStore;
12607 use camel_auth::{StaticTokenAuthenticator, TokenAuthenticator};
12608
12609 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 {
12617 let mut msg = Message::default();
12618 msg.set_header(
12619 "CamelHttpMethod",
12620 serde_json::Value::String(envelope.method.clone()),
12621 );
12622 msg.set_header(
12623 "CamelHttpPath",
12624 serde_json::Value::String(envelope.path.clone()),
12625 );
12626 msg.set_header(
12627 "CamelHttpQuery",
12628 serde_json::Value::String(envelope.query.clone()),
12629 );
12630 for (k, v) in &envelope.headers {
12631 if let Ok(val_str) = v.to_str() {
12632 msg.set_header(
12633 title_case_header(k.as_str()),
12634 serde_json::Value::String(val_str.to_string()),
12635 );
12636 }
12637 }
12638 Exchange::new(msg)
12639 }
12640
12641 async fn spawn_failing_auth_route(
12648 registry: &HttpRouteRegistry,
12649 path: &str,
12650 sources: Vec<CredentialSource>,
12651 ) {
12652 let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
12653 NativeCredentialStore::try_new(vec![]).unwrap(),
12654 ));
12655 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12656 registry.register_api_route(path.to_string(), tx).await;
12657 let path_owned = path.to_string();
12658 tokio::spawn(async move {
12659 while let Some(envelope) = rx.recv().await {
12660 let exchange = envelope_to_exchange(&envelope);
12661 let reply_tx = envelope.reply_tx;
12662 let result: Result<(), CamelError> = async {
12663 let token = extract_token_from_exchange(&exchange, &sources)
12664 .map(|extracted| extracted.token)
12665 .ok_or_else(|| {
12666 CamelError::Unauthenticated("no credential in any source".into())
12667 })?;
12668 authenticator.authenticate_bearer(&token).await?;
12669 Ok(())
12670 }
12671 .await;
12672 let reply = match result {
12673 Ok(()) => HttpReply {
12674 status: 200,
12675 headers: vec![],
12676 body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
12677 },
12678 Err(e) => pipeline_error_to_reply(e, &path_owned),
12679 };
12680 let _ = reply_tx.send(reply);
12681 }
12682 });
12683 }
12684
12685 fn captured_logs_contain(needle: &str) -> bool {
12689 let buf = tracing_test::internal::global_buf().lock().unwrap();
12690 String::from_utf8_lossy(&buf).contains(needle)
12691 }
12692
12693 #[tracing_test::traced_test]
12694 #[tokio::test]
12695 async fn error_context_redacts_query_sentinel() {
12696 let (port, registry) = spawn_test_server().await;
12697 spawn_failing_auth_route(
12698 ®istry,
12699 "/secure-query",
12700 vec![CredentialSource::QueryParam {
12701 param: "token".to_string(),
12702 }],
12703 )
12704 .await;
12705
12706 let client = reqwest::Client::new();
12707 let resp = client
12708 .get(format!(
12710 "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
12711 ))
12712 .send()
12713 .await
12714 .unwrap();
12715
12716 assert_eq!(resp.status().as_u16(), 401);
12717 let body = resp.text().await.unwrap();
12718 assert_eq!(body, "Unauthorized");
12719 assert!(
12720 !body.contains(SENTINEL_QRY_42),
12721 "reply body must not contain the query credential"
12722 );
12723 assert!(
12724 !captured_logs_contain(SENTINEL_QRY_42),
12725 "no tracing record during request handling may render the query credential"
12726 );
12727 assert!(
12731 captured_logs_contain("Authentication failed"),
12732 "positive control: the failed-auth warn! must be captured by the test subscriber"
12733 );
12734 }
12735
12736 #[tracing_test::traced_test]
12737 #[tokio::test]
12738 async fn error_context_redacts_cookie_sentinel() {
12739 let (port, registry) = spawn_test_server().await;
12740 spawn_failing_auth_route(
12741 ®istry,
12742 "/secure-cookie",
12743 vec![CredentialSource::Cookie {
12744 name: "session".to_string(),
12745 }],
12746 )
12747 .await;
12748
12749 let client = reqwest::Client::new();
12750 let resp = client
12751 .get(format!("http://127.0.0.1:{port}/secure-cookie"))
12752 .header("Cookie", format!("session={SENTINEL_CKY_7}"))
12753 .send()
12754 .await
12755 .unwrap();
12756
12757 assert_eq!(resp.status().as_u16(), 401);
12758 let body = resp.text().await.unwrap();
12759 assert_eq!(body, "Unauthorized");
12760 assert!(
12761 !body.contains(SENTINEL_CKY_7),
12762 "reply body must not contain the cookie credential"
12763 );
12764 assert!(
12765 !captured_logs_contain(SENTINEL_CKY_7),
12766 "no tracing record during request handling may render the cookie credential"
12767 );
12768 }
12769
12770 #[tracing_test::traced_test]
12771 #[tokio::test]
12772 async fn error_reply_no_credential_value() {
12773 let (port, registry) = spawn_test_server().await;
12774 spawn_failing_auth_route(
12775 ®istry,
12776 "/secure-bad",
12777 vec![CredentialSource::Cookie {
12778 name: "session".to_string(),
12779 }],
12780 )
12781 .await;
12782
12783 let client = reqwest::Client::new();
12784 let resp = client
12785 .get(format!("http://127.0.0.1:{port}/secure-bad"))
12786 .header("Cookie", format!("session={SENTINEL_BAD_1}"))
12787 .send()
12788 .await
12789 .unwrap();
12790
12791 assert_eq!(resp.status().as_u16(), 401);
12792 let body = resp.text().await.unwrap();
12793 assert_eq!(body, "Unauthorized");
12794 assert!(
12795 !body.contains(SENTINEL_BAD_1),
12796 "reply body must not contain the credential value"
12797 );
12798 assert!(
12799 !captured_logs_contain(SENTINEL_BAD_1),
12800 "error logs must not render the credential value"
12801 );
12802 }
12803
12804 async fn spawn_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
12818 use tokio::io::AsyncWriteExt;
12819
12820 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
12821 .await
12822 .expect("bind ephemeral 127.0.0.1 listener");
12823 let port = listener.local_addr().expect("local addr").port();
12824 let base_url = format!("http://localhost:{port}");
12825 let handle = tokio::spawn(async move {
12826 while let Ok((mut conn, _)) = listener.accept().await {
12827 let _ = conn
12828 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
12829 .await;
12830 let _ = conn.shutdown().await;
12831 }
12832 });
12833 (base_url, handle)
12834 }
12835
12836 async fn spawn_tls_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
12843 use tokio::io::AsyncWriteExt;
12844
12845 let (_ca_pem, cert_pem, key_pem) =
12846 camel_component_api::test_support::tls::gen_server_cert();
12847 let certs: Vec<_> = rustls_pemfile::certs(&mut cert_pem.as_bytes())
12848 .collect::<Result<_, _>>()
12849 .expect("parse server cert pem");
12850 let key = rustls_pemfile::private_key(&mut key_pem.as_bytes())
12851 .expect("parse server key pem")
12852 .expect("server key present");
12853 let provider =
12856 std::sync::Arc::new(tokio_rustls::rustls::crypto::aws_lc_rs::default_provider());
12857 let tls_cfg = tokio_rustls::rustls::ServerConfig::builder_with_provider(provider)
12858 .with_safe_default_protocol_versions()
12859 .expect("safe default protocol versions")
12860 .with_no_client_auth()
12861 .with_single_cert(certs, key)
12862 .expect("build rustls server config");
12863 let acceptor = tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(tls_cfg));
12864
12865 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
12866 .await
12867 .expect("bind ephemeral 127.0.0.1 listener");
12868 let port = listener.local_addr().expect("local addr").port();
12869 let base_url = format!("https://localhost:{port}");
12870 let handle = tokio::spawn(async move {
12871 while let Ok((conn, _)) = listener.accept().await {
12872 let acceptor = acceptor.clone();
12873 tokio::spawn(async move {
12874 if let Ok(mut tls) = acceptor.accept(conn).await {
12875 let _ = tls
12876 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
12877 .await;
12878 let _ = tls.shutdown().await;
12879 }
12880 });
12881 }
12882 });
12883 (base_url, handle)
12884 }
12885
12886 fn responder_port(base_url: &str) -> u16 {
12890 url::Url::parse(base_url)
12891 .expect("responder base URL parses")
12892 .port()
12893 .expect("responder base URL carries an explicit port")
12894 }
12895
12896 fn endpoint_with_shared_cache(
12901 base_url: &str,
12902 pinned_cache: &Arc<PinnedClientCache>,
12903 ) -> HttpEndpoint {
12904 let uri = format!("{base_url}?allowInternal=true");
12905 HttpEndpoint {
12906 uri: uri.clone(),
12907 config: HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses"),
12908 server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
12909 client: reqwest::Client::new(),
12910 pinned_cache: Arc::clone(pinned_cache),
12911 http_config: HttpConfig::default(),
12912 }
12913 }
12914
12915 #[tokio::test]
12916 async fn producers_share_endpoint_cache() {
12917 use tower::ServiceExt;
12918
12919 let (base_url, _handle) = spawn_multi_accept_200().await;
12920 let pinned_cache = Arc::new(PinnedClientCache::new(
12921 PINNED_CLIENT_TTL,
12922 PINNED_CLIENT_MAX_ENTRIES,
12923 ));
12924
12925 let ctx = test_producer_ctx();
12926 let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
12927 let producer_a = endpoint.create_producer(rt(), &ctx);
12928 let producer_b = endpoint.create_producer(rt(), &ctx);
12929
12930 for producer in [producer_a, producer_b] {
12933 let producer = producer.expect("create producer");
12934 let exchange = Exchange::new(Message::default());
12935 let reply = producer.oneshot(exchange).await;
12936 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
12937 }
12938
12939 assert_eq!(
12940 pinned_cache.build_count(),
12941 1,
12942 "both producers must hit the same shared cache entry; a second \
12943 build means sharing is broken"
12944 );
12945 }
12946
12947 #[tokio::test]
12948 async fn producer_repeated_hostname_requests_build_one_client() {
12949 use tower::ServiceExt;
12950
12951 let (base_url, _handle) = spawn_multi_accept_200().await;
12952 let pinned_cache = Arc::new(PinnedClientCache::new(
12953 PINNED_CLIENT_TTL,
12954 PINNED_CLIENT_MAX_ENTRIES,
12955 ));
12956 let ctx = test_producer_ctx();
12957 let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
12958 let producer = endpoint
12959 .create_producer(rt(), &ctx)
12960 .expect("create producer");
12961
12962 for i in 0..2 {
12965 let exchange = Exchange::new(Message::default());
12966 let reply = producer.clone().oneshot(exchange).await;
12967 assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
12968 }
12969
12970 assert_eq!(
12971 pinned_cache.build_count(),
12972 1,
12973 "repeated hostname requests must reuse the one pinned client; \
12974 0 builds means the producer bypassed the cache, more than 1 \
12975 means the entry was dropped"
12976 );
12977 }
12978
12979 #[tokio::test]
12980 async fn ip_literal_request_never_enters_cache() {
12981 use tower::ServiceExt;
12982
12983 let (base_url, _handle) = spawn_multi_accept_200().await;
12984 let pinned_cache = Arc::new(PinnedClientCache::new(
12985 PINNED_CLIENT_TTL,
12986 PINNED_CLIENT_MAX_ENTRIES,
12987 ));
12988
12989 let ctx = test_producer_ctx();
12990 let destination = format!("http://127.0.0.1:{}/ping", responder_port(&base_url));
12991 let endpoint = endpoint_with_shared_cache(&destination, &pinned_cache);
12992 let producer = endpoint
12993 .create_producer(rt(), &ctx)
12994 .expect("create producer");
12995
12996 let exchange = Exchange::new(Message::default());
12997 let reply = producer.oneshot(exchange).await;
12998 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
12999
13000 assert_eq!(
13001 pinned_cache.build_count(),
13002 0,
13003 "an IP-literal URL must use the shared unpinned client and \
13004 never enter the pinned cache"
13005 );
13006 }
13007
13008 #[tokio::test]
13009 async fn test_component_endpoints_share_pinned_cache() {
13010 use tower::ServiceExt;
13011
13012 let component = HttpComponent::new();
13013 let (base_url, _handle) = spawn_multi_accept_200().await;
13014 let baseline = component.pinned_cache.build_count();
13015
13016 let ctx = test_producer_ctx();
13017 let endpoint_ctx = NoOpComponentContext;
13018 for uri in [
13019 format!("{base_url}/a?allowInternal=true&k=a"),
13020 format!("{base_url}/b?allowInternal=true&k=b"),
13021 ] {
13022 let endpoint = component
13023 .create_endpoint(&uri, &endpoint_ctx)
13024 .expect("create endpoint");
13025 let producer = endpoint
13026 .create_producer(rt(), &ctx)
13027 .expect("create producer");
13028 let exchange = Exchange::new(Message::default());
13029 let reply = producer.oneshot(exchange).await;
13030 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
13031 }
13032
13033 assert_eq!(
13034 component.pinned_cache.build_count() - baseline,
13035 1,
13036 "endpoints created by one component must share its pinned cache; \
13037 0 builds means the endpoints bypassed it, more than 1 means \
13038 per-endpoint caches came back"
13039 );
13040 }
13041
13042 #[tokio::test]
13043 async fn test_dynamic_resolution_sequence_hits_shared_cache() {
13044 use tower::ServiceExt;
13045
13046 let component = HttpComponent::new();
13047 let (base_url, _handle) = spawn_multi_accept_200().await;
13048 let baseline = component.pinned_cache.build_count();
13049
13050 let ctx = test_producer_ctx();
13051 let endpoint_ctx = NoOpComponentContext;
13052 for i in 0..3 {
13053 let endpoint = component
13054 .create_endpoint(
13055 &format!("{base_url}/r{i}?allowInternal=true&k={i}"),
13056 &endpoint_ctx,
13057 )
13058 .expect("create endpoint");
13059 let producer = endpoint
13060 .create_producer(rt(), &ctx)
13061 .expect("create producer");
13062 let exchange = Exchange::new(Message::default());
13063 let reply = producer.oneshot(exchange).await;
13064 assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
13065 }
13066
13067 assert_eq!(
13068 component.pinned_cache.build_count() - baseline,
13069 1,
13070 "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
13071 must reuse the component's one pinned cache entry; 0 builds \
13072 means the endpoints bypassed it, more than 1 means \
13073 per-endpoint caches came back"
13074 );
13075 }
13076
13077 #[tokio::test]
13085 async fn test_https_component_endpoints_share_pinned_cache_behaviorally() {
13086 use tower::ServiceExt;
13087
13088 let http_config = HttpConfig {
13089 tls: Some(crate::config::TlsConfig {
13090 enabled: true,
13091 insecure: true,
13092 ..Default::default()
13093 }),
13094 ..Default::default()
13095 };
13096 let component = HttpsComponent::with_config(http_config);
13097 let (base_url, _handle) = spawn_tls_multi_accept_200().await;
13098 let baseline = component.pinned_cache.build_count();
13099
13100 let ctx = test_producer_ctx();
13101 let endpoint_ctx = NoOpComponentContext;
13102 for uri in [
13103 format!("{base_url}/a?allowInternal=true&k=a"),
13104 format!("{base_url}/b?allowInternal=true&k=b"),
13105 ] {
13106 let endpoint = component
13107 .create_endpoint(&uri, &endpoint_ctx)
13108 .expect("create https endpoint");
13109 let producer = endpoint
13110 .create_producer(rt(), &ctx)
13111 .expect("create producer");
13112 let exchange = Exchange::new(Message::default());
13113 let reply = producer.oneshot(exchange).await;
13114 assert!(reply.is_ok(), "https request failed: {reply:?}");
13115 }
13116
13117 assert_eq!(
13118 component.pinned_cache.build_count() - baseline,
13119 1,
13120 "endpoints of one HttpsComponent must share its pinned cache over \
13121 real https requests; 0 builds means the endpoints bypassed it \
13122 (per-endpoint cache regression), more than 1 means \
13123 per-endpoint caches came back"
13124 );
13125 }
13126
13127 #[test]
13128 fn test_https_component_owns_distinct_cache() {
13129 let http = HttpComponent::new();
13130 let https = HttpsComponent::new();
13131
13132 assert!(
13133 !Arc::ptr_eq(&http.pinned_cache, &https.pinned_cache),
13134 "http and https components must each own their own pinned cache"
13135 );
13136
13137 let endpoint_ctx = NoOpComponentContext;
13138 let _ = http
13139 .create_endpoint("http://localhost:1/?allowInternal=true", &endpoint_ctx)
13140 .expect("http endpoint");
13141 let _ = https
13142 .create_endpoint("https://localhost:1/?allowInternal=true", &endpoint_ctx)
13143 .expect("https endpoint");
13144
13145 assert_eq!(
13146 http.pinned_cache.build_count(),
13147 0,
13148 "endpoint creation must not build a pinned client"
13149 );
13150 assert_eq!(
13151 https.pinned_cache.build_count(),
13152 0,
13153 "endpoint creation must not build a pinned client"
13154 );
13155 }
13156
13157 #[test]
13158 fn test_component_constructor_builds_one_unpinned_client() {
13159 let baseline = build_client_call_count();
13160
13161 let _http = HttpComponent::new();
13162 assert_eq!(
13163 build_client_call_count() - baseline,
13164 1,
13165 "HttpComponent::new() must build exactly one shared unpinned client"
13166 );
13167
13168 let _https = HttpsComponent::new();
13169 assert_eq!(
13170 build_client_call_count() - baseline,
13171 2,
13172 "HttpsComponent::new() must build exactly one more shared unpinned client"
13173 );
13174 }
13175
13176 #[test]
13177 fn test_component_endpoints_share_unpinned_client() {
13178 let component = HttpComponent::new();
13179 let baseline = build_client_call_count();
13180
13181 let endpoint_ctx = NoOpComponentContext;
13182 for uri in [
13183 "http://localhost:1/a?allowInternal=true",
13184 "http://localhost:1/b?allowInternal=true",
13185 ] {
13186 let _endpoint = component
13187 .create_endpoint(uri, &endpoint_ctx)
13188 .expect("create endpoint");
13189 }
13190
13191 assert_eq!(
13192 build_client_call_count() - baseline,
13193 0,
13194 "create_endpoint must clone the component's shared unpinned client, \
13195 never build a fresh one"
13196 );
13197 }
13198
13199 #[test]
13200 fn test_dynamic_resolution_adds_no_unpinned_client_builds() {
13201 let component = HttpComponent::new();
13202 let baseline = build_client_call_count();
13203
13204 let ctx = test_producer_ctx();
13205 let endpoint_ctx = NoOpComponentContext;
13206 for i in 0..3 {
13207 let endpoint = component
13208 .create_endpoint(
13209 &format!("http://localhost:1/r{i}?allowInternal=true&k={i}"),
13210 &endpoint_ctx,
13211 )
13212 .expect("create endpoint");
13213 let _producer = endpoint
13214 .create_producer(rt(), &ctx)
13215 .expect("create producer");
13216 }
13217
13218 assert_eq!(
13219 build_client_call_count() - baseline,
13220 0,
13221 "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
13222 must reuse the component's shared unpinned client and build \
13223 no additional clients"
13224 );
13225 }
13226}