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 in_flight = ctx.in_flight_counter();
1858
1859 let path = self.config.path.clone();
1860 let registry_for_cleanup = registry.clone();
1861 let server_exited = registry.server_exited.clone();
1862 let cancel_token = ctx.cancel_token();
1863 let kernel = self.kernel.clone();
1864 let mut server_died = false;
1869 loop {
1870 tokio::select! {
1871 _ = ctx.cancelled() => {
1872 break;
1873 }
1874 _ = server_exited.cancelled() => {
1875 server_died = true;
1880 break;
1881 }
1882 envelope = env_rx.recv() => {
1883 let Some(envelope) = envelope else { break; };
1884
1885 let claim =
1893 in_flight.as_ref().map(camel_component_api::InFlightClaim::attach);
1894
1895 let mut msg = Message::default();
1897
1898 msg.set_header("CamelHttpMethod",
1900 serde_json::Value::String(envelope.method.clone()));
1901 msg.set_header("CamelHttpPath",
1902 serde_json::Value::String(envelope.path.clone()));
1903 msg.set_header("CamelHttpQuery",
1904 serde_json::Value::String(envelope.query.clone()));
1905
1906 for (param_name, param_value) in &envelope.path_params {
1913 msg.set_header(
1914 format!("CamelHttpPath_{param_name}"),
1915 serde_json::Value::String(param_value.clone()),
1916 );
1917 }
1918
1919 for (k, v) in &envelope.headers {
1921 if let Ok(val_str) = v.to_str() {
1922 msg.set_header(
1923 title_case_header(k.as_str()),
1924 serde_json::Value::String(val_str.to_string()),
1925 );
1926 }
1927 }
1928
1929 msg.body = Body::Stream(envelope.body);
1932
1933 #[allow(unused_mut)]
1934 let mut exchange = Exchange::new(msg);
1935
1936 #[cfg(feature = "otel")]
1938 {
1939 let headers: HashMap<String, String> = envelope
1940 .headers
1941 .iter()
1942 .filter_map(|(k, v)| {
1943 Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1944 })
1945 .collect();
1946 camel_otel::extract_into_exchange(&mut exchange, &headers);
1947 }
1948
1949 let reply_tx = envelope.reply_tx;
1950 let sender = ctx.sender().clone();
1951 let path_clone = path.clone();
1952 let cancel = cancel_token.clone();
1953 let auth_headers = envelope.headers.clone();
1957 let auth_uri: http::Uri = {
1958 let full = if envelope.query.is_empty() {
1959 envelope.path.clone()
1960 } else {
1961 format!("{}?{}", envelope.path, envelope.query)
1962 };
1963 full.parse().unwrap_or_default()
1967 };
1968 let kernel = kernel.clone();
1969
1970 tokio::spawn(async move {
1990 if cancel.is_cancelled() {
1998 let _ = reply_tx.send(HttpReply {
1999 status: 503,
2000 headers: vec![],
2001 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
2002 });
2003 return;
2004 }
2005
2006 if let Some(kernel) = kernel.as_ref()
2015 && !matches!(
2016 kernel.plan.access_mode,
2017 camel_api::security_policy::AccessMode::Public
2018 )
2019 {
2020 let principal = match camel_auth::extract_token_multi(
2021 &auth_headers,
2022 &auth_uri,
2023 &kernel.plan.credential_sources,
2024 ) {
2025 Some(extracted) => {
2026 match camel_auth::kernel_authenticate(
2027 &kernel.plan,
2028 &kernel.providers,
2029 &extracted,
2030 )
2031 .await
2032 {
2033 Ok(principal) => principal,
2034 Err(e) => {
2035 tracing::warn!(
2037 path = %path_clone,
2038 error = %e,
2039 "HTTP request authentication failed"
2040 );
2041 let _ = reply_tx.send(pipeline_error_to_reply(
2042 e,
2043 &path_clone,
2044 ));
2045 return;
2046 }
2047 }
2048 }
2049 None => {
2050 tracing::warn!(
2052 path = %path_clone,
2053 "HTTP request rejected: no credential found in any source"
2054 );
2055 let _ = reply_tx.send(pipeline_error_to_reply(
2056 CamelError::Unauthenticated(
2057 "no credential found in any source".to_string(),
2058 ),
2059 &path_clone,
2060 ));
2061 return;
2062 }
2063 };
2064 camel_auth::install_carrier(&mut exchange, &principal);
2065 }
2066
2067 let (tx, rx) = tokio::sync::oneshot::channel();
2069 let envelope = camel_component_api::consumer::ExchangeEnvelope {
2070 exchange,
2071 reply_tx: Some(tx),
2072 in_flight_claim: claim,
2078 };
2079
2080 let result = match sender.send(envelope).await {
2081 Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
2082 Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
2083 }
2084 .and_then(|r| r);
2085
2086 let reply = match result {
2087 Ok(out) => {
2088 let status = out
2089 .input
2090 .header("CamelHttpResponseCode")
2091 .and_then(|v| {
2092 let raw = v.as_u64()
2093 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
2094 let code = raw as u16;
2095 (100..1000).contains(&code).then_some(code)
2096 })
2097 .unwrap_or(200);
2098
2099 let user_content_type = out
2100 .input
2101 .header("Content-Type")
2102 .and_then(|v| v.as_str().map(|s| s.to_string()));
2103
2104 let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
2105 Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
2106 Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
2107 Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
2108 Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
2109 v.to_string().into_bytes(),
2110 )), Some("application/json".to_string())),
2111 Body::Stream(s) => {
2112 let ct = s.metadata.content_type.clone();
2113 match s.stream.lock().await.take() {
2114 Some(stream) => (
2115 HttpReplyBody::Stream(stream),
2116 ct,
2117 ),
2118 None => {
2119 tracing::error!(
2121 "Body::Stream already consumed before HTTP reply — returning 500"
2122 );
2123 let error_reply = HttpReply {
2124 status: 500,
2125 headers: vec![],
2126 body: HttpReplyBody::Bytes(bytes::Bytes::new()),
2127 };
2128 if reply_tx.send(error_reply).is_err() {
2129 debug!("reply_tx dropped before error reply could be sent");
2130 }
2131 return;
2132 }
2133 }
2134 }
2135 _ => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
2137 };
2138
2139 let resp_headers = select_response_headers(
2140 &out.input.headers,
2141 user_content_type,
2142 inferred_content_type,
2143 );
2144
2145 HttpReply {
2146 status,
2147 headers: resp_headers,
2148 body: reply_body,
2149 }
2150 }
2151 Err(e) => {
2152 pipeline_error_to_reply(e, &path_clone)
2153 }
2154 };
2155
2156 let _ = reply_tx.send(reply);
2158 });
2159 }
2160 }
2161 }
2162
2163 if let Some(method) = &self.config.method {
2168 registry_for_cleanup
2169 .unregister_rest_endpoint(method, &path)
2170 .await;
2171 } else {
2172 registry_for_cleanup.unregister_api_route(&path).await;
2173 }
2174
2175 ServerRegistry::global()
2181 .unregister(&self.config.host, self.config.port)
2182 .await;
2183
2184 if server_died {
2185 tracing::error!(
2187 host = %camel_api::redact::redact_host(&self.config.host),
2188 port = self.config.port,
2189 path = %path,
2190 "Shared HTTP server exited — failing consumer to engage route supervision (ADR-0007)"
2191 );
2192 return Err(CamelError::RouteError(format!(
2196 "shared HTTP server for {}:{} exited unexpectedly; route transport is dead",
2197 camel_api::redact::redact_host(&self.config.host),
2198 self.config.port
2199 )));
2200 }
2201
2202 Ok(())
2203 }
2204
2205 async fn stop(&mut self) -> Result<(), CamelError> {
2206 Ok(())
2207 }
2208
2209 fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
2210 camel_component_api::ConcurrencyModel::Concurrent { max: None }
2211 }
2212
2213 fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
2220 camel_component_api::ConsumerStartupMode::Explicit
2221 }
2222
2223 fn set_security_context(&mut self, ctx: camel_component_api::SecurityContext) {
2226 self.kernel = HttpKernelAuth::from_security_context(&ctx).map(Arc::new);
2227 }
2228}
2229
2230pub struct HttpComponent {
2235 config: HttpConfig,
2236 pinned_cache: std::sync::Arc<PinnedClientCache>,
2237 client: reqwest::Client,
2238 strict_tls_error: Option<CamelError>,
2242}
2243
2244#[cfg(test)]
2245thread_local! {
2246 static BUILD_CLIENT_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
2247}
2248
2249pub(crate) fn build_client(
2250 config: &HttpConfig,
2251 resolve_override: Option<(&str, &[std::net::SocketAddr])>,
2252) -> reqwest::Client {
2253 #[cfg(test)]
2254 BUILD_CLIENT_CALLS.with(|c| c.set(c.get() + 1));
2255
2256 let mut builder = reqwest::Client::builder()
2257 .no_proxy() .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
2259 .pool_max_idle_per_host(config.pool_max_idle_per_host)
2260 .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
2261
2262 builder = builder.redirect(reqwest::redirect::Policy::none());
2266
2267 if let Some((host, addrs)) = resolve_override {
2268 builder = builder.resolve_to_addrs(host, addrs);
2269 }
2270
2271 if let Some(tls) = &config.tls
2272 && tls.enabled
2273 {
2274 if tls.insecure || !tls.verify_peer {
2275 tracing::warn!("HTTP TLS verification disabled — connections are vulnerable to MitM");
2277 builder = builder.danger_accept_invalid_certs(true);
2278 }
2279
2280 if let Some(ca_path) = &tls.ca_cert_path {
2281 match std::fs::read(ca_path) {
2286 Ok(ca_bytes) => {
2287 let pem_sections = rustls_pemfile::certs(&mut std::io::Cursor::new(&ca_bytes))
2294 .filter(|r| r.is_ok())
2295 .count();
2296 if pem_sections == 0 {
2297 tracing::warn!(
2299 "configured CA certificate contains no parseable PEM CERTIFICATE section — falling back to system roots"
2300 );
2301 }
2302 match reqwest::Certificate::from_pem(&ca_bytes)
2303 .or_else(|_| reqwest::Certificate::from_der(&ca_bytes))
2304 {
2305 Ok(ca_cert) => {
2306 builder = builder.add_root_certificate(ca_cert);
2307 }
2308 Err(e) => {
2309 tracing::warn!(
2311 error = %e,
2312 "configured CA certificate failed to parse — falling back to system roots"
2313 );
2314 }
2315 }
2316 }
2317 Err(e) => {
2318 tracing::warn!(
2320 error = %e,
2321 "configured CA certificate file unreadable — falling back to system roots"
2322 );
2323 }
2324 }
2325 }
2326
2327 if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path) {
2331 match (std::fs::read(cert_path), std::fs::read(key_path)) {
2332 (Ok(cert_bytes), Ok(key_bytes)) => {
2333 let mut identity_pem = cert_bytes;
2334 identity_pem.extend_from_slice(&key_bytes);
2335 match reqwest::Identity::from_pem(&identity_pem) {
2336 Ok(identity) => {
2337 builder = builder.identity(identity);
2338 }
2339 Err(e) => {
2340 tracing::warn!(
2342 error = %e,
2343 "configured mTLS identity failed to parse — client certificate NOT used"
2344 );
2345 }
2346 }
2347 }
2348 (cert_r, key_r) => {
2349 tracing::warn!(
2351 cert_ok = cert_r.is_ok(),
2352 key_ok = key_r.is_ok(),
2353 "configured mTLS cert/key file unreadable — client certificate NOT used"
2354 );
2355 }
2356 }
2357 }
2358 }
2359
2360 builder
2361 .build()
2362 .expect("reqwest::Client::build() with valid config should not fail") }
2364
2365fn strict_tls_error(config: &HttpConfig) -> Option<CamelError> {
2374 let tls = config.tls.as_ref()?;
2375 if !tls.enabled || !tls.strict {
2376 return None;
2377 }
2378 if let Some(ca_path) = &tls.ca_cert_path {
2379 match std::fs::read(ca_path) {
2380 Ok(ca_bytes) => {
2381 let pem_sections = rustls_pemfile::certs(&mut std::io::Cursor::new(&ca_bytes))
2393 .filter(|r| r.is_ok())
2394 .count();
2395 if pem_sections == 0 {
2396 return Some(CamelError::EndpointCreationFailed(format!(
2397 "tls.strict: configured CA certificate '{ca_path}' has no \
2398 parseable PEM CERTIFICATE section (DER bundles are not \
2399 enforced by the TLS backend — convert to PEM)"
2400 )));
2401 }
2402 }
2403 Err(e) => {
2404 return Some(CamelError::EndpointCreationFailed(format!(
2405 "tls.strict: configured CA certificate '{ca_path}' is unreadable: {e}"
2406 )));
2407 }
2408 }
2409 }
2410 if tls.client_cert_path.is_some() != tls.client_key_path.is_some() {
2414 return Some(CamelError::EndpointCreationFailed(
2415 "tls.strict: mTLS requires BOTH client_cert_path and client_key_path".to_string(),
2416 ));
2417 }
2418 if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path) {
2419 match (std::fs::read(cert_path), std::fs::read(key_path)) {
2420 (Ok(mut cert_bytes), Ok(key_bytes)) => {
2421 cert_bytes.extend_from_slice(&key_bytes);
2422 if reqwest::Identity::from_pem(&cert_bytes).is_err() {
2423 return Some(CamelError::EndpointCreationFailed(
2424 "tls.strict: configured mTLS identity failed to parse".to_string(),
2425 ));
2426 }
2427 }
2428 _ => {
2429 return Some(CamelError::EndpointCreationFailed(
2430 "tls.strict: configured mTLS cert/key files are unreadable".to_string(),
2431 ));
2432 }
2433 }
2434 }
2435 None
2436}
2437
2438#[cfg(test)]
2439pub(crate) fn build_client_call_count() -> u64 {
2440 BUILD_CLIENT_CALLS.with(|c| c.get())
2441}
2442
2443impl HttpComponent {
2444 pub fn new() -> Self {
2445 let config = HttpConfig::default();
2446 let strict_err = strict_tls_error(&config);
2447 Self {
2448 client: build_client(&config, None),
2449 config,
2450 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2451 PINNED_CLIENT_TTL,
2452 PINNED_CLIENT_MAX_ENTRIES,
2453 )),
2454 strict_tls_error: strict_err,
2455 }
2456 }
2457
2458 pub fn with_config(config: HttpConfig) -> Self {
2459 let strict_err = strict_tls_error(&config);
2460 Self {
2461 client: build_client(&config, None),
2462 config,
2463 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2464 PINNED_CLIENT_TTL,
2465 PINNED_CLIENT_MAX_ENTRIES,
2466 )),
2467 strict_tls_error: strict_err,
2468 }
2469 }
2470
2471 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2472 match config {
2473 Some(cfg) => Self::with_config(cfg),
2474 None => Self::new(),
2475 }
2476 }
2477}
2478
2479impl Default for HttpComponent {
2480 fn default() -> Self {
2481 Self::new()
2482 }
2483}
2484
2485impl Component for HttpComponent {
2486 fn scheme(&self) -> &str {
2487 "http"
2488 }
2489
2490 fn metadata(&self) -> ComponentMetadata {
2491 HttpEndpointConfig::metadata()
2492 }
2493
2494 fn create_endpoint(
2495 &self,
2496 uri: &str,
2497 ctx: &dyn camel_component_api::ComponentContext,
2498 ) -> Result<Box<dyn Endpoint>, CamelError> {
2499 if let Some(err) = &self.strict_tls_error {
2500 return Err(err.clone());
2501 }
2502 self.config.validate()?;
2503 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2504 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2505 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2506 server_config.host.clone(),
2507 server_config.port,
2508 )));
2509 self.pinned_cache
2510 .wire(HttpComponentKind::Http, ctx.metrics());
2511 Ok(Box::new(HttpEndpoint {
2512 uri: uri.to_string(),
2513 config,
2514 server_config,
2515 client: self.client.clone(),
2516 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2517 http_config: self.config.clone(),
2518 }))
2519 }
2520}
2521
2522pub struct HttpsComponent {
2523 config: HttpConfig,
2524 pinned_cache: std::sync::Arc<PinnedClientCache>,
2525 client: reqwest::Client,
2526 strict_tls_error: Option<CamelError>,
2530}
2531
2532impl HttpsComponent {
2533 pub fn new() -> Self {
2534 let config = HttpConfig::default();
2535 let strict_err = strict_tls_error(&config);
2536 Self {
2537 client: build_client(&config, None),
2538 config,
2539 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2540 PINNED_CLIENT_TTL,
2541 PINNED_CLIENT_MAX_ENTRIES,
2542 )),
2543 strict_tls_error: strict_err,
2544 }
2545 }
2546
2547 pub fn with_config(config: HttpConfig) -> Self {
2548 let strict_err = strict_tls_error(&config);
2549 Self {
2550 client: build_client(&config, None),
2551 config,
2552 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2553 PINNED_CLIENT_TTL,
2554 PINNED_CLIENT_MAX_ENTRIES,
2555 )),
2556 strict_tls_error: strict_err,
2557 }
2558 }
2559
2560 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2561 match config {
2562 Some(cfg) => Self::with_config(cfg),
2563 None => Self::new(),
2564 }
2565 }
2566}
2567
2568impl Default for HttpsComponent {
2569 fn default() -> Self {
2570 Self::new()
2571 }
2572}
2573
2574impl Component for HttpsComponent {
2575 fn scheme(&self) -> &str {
2576 "https"
2577 }
2578
2579 fn metadata(&self) -> ComponentMetadata {
2580 let mut meta = HttpEndpointConfig::metadata();
2583 meta.scheme = "https".to_string();
2584 meta.description = "HTTPS client and server component (TLS over HTTP)".to_string();
2585 meta
2586 }
2587
2588 fn create_endpoint(
2589 &self,
2590 uri: &str,
2591 ctx: &dyn camel_component_api::ComponentContext,
2592 ) -> Result<Box<dyn Endpoint>, CamelError> {
2593 if let Some(err) = &self.strict_tls_error {
2594 return Err(err.clone());
2595 }
2596 self.config.validate()?;
2597 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2598 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2599 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2600 server_config.host.clone(),
2601 server_config.port,
2602 )));
2603 self.pinned_cache
2604 .wire(HttpComponentKind::Https, ctx.metrics());
2605 Ok(Box::new(HttpEndpoint {
2606 uri: uri.to_string(),
2607 config,
2608 server_config,
2609 client: self.client.clone(),
2610 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2611 http_config: self.config.clone(),
2612 }))
2613 }
2614}
2615
2616struct HttpEndpoint {
2621 uri: String,
2622 config: HttpEndpointConfig,
2623 server_config: HttpServerConfig,
2624 client: reqwest::Client,
2625 pinned_cache: std::sync::Arc<PinnedClientCache>,
2626 http_config: HttpConfig,
2627}
2628
2629impl Endpoint for HttpEndpoint {
2630 fn uri(&self) -> &str {
2631 &self.uri
2632 }
2633
2634 fn create_consumer(
2635 &self,
2636 rt: Arc<dyn camel_component_api::RuntimeObservability>,
2637 ) -> Result<Box<dyn Consumer>, CamelError> {
2638 let scheme_is_https = self.server_config.scheme == "https";
2641 let has_tls = self.server_config.tls_config.is_some();
2642
2643 if scheme_is_https && !has_tls {
2644 return Err(CamelError::EndpointCreationFailed(
2645 "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
2646 ));
2647 }
2648 if !scheme_is_https && has_tls {
2649 return Err(CamelError::EndpointCreationFailed(
2650 "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
2651 ));
2652 }
2653 Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
2654 }
2655
2656 fn create_producer(
2657 &self,
2658 rt: Arc<dyn camel_component_api::RuntimeObservability>,
2659 _ctx: &ProducerContext,
2660 ) -> Result<BoxProcessor, CamelError> {
2661 let producer = HttpProducer {
2662 config: Arc::new(self.config.clone()),
2663 client: self.client.clone(),
2664 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2665 http_config: Arc::new(self.http_config.clone()),
2666 runtime: rt,
2667 };
2668 if let Some(ref provider) = self.config.token_provider {
2669 let layer = BearerTokenLayer::new(Arc::clone(provider));
2670 Ok(BoxProcessor::new(layer.layer(producer)))
2671 } else {
2672 Ok(BoxProcessor::new(producer))
2673 }
2674 }
2675}
2676
2677#[derive(Clone)]
2682struct HttpProducer {
2683 config: Arc<HttpEndpointConfig>,
2684 client: reqwest::Client,
2685 pinned_cache: std::sync::Arc<PinnedClientCache>,
2686 http_config: Arc<HttpConfig>,
2687 runtime: Arc<dyn RuntimeObservability>,
2693}
2694
2695impl HttpProducer {
2696 fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
2697 if let Some(ref method) = config.http_method {
2698 return method.to_uppercase();
2699 }
2700 if let Some(method) = exchange
2701 .input
2702 .header("CamelHttpMethod")
2703 .and_then(|v| v.as_str())
2704 {
2705 return method.to_uppercase();
2706 }
2707 if !exchange.input.body.is_empty() {
2708 return "POST".to_string();
2709 }
2710 "GET".to_string()
2711 }
2712
2713 fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> Result<String, CamelError> {
2714 if config.bridge_endpoint {
2723 let Some(query) = resolve_endpoint_query(config)? else {
2724 return Ok(config.base_url.clone());
2725 };
2726 let _: url::Url = url::Url::parse(&config.base_url).map_err(|e| {
2734 CamelError::ProcessorError(format!(
2735 "invalid base URL '{}': {e}",
2736 redact_url_for_diagnostics(&config.base_url)
2737 ))
2738 })?;
2739 let mut url = config.base_url.clone();
2740 url.push('?');
2741 url.push_str(&query);
2742 return Ok(url);
2743 }
2744
2745 if let Some(uri) = exchange
2746 .input
2747 .header("CamelHttpUri")
2748 .and_then(|v| v.as_str())
2749 {
2750 if let Some(fence) = &config.allowed_uri_hosts
2755 && !uri_host_allowed(uri, fence)?
2756 {
2757 return Err(CamelError::ProcessorError(format!(
2758 "CamelHttpUri host not allowed by allowedUriHosts fence: {}",
2759 redact_url_for_diagnostics(uri)
2760 )));
2761 }
2762 let (base, override_query) = match uri.split_once('?') {
2769 Some((base, query)) => (base, Some(query)),
2770 None => (uri, None),
2771 };
2772 if let Some(query) = override_query {
2778 for (_key, span) in raw_query_pairs(query)? {
2779 validate_raw_query_span(span)?;
2780 }
2781 }
2782 let mut url = base.to_string();
2783 if let Some(path) = exchange
2784 .input
2785 .header("CamelHttpPath")
2786 .and_then(|v| v.as_str())
2787 {
2788 if !url.ends_with('/') && !path.starts_with('/') {
2789 url.push('/');
2790 }
2791 url.push_str(path);
2792 }
2793 if let Some(query) = exchange
2794 .input
2795 .header("CamelHttpQuery")
2796 .and_then(|v| v.as_str())
2797 {
2798 if let Some(merged) = merge_header_query(override_query, query)? {
2799 url.push('?');
2800 url.push_str(&merged);
2801 }
2802 return Ok(url);
2803 }
2804 if let Some(query) = override_query {
2805 url.push('?');
2806 url.push_str(query);
2807 }
2808 return Ok(url);
2809 }
2810
2811 let mut url = config.base_url.clone();
2812
2813 if let Some(path) = exchange
2814 .input
2815 .header("CamelHttpPath")
2816 .and_then(|v| v.as_str())
2817 {
2818 if !url.ends_with('/') && !path.starts_with('/') {
2819 url.push('/');
2820 }
2821 url.push_str(path);
2822 }
2823
2824 if let Some(query) = exchange
2825 .input
2826 .header("CamelHttpQuery")
2827 .and_then(|v| v.as_str())
2828 {
2829 if let Some(merged) =
2834 merge_header_query(resolve_endpoint_query(config)?.as_deref(), query)?
2835 {
2836 url.push('?');
2837 url.push_str(&merged);
2838 }
2839 return Ok(url);
2840 }
2841
2842 if let Some(query) = resolve_endpoint_query(config)? {
2843 url.push('?');
2844 url.push_str(&query);
2845 }
2846
2847 Ok(url)
2848 }
2849
2850 fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
2851 status >= range.0 && status <= range.1
2852 }
2853}
2854
2855#[derive(Clone, Debug, PartialEq, Eq)]
2860pub struct AllowedUriHost {
2861 pub host: String,
2863 pub port: Option<u16>,
2865}
2866
2867fn parse_allowed_uri_hosts(raw: &str) -> Result<Vec<AllowedUriHost>, CamelError> {
2875 let mut entries = Vec::new();
2876 for segment in raw.split(',') {
2877 let segment = segment.trim();
2878 if segment.is_empty() {
2879 continue;
2880 }
2881 let parsed = url::Url::parse(&format!("http://{segment}"))
2882 .map_err(|_| invalid_allowed_uri_host_entry(segment))?;
2883 if parsed.path() != "/" || !parsed.username().is_empty() || parsed.password().is_some() {
2887 return Err(invalid_allowed_uri_host_entry(segment));
2888 }
2889 let Some(host) = parsed.host_str() else {
2890 return Err(invalid_allowed_uri_host_entry(segment));
2891 };
2892 entries.push(AllowedUriHost {
2893 host: host.to_string(),
2894 port: parsed.port(),
2895 });
2896 }
2897 if entries.is_empty() {
2898 return Err(CamelError::InvalidUri(
2899 "allowedUriHosts declares no valid host entries".to_string(),
2900 ));
2901 }
2902 Ok(entries)
2903}
2904
2905fn invalid_allowed_uri_host_entry(segment: &str) -> CamelError {
2906 CamelError::InvalidUri(format!("invalid allowedUriHosts entry '{segment}'"))
2907}
2908
2909pub(crate) fn uri_host_allowed(
2916 url_str: &str,
2917 fence: &[AllowedUriHost],
2918) -> Result<bool, CamelError> {
2919 let Ok(parsed) = url::Url::parse(url_str) else {
2920 return Ok(false);
2921 };
2922 let Some(host) = parsed.host_str() else {
2923 return Ok(false);
2924 };
2925 let effective_port = parsed.port().or(match parsed.scheme() {
2926 "https" => Some(443_u16),
2927 "http" => Some(80),
2928 _ => None,
2929 });
2930 Ok(fence.iter().any(|entry| {
2931 entry.host == host
2932 && match entry.port {
2933 None => true,
2934 Some(port) => effective_port == Some(port),
2935 }
2936 }))
2937}
2938
2939fn resolve_endpoint_query(config: &HttpEndpointConfig) -> Result<Option<String>, CamelError> {
2952 let mut parts: Vec<String> = Vec::new();
2953 let mut authored_keys = std::collections::HashSet::new();
2954
2955 if let Some(raw) = config.raw_query.as_deref() {
2956 for (key, span) in raw_query_pairs(raw)? {
2957 authored_keys.insert(key.clone());
2958 if is_consumed_option(&key) {
2959 continue;
2960 }
2961 validate_raw_query_span(span)?;
2962 parts.push(span.to_string());
2963 }
2964 }
2965
2966 for (key, value) in &config.query_params {
2967 if !authored_keys.contains(key.as_str()) {
2968 parts.push(format!(
2969 "{}={}",
2970 encode_query_component(key),
2971 encode_query_component(value)
2972 ));
2973 }
2974 }
2975
2976 if parts.is_empty() && config.raw_query.as_deref() != Some("") {
2977 return Ok(None);
2978 }
2979 Ok(Some(parts.join("&")))
2980}
2981
2982fn merge_header_query(
2991 higher_precedence: Option<&str>,
2992 header_query: &str,
2993) -> Result<Option<String>, CamelError> {
2994 if header_query.is_empty() {
2995 return Ok(higher_precedence.map(str::to_string));
2996 }
2997 let mut parts: Vec<String> = Vec::new();
2998 let mut higher_keys = std::collections::HashSet::new();
2999 for (key, span) in raw_query_pairs(higher_precedence.unwrap_or(""))? {
3000 higher_keys.insert(key);
3001 parts.push(span.to_string());
3002 }
3003 for (key, span) in raw_query_pairs(header_query)? {
3004 validate_raw_query_span(span)?;
3005 if !higher_keys.contains(key.as_str()) {
3006 parts.push(span.to_string());
3007 }
3008 }
3009 if parts.is_empty() {
3010 return Ok(None);
3011 }
3012 Ok(Some(parts.join("&")))
3013}
3014
3015fn is_legal_query_byte(byte: u8) -> bool {
3026 matches!(byte,
3027 b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z'
3028 | b'-' | b'.' | b'_' | b'~'
3029 | b'!' | b'$' | b'&' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'='
3030 | b':' | b'@' | b'/' | b'?'
3031 | b'%')
3032}
3033
3034fn validate_raw_query_span(span: &str) -> Result<(), CamelError> {
3040 for &byte in span.as_bytes() {
3041 if !is_legal_query_byte(byte) {
3042 return Err(CamelError::ProcessorError(format!(
3043 "raw query pair '{span}' contains byte 0x{byte:02X}, which is not legal in a URL query component"
3044 )));
3045 }
3046 }
3047 Ok(())
3048}
3049
3050fn encode_query_component(component: &str) -> String {
3054 const HEX: &[u8; 16] = b"0123456789ABCDEF";
3055 let mut out = String::with_capacity(component.len());
3056 for &byte in component.as_bytes() {
3057 match byte {
3058 b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z' | b'-' | b'.' | b'_' | b'~' => {
3059 out.push(byte as char);
3060 }
3061 _ => {
3062 out.push('%');
3063 out.push(HEX[(byte >> 4) as usize] as char);
3064 out.push(HEX[(byte & 0x0f) as usize] as char);
3065 }
3066 }
3067 }
3068 out
3069}
3070
3071pub(crate) fn redact_url_for_diagnostics(raw: &str) -> String {
3099 match url::Url::parse(raw) {
3100 Ok(mut u) => {
3101 if !u.cannot_be_a_base()
3108 && u.host_str().is_none()
3109 && camel_api::redact::window_has_at_sign(raw)
3110 {
3111 return "[redacted]".to_string();
3112 }
3113 if !u.username().is_empty() || u.password().is_some() {
3114 let _ = u.set_username("***");
3115 let _ = u.set_password(None);
3116 }
3117 let s = u.to_string();
3120 camel_api::redact::redact_url(&s)
3121 }
3122 Err(_) => camel_api::redact::redact_url_fail_closed(raw),
3123 }
3124}
3125
3126const MAX_ERROR_RESPONSE_BODY_BYTES: usize = 4096;
3131
3132fn truncate_error_body(body: &[u8]) -> String {
3133 if body.len() <= MAX_ERROR_RESPONSE_BODY_BYTES {
3134 String::from_utf8_lossy(body).into_owned()
3135 } else {
3136 let mut s = String::from_utf8_lossy(&body[..MAX_ERROR_RESPONSE_BODY_BYTES]).into_owned();
3137 s.push_str("...[truncated]");
3138 s
3139 }
3140}
3141
3142impl HttpProducer {
3143 fn is_entity_enclosing(method: &str) -> bool {
3148 matches!(method, "POST" | "PUT" | "PATCH")
3149 }
3150}
3151
3152impl Service<Exchange> for HttpProducer {
3153 type Response = Exchange;
3154 type Error = CamelError;
3155 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
3156
3157 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
3158 Poll::Ready(Ok(()))
3159 }
3160
3161 fn call(&mut self, exchange: Exchange) -> Self::Future {
3162 let config = self.config.clone();
3163 let shared_client = self.client.clone();
3164 let pinned_cache = std::sync::Arc::clone(&self.pinned_cache);
3165 let http_config = self.http_config.clone();
3166 let component_metrics = self.runtime.component_metrics();
3167
3168 Box::pin(async move {
3169 let mut exchange = exchange;
3170 let outcome = async {
3171 let method_str = HttpProducer::resolve_method(&exchange, &config);
3172 let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
3177 let url = HttpProducer::resolve_url(&exchange, &config)?;
3178
3179 ssrf::validate_url_for_ssrf(&url, &config)?;
3181
3182 let resolved = ssrf::resolve_initial_url_for_ssrf(
3190 &url,
3191 config.allow_internal,
3192 config.allow_cleartext,
3193 )
3194 .await?;
3195 let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
3196 pinned_cache
3197 .get_or_build(host.as_str(), addrs, || {
3198 build_client(&http_config, Some((host.as_str(), addrs)))
3199 })
3200 .await
3201 } else {
3202 shared_client.clone()
3203 };
3204
3205 debug!(
3206 correlation_id = %exchange.correlation_id(),
3207 method = %method_str,
3208 url = %redact_url_for_diagnostics(&url),
3209 "HTTP request"
3210 );
3211
3212 let method = method_str.parse::<reqwest::Method>().map_err(|e| {
3213 CamelError::ProcessorError(format!(
3214 "Invalid HTTP method '{}': {}",
3215 method_str, e
3216 ))
3217 })?;
3218
3219 let mut collected_headers: Vec<(
3221 reqwest::header::HeaderName,
3222 reqwest::header::HeaderValue,
3223 )> = Vec::new();
3224
3225 if let Some(user_agent) = &config.user_agent
3226 && !config.bridge_endpoint
3227 {
3228 match constructed_header("user-agent", user_agent) {
3229 Ok((_, val)) => {
3230 collected_headers.push((reqwest::header::USER_AGENT, val));
3231 }
3232 Err(drop) => debug!(
3233 correlation_id = %exchange.correlation_id(),
3234 header = %drop.name,
3235 "outbound header dropped: {}",
3236 drop.reason
3237 ),
3238 }
3239 }
3240
3241 #[cfg(feature = "otel")]
3243 let should_inject_otel = !config.bridge_endpoint;
3244 #[cfg(feature = "otel")]
3245 if should_inject_otel {
3246 let mut otel_headers = HashMap::new();
3247 camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
3248 for (k, v) in otel_headers {
3249 match constructed_header(&k, &v) {
3250 Ok((name, val)) => collected_headers.push((name, val)),
3251 Err(drop) => debug!(
3252 correlation_id = %exchange.correlation_id(),
3253 header = %drop.name,
3254 "outbound header dropped: {}",
3255 drop.reason
3256 ),
3257 }
3258 }
3259 }
3260
3261 let conn_tokens = header_policy::connection_tokens(
3262 exchange
3263 .input
3264 .headers
3265 .iter()
3266 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3267 .filter_map(|(_, v)| v.as_str()),
3268 );
3269
3270 let outbound = select_outbound_headers(
3271 &exchange.input.headers,
3272 &config.skip_request_headers,
3273 &conn_tokens,
3274 );
3275 for drop in &outbound.drops {
3276 if let Some(value_kind) = drop.value_kind {
3277 debug!(
3278 correlation_id = %exchange.correlation_id(),
3279 header = %drop.name,
3280 value_kind = value_kind,
3281 "outbound header dropped: {}",
3282 drop.reason
3283 );
3284 } else {
3285 debug!(
3286 correlation_id = %exchange.correlation_id(),
3287 header = %drop.name,
3288 "outbound header dropped: {}",
3289 drop.reason
3290 );
3291 }
3292 }
3293 collected_headers.extend(outbound.accepted);
3294
3295 if !config.bridge_endpoint {
3297 match &config.auth {
3298 HttpAuth::None => {}
3299 HttpAuth::Basic { username, password } => {
3300 use base64::Engine;
3301 let credentials = format!("{username}:{password}");
3303 let encoded =
3304 base64::engine::general_purpose::STANDARD.encode(credentials);
3305 match constructed_header("authorization", &format!("Basic {encoded}")) {
3308 Ok((_, val)) => {
3309 collected_headers.push((reqwest::header::AUTHORIZATION, val));
3310 }
3311 Err(drop) => debug!(
3312 correlation_id = %exchange.correlation_id(),
3313 header = %drop.name,
3314 "outbound header dropped: {}",
3315 drop.reason
3316 ),
3317 }
3318 }
3319 HttpAuth::Bearer { token } => {
3320 let bearer = format!("Bearer {token}");
3322 match constructed_header("authorization", &bearer) {
3323 Ok((_, val)) => {
3324 collected_headers.push((reqwest::header::AUTHORIZATION, val));
3325 }
3326 Err(drop) => debug!(
3327 correlation_id = %exchange.correlation_id(),
3328 header = %drop.name,
3329 "outbound header dropped: {}",
3330 drop.reason
3331 ),
3332 }
3333 }
3334 }
3335
3336 if config.connection_close {
3337 collected_headers.push((
3338 reqwest::header::CONNECTION,
3339 reqwest::header::HeaderValue::from_static("close"),
3340 ));
3341 }
3342 }
3343
3344 let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
3346 let materialized_body: Option<Vec<u8>> = if is_stream_body {
3347 if suppress_body {
3348 std::mem::take(&mut exchange.input.body);
3356 tracing::warn!(
3358 correlation_id = %exchange.correlation_id(),
3359 method = %method_str,
3360 "dropping request body for non-entity-enclosing HTTP method"
3361 );
3362 }
3363 None } else {
3365 let body = std::mem::take(&mut exchange.input.body);
3366 let bytes = body.into_bytes(config.max_body_size).await?;
3367 if bytes.is_empty() {
3368 None
3370 } else if suppress_body {
3371 tracing::warn!(
3373 correlation_id = %exchange.correlation_id(),
3374 method = %method_str,
3375 "dropping request body for non-entity-enclosing HTTP method"
3376 );
3377 None
3378 } else {
3379 Some(bytes.to_vec())
3380 }
3381 };
3382
3383 let response = if config.follow_redirects && !is_stream_body {
3384 ssrf::send_with_ssrf_safe_redirects(
3390 &client,
3391 &shared_client,
3392 &pinned_cache,
3393 &http_config,
3394 &config,
3395 method,
3396 &url,
3397 collected_headers,
3398 materialized_body,
3399 config.max_redirects,
3400 config.response_timeout,
3401 )
3402 .await?
3403 } else {
3404 let mut request = client.request(method, &url);
3406
3407 if let Some(timeout) = config.response_timeout {
3408 request = request.timeout(timeout);
3409 }
3410
3411 for (name, value) in &collected_headers {
3412 request = request.header(name, value);
3413 }
3414
3415 if is_stream_body {
3416 if let Body::Stream(ref s) = exchange.input.body {
3417 let mut stream_lock = s.stream.lock().await;
3418 if let Some(stream) = stream_lock.take() {
3419 request = request.body(reqwest::Body::wrap_stream(stream));
3420 } else {
3421 return Err(CamelError::AlreadyConsumed);
3422 }
3423 }
3424 } else if let Some(ref body_bytes) = materialized_body {
3425 request = request.body(body_bytes.clone());
3426 }
3427
3428 request.send().await.map_err(|e| {
3429 CamelError::ProcessorError(format!("HTTP request failed: {e}"))
3430 })?
3431 };
3432
3433 let status_code = response.status().as_u16();
3434 let status_text = response
3435 .status()
3436 .canonical_reason()
3437 .unwrap_or("Unknown")
3438 .to_string();
3439
3440 for (key, value) in response.headers() {
3441 if config
3442 .skip_response_headers
3443 .iter()
3444 .any(|h| h.eq_ignore_ascii_case(key.as_str()))
3445 {
3446 continue;
3447 }
3448 if let Ok(val_str) = value.to_str() {
3449 exchange.input.set_header(
3450 title_case_header(key.as_str()),
3451 serde_json::Value::String(val_str.to_string()),
3452 );
3453 }
3454 }
3455
3456 exchange.input.set_header(
3457 "CamelHttpResponseCode",
3458 serde_json::Value::Number(status_code.into()),
3459 );
3460 exchange.input.set_header(
3461 "CamelHttpResponseText",
3462 serde_json::Value::String(status_text.clone()),
3463 );
3464
3465 let read_timeout = Duration::from_millis(config.read_timeout_ms);
3467 let response_body = tokio::time::timeout(read_timeout, async {
3468 if let Some(content_len) = response.content_length()
3470 && content_len > config.max_response_bytes as u64
3471 {
3472 return Err(CamelError::ProcessorError(format!(
3473 "Response body too large: {} bytes exceeds limit of {} bytes",
3474 content_len, config.max_response_bytes
3475 )));
3476 }
3477 use futures::TryStreamExt;
3479 let mut stream = response.bytes_stream();
3480 let mut total: usize = 0;
3481 let mut collected = Vec::new();
3482 while let Some(chunk) = stream.try_next().await.map_err(|e| {
3483 CamelError::ProcessorError(format!("Failed to read response body: {e}"))
3484 })? {
3485 total += chunk.len();
3486 if total > config.max_response_bytes {
3487 return Err(CamelError::ProcessorError(format!(
3488 "Response body too large: {} bytes exceeds limit of {} bytes",
3489 total, config.max_response_bytes
3490 )));
3491 }
3492 collected.push(chunk);
3493 }
3494 let mut result = bytes::BytesMut::with_capacity(total);
3495 for chunk in collected {
3496 result.extend_from_slice(&chunk);
3497 }
3498 Ok::<bytes::Bytes, CamelError>(result.freeze())
3499 })
3500 .await
3501 .map_err(|_| {
3502 CamelError::ProcessorError(format!(
3503 "Read timeout after {}ms",
3504 config.read_timeout_ms
3505 ))
3506 })??;
3507
3508 if config.throw_exception_on_failure
3509 && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
3510 {
3511 return Err(CamelError::HttpOperationFailed {
3512 method: method_str,
3513 url: redact_url_for_diagnostics(&url),
3516 status_code,
3517 status_text,
3518 response_body: Some(truncate_error_body(&response_body)),
3519 });
3520 }
3521
3522 if !response_body.is_empty() {
3523 exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
3524 }
3525
3526 debug!(
3527 correlation_id = %exchange.correlation_id(),
3528 status = status_code,
3529 url = %redact_url_for_diagnostics(&url),
3530 "HTTP response"
3531 );
3532 Ok(exchange)
3533 }
3534 .await;
3535 component_metrics.observe("http", "request", outcome.is_err());
3542 outcome
3543 })
3544 }
3545}
3546
3547#[cfg(test)]
3561pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
3562
3563#[cfg(test)]
3571pub(crate) fn lock_registry_test_mutex() -> std::sync::MutexGuard<'static, ()> {
3572 REGISTRY_TEST_MUTEX
3573 .lock()
3574 .unwrap_or_else(|poisoned| poisoned.into_inner())
3575}
3576
3577fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
3587 match e {
3588 CamelError::Unauthenticated(msg) => {
3589 tracing::warn!(error = %msg, path = %path, "Authentication failed");
3590 HttpReply {
3591 status: 401,
3592 headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
3593 body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
3594 }
3595 }
3596 CamelError::Unauthorized(msg) => {
3597 tracing::warn!(error = %msg, path = %path, "Authorization failed");
3598 HttpReply {
3599 status: 403,
3600 headers: vec![],
3601 body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
3602 }
3603 }
3604 CamelError::TypeConversionFailed(msg) => {
3605 tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
3606 json_error_reply(400, "bad_request", msg)
3607 }
3608 CamelError::ValidationError(msg) => {
3609 tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
3610 json_error_reply(400, "validation_error", msg)
3611 }
3612 CamelError::ConsumerStopping => {
3613 tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
3614 HttpReply {
3615 status: 503,
3616 headers: vec![],
3617 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
3618 }
3619 }
3620 CamelError::UnsupportedMediaType { consumed, declared } => {
3621 tracing::warn!(error = %consumed, declared = %declared, path = %path, "Unsupported media type (bad request)");
3622 json_error_reply(
3623 415,
3624 "unsupported_media_type",
3625 format!("consumed {consumed}, declared {declared}"),
3626 )
3627 }
3628 CamelError::NotAcceptable { accept, produced } => {
3629 tracing::warn!(error = %accept, produced = %produced, path = %path, "Not acceptable (bad request)");
3630 json_error_reply(
3631 406,
3632 "not_acceptable",
3633 format!("accept {accept}, produced {produced}"),
3634 )
3635 }
3636 e => {
3637 tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
3639 HttpReply {
3640 status: 500,
3641 headers: vec![],
3642 body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
3643 }
3644 }
3645 }
3646}
3647
3648fn json_error_reply(status: u16, code: &str, message: String) -> HttpReply {
3656 let body = serde_json::to_string(&serde_json::json!({
3657 "error": code,
3658 "message": message,
3659 }))
3660 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
3662 status,
3663 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
3664 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
3665 }
3666}
3667
3668const fn json_value_kind(v: &serde_json::Value) -> &'static str {
3672 match v {
3673 serde_json::Value::Null => "null",
3674 serde_json::Value::Bool(_) => "bool",
3675 serde_json::Value::Number(_) => "number",
3676 serde_json::Value::String(_) => "string",
3677 serde_json::Value::Array(_) => "array",
3678 serde_json::Value::Object(_) => "object",
3679 }
3680}
3681
3682fn scalar_string_form(v: &serde_json::Value) -> Option<String> {
3687 match v {
3688 serde_json::Value::String(s) => Some(s.clone()),
3689 serde_json::Value::Number(n) => Some(n.to_string()),
3690 serde_json::Value::Bool(b) => Some(b.to_string()),
3691 _ => None,
3692 }
3693}
3694
3695fn select_response_headers(
3710 headers: &HashMap<String, serde_json::Value>,
3711 user_content_type: Option<String>,
3712 inferred_content_type: Option<String>,
3713) -> Vec<(String, String)> {
3714 let conn_tokens = header_policy::connection_tokens(
3715 headers
3716 .iter()
3717 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3718 .filter_map(|(_, v)| v.as_str()),
3719 );
3720 let mut selected: Vec<(String, String)> = Vec::new();
3721 for (k, v) in headers {
3722 if k.starts_with("Camel") {
3723 debug!(header = %k, "reply header dropped: Camel namespace");
3724 continue;
3725 }
3726 if header_policy::excluded_response(k, &conn_tokens) {
3727 debug!(header = %k, "reply header dropped: emission policy");
3728 continue;
3729 }
3730 match scalar_string_form(v) {
3731 Some(s) => selected.push((k.clone(), s)),
3732 None => debug!(
3733 header = %k,
3734 value_kind = json_value_kind(v),
3735 "reply header dropped: no scalar string form"
3736 ),
3737 }
3738 }
3739 if let Some(ct) = user_content_type.or(inferred_content_type) {
3740 selected.push(("Content-Type".to_string(), ct));
3741 }
3742 selected
3743}
3744
3745#[derive(Debug)]
3750struct OutboundHeaderDrop<'a> {
3751 name: &'a str,
3752 reason: &'static str,
3753 value_kind: Option<&'static str>,
3754}
3755
3756struct OutboundHeaderSelection<'a> {
3759 accepted: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>,
3760 drops: Vec<OutboundHeaderDrop<'a>>,
3761}
3762
3763fn select_outbound_headers<'a>(
3777 headers: &'a HashMap<String, serde_json::Value>,
3778 skip_request_headers: &[String],
3779 conn_tokens: &[String],
3780) -> OutboundHeaderSelection<'a> {
3781 let mut accepted = Vec::new();
3782 let mut drops = Vec::new();
3783 for (key, value) in headers {
3784 if key.starts_with("Camel") {
3785 drops.push(OutboundHeaderDrop {
3786 name: key,
3787 reason: "Camel namespace",
3788 value_kind: None,
3789 });
3790 continue;
3791 }
3792 if skip_request_headers
3793 .iter()
3794 .any(|h| h.eq_ignore_ascii_case(key))
3795 {
3796 drops.push(OutboundHeaderDrop {
3797 name: key,
3798 reason: "skip_request_headers",
3799 value_kind: None,
3800 });
3801 continue;
3802 }
3803 if header_policy::excluded_outbound(key, conn_tokens) {
3804 drops.push(OutboundHeaderDrop {
3805 name: key,
3806 reason: "outbound emission policy",
3807 value_kind: None,
3808 });
3809 continue;
3810 }
3811 let Some(val_str) = scalar_string_form(value) else {
3812 drops.push(OutboundHeaderDrop {
3813 name: key,
3814 reason: "no scalar string form",
3815 value_kind: Some(json_value_kind(value)),
3816 });
3817 continue;
3818 };
3819 match constructed_header(key, &val_str) {
3820 Ok((name, val)) => accepted.push((name, val)),
3821 Err(drop) => drops.push(drop),
3822 }
3823 }
3824 OutboundHeaderSelection { accepted, drops }
3825}
3826
3827fn constructed_header<'a>(
3832 name: &'a str,
3833 value: &str,
3834) -> Result<(reqwest::header::HeaderName, reqwest::header::HeaderValue), OutboundHeaderDrop<'a>> {
3835 let header_name = match reqwest::header::HeaderName::from_bytes(name.as_bytes()) {
3836 Ok(header_name) => header_name,
3837 Err(_) => {
3838 return Err(OutboundHeaderDrop {
3839 name,
3840 reason: "invalid header name",
3841 value_kind: None,
3842 });
3843 }
3844 };
3845 let header_value = match reqwest::header::HeaderValue::from_str(value) {
3846 Ok(header_value) => header_value,
3847 Err(_) => {
3848 return Err(OutboundHeaderDrop {
3849 name,
3850 reason: "invalid header value",
3851 value_kind: None,
3852 });
3853 }
3854 };
3855 Ok((header_name, header_value))
3856}
3857
3858#[cfg(test)]
3859mod tests {
3860 use camel_component_api::test_support::NoopRuntimeObservability;
3861
3862 fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3866 std::sync::Arc::new(NoopRuntimeObservability)
3867 }
3868 fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3869 std::sync::Arc::new(NoopRuntimeObservability)
3870 }
3871 fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3872 std::sync::Arc::new(NoopRuntimeObservability)
3873 }
3874
3875 use super::*;
3876 use crate::config::TlsConfig;
3877 use crate::rest_match::PathSegment;
3878 use camel_component_api::{Message, NoOpComponentContext};
3879 use std::sync::Arc;
3880 use std::time::Duration;
3881
3882 fn test_producer_ctx() -> ProducerContext {
3883 ProducerContext::new()
3884 }
3885
3886 #[test]
3897 fn canonical_redact_host_pinned() {
3898 assert_eq!(
3899 camel_api::redact::redact_host("host.example:8080"),
3900 "host.example:8080"
3901 );
3902 assert_eq!(camel_api::redact::redact_host("a@b@c"), "***@c");
3903 }
3904
3905 #[test]
3906 fn redact_url_drops_oauth2_fragment_access_token() {
3907 let redacted =
3908 redact_url_for_diagnostics("https://app.example/cb#access_token=SECRET&state=x");
3909 assert!(
3910 !redacted.contains("SECRET"),
3911 "fragment access token leaked: {redacted}"
3912 );
3913 assert!(
3914 !redacted.contains("access_token"),
3915 "fragment key leaked: {redacted}"
3916 );
3917 assert!(
3918 redacted.ends_with("#[redacted]"),
3919 "fragment must be replaced with the sentinel: {redacted}"
3920 );
3921 }
3922
3923 #[test]
3924 fn redact_url_drops_oauth2_fragment_id_token() {
3925 let redacted =
3926 redact_url_for_diagnostics("https://app.example/cb#id_token=eyJhbG.SECRET.SIG&state=y");
3927 assert!(
3928 !redacted.contains("eyJhbG"),
3929 "id token payload leaked: {redacted}"
3930 );
3931 assert!(
3932 !redacted.contains("id_token"),
3933 "id token key leaked: {redacted}"
3934 );
3935 assert!(
3936 !redacted.contains("SECRET"),
3937 "id token signature leaked: {redacted}"
3938 );
3939 assert!(
3940 redacted.ends_with("#[redacted]"),
3941 "fragment must be replaced with the sentinel: {redacted}"
3942 );
3943 }
3944
3945 #[test]
3946 fn redact_url_drops_generic_fragment_kv() {
3947 let redacted = redact_url_for_diagnostics("https://h.example/p/session#session=abc123");
3948 assert!(
3949 !redacted.contains("abc123"),
3950 "fragment value leaked: {redacted}"
3951 );
3952 assert!(
3953 !redacted.contains("session="),
3954 "fragment key leaked: {redacted}"
3955 );
3956 assert!(
3957 redacted.contains("#[redacted]"),
3958 "fragment must be replaced with the sentinel: {redacted}"
3959 );
3960 }
3961
3962 #[test]
3963 fn redact_url_query_and_fragment_sentinels_compose() {
3964 let redacted = redact_url_for_diagnostics("https://h.example/p?a=1#access_token=x");
3965 assert_eq!(
3966 redacted, "https://h.example/p?[redacted]#[redacted]",
3967 "query and fragment sentinels must compose: {redacted}"
3968 );
3969 }
3970
3971 #[test]
3972 fn redact_url_drops_benign_fragment_too() {
3973 let redacted = redact_url_for_diagnostics("https://h.example/docs#section-3");
3976 assert_eq!(
3977 redacted, "https://h.example/docs#[redacted]",
3978 "benign fragment must still be dropped: {redacted}"
3979 );
3980 }
3981
3982 #[test]
3983 fn redact_url_unparseable_fragment_credentials_dropped() {
3984 let raw = "ht tps://app.example/cb#access_token=SECRET";
3985 assert!(
3986 url::Url::parse(raw).is_err(),
3987 "fixture must be unparseable: {raw}"
3988 );
3989 let redacted = redact_url_for_diagnostics(raw);
3990 assert!(
3991 !redacted.contains("SECRET"),
3992 "unparseable fragment token leaked: {redacted}"
3993 );
3994 assert!(
3995 !redacted.contains("access_token"),
3996 "unparseable fragment bytes leaked: {redacted}"
3997 );
3998 assert!(
3999 redacted.contains("#[redacted]"),
4000 "unparseable fragment must end in the sentinel: {redacted}"
4001 );
4002 }
4003
4004 #[test]
4005 fn redact_url_double_slash_evader_sentinel() {
4006 let redacted = redact_url_for_diagnostics("scheme:////user:pass@evil/");
4009 assert_eq!(
4010 redacted, "[redacted]",
4011 "double-slash evader must fail closed: {redacted}"
4012 );
4013 }
4014
4015 #[test]
4016 fn redact_url_triple_slash_evader_sentinel() {
4017 let redacted = redact_url_for_diagnostics("scheme:///user:pass@evil/");
4018 assert_eq!(
4019 redacted, "[redacted]",
4020 "triple-slash evader must fail closed: {redacted}"
4021 );
4022 }
4023
4024 #[test]
4025 fn redact_url_bare_protocol_relative_userinfo_sentinel() {
4026 let redacted = redact_url_for_diagnostics("//user:pass@evil");
4027 assert_eq!(
4028 redacted, "[redacted]",
4029 "protocol-relative userinfo must fail closed: {redacted}"
4030 );
4031 }
4032
4033 #[test]
4034 fn redact_url_empty_host_userinfo_sentinel() {
4035 let redacted = redact_url_for_diagnostics("scheme://user@");
4038 assert_eq!(
4039 redacted, "[redacted]",
4040 "empty-host userinfo must fail closed: {redacted}"
4041 );
4042 }
4043
4044 #[test]
4045 fn redact_url_unparseable_slash_run_evader_sentinel() {
4046 let raw = "schem e:////user:pass@evil/";
4050 assert!(
4051 url::Url::parse(raw).is_err(),
4052 "fixture must be unparseable: {raw}"
4053 );
4054 let redacted = redact_url_for_diagnostics(raw);
4055 assert_eq!(
4056 redacted, "[redacted]",
4057 "unparseable slash-run evader must fail closed: {redacted}"
4058 );
4059 }
4060
4061 #[test]
4062 fn redact_url_unparseable_later_window_userinfo_sentinel() {
4063 let raw = "http://ho st/a//user:pass@evil/";
4067 assert!(
4068 url::Url::parse(raw).is_err(),
4069 "fixture must be unparseable: {raw}"
4070 );
4071 let redacted = redact_url_for_diagnostics(raw);
4072 assert_eq!(
4073 redacted, "[redacted]",
4074 "userinfo in a later // window must fail closed: {redacted}"
4075 );
4076 }
4077
4078 #[test]
4079 fn redact_url_parsed_later_window_userinfo_masked() {
4080 let redacted = redact_url_for_diagnostics("https://h//user:pass@evil/");
4085 assert!(
4086 !redacted.contains("user:pass"),
4087 "parsed later-window userinfo leaked: {redacted}"
4088 );
4089 assert!(
4090 redacted.contains("h//***@evil/"),
4091 "later window must be masked in place: {redacted}"
4092 );
4093 }
4094
4095 #[test]
4096 fn redact_url_parsed_window_mask_idempotent_with_real_userinfo() {
4097 let redacted = redact_url_for_diagnostics("https://user:pass@h//x@y/");
4101 assert!(
4102 redacted.contains("***@h"),
4103 "accessor mask must survive the window surgery: {redacted}"
4104 );
4105 assert!(
4106 !redacted.contains("user:pass"),
4107 "real userinfo leaked: {redacted}"
4108 );
4109 assert!(
4110 !redacted.contains("x@y"),
4111 "later path window leaked: {redacted}"
4112 );
4113 }
4114
4115 #[test]
4116 fn redact_url_backslash_authority_ruling() {
4117 let redacted = redact_url_for_diagnostics("http:\\\\user:pass@evil\\path");
4123 assert!(
4124 redacted.contains("***@"),
4125 "backslash authority must be userinfo-masked: {redacted}"
4126 );
4127 assert!(
4128 !redacted.contains("user:pass"),
4129 "backslash authority must not leak credentials: {redacted}"
4130 );
4131 }
4132
4133 #[test]
4134 fn non_special_backslash_authority_masked() {
4135 let redacted = redact_url_for_diagnostics("foo:\\user:pass@evil/");
4140 assert!(
4141 !redacted.contains("user:pass"),
4142 "non-special backslash authority leaked: {redacted}"
4143 );
4144 assert!(
4145 !redacted.contains("pass"),
4146 "non-special backslash authority leaked a credential byte: {redacted}"
4147 );
4148 assert_eq!(
4150 redact_url_for_diagnostics("foo:\\clean/path"),
4151 "foo:\\clean/path"
4152 );
4153 }
4154
4155 #[test]
4156 fn one_char_scheme_credential_content_masked() {
4157 let redacted = redact_url_for_diagnostics("x:\\user:pass@evil");
4160 assert!(
4161 !redacted.contains("user:pass"),
4162 "one-char-scheme backslash authority leaked: {redacted}"
4163 );
4164 assert!(
4165 !redacted.contains("pass"),
4166 "one-char-scheme backslash authority leaked a credential byte: {redacted}"
4167 );
4168 }
4169
4170 #[test]
4171 fn drive_and_unc_inputs_stay_visible() {
4172 let drive = redact_url_for_diagnostics("C:\\Users\\x@corp\\file");
4178 assert!(
4179 !drive.contains("[redacted]"),
4180 "drive path must not be sentineled: {drive}"
4181 );
4182 assert!(
4183 !drive.contains("***"),
4184 "drive path must not be masked: {drive}"
4185 );
4186 assert!(
4187 drive.contains("x@corp"),
4188 "drive path keeps its at-sign content visible: {drive}"
4189 );
4190 let unc = redact_url_for_diagnostics("\\\\server\\x@y");
4193 assert_eq!(unc, "\\\\server\\x@y");
4194 assert!(
4195 !unc.contains("[redacted]"),
4196 "UNC path must not be sentineled: {unc}"
4197 );
4198 }
4199
4200 #[test]
4201 fn redact_url_masks_userinfo_and_query() {
4202 let redacted =
4203 redact_url_for_diagnostics("http://user:secretpass@internal.example/api?token=abc123");
4204 assert!(
4205 !redacted.contains("secretpass"),
4206 "password must be masked: {redacted}"
4207 );
4208 assert!(
4209 !redacted.contains("token=abc123"),
4210 "query must be masked: {redacted}"
4211 );
4212 assert!(
4213 !redacted.contains("user@"),
4214 "username must be masked: {redacted}"
4215 );
4216 assert!(
4217 redacted.contains("internal.example"),
4218 "host stays visible: {redacted}"
4219 );
4220 assert!(redacted.contains("[redacted]"), "query marked: {redacted}");
4221 }
4222
4223 #[test]
4224 fn redact_url_keeps_clean_urls_visible() {
4225 let redacted = redact_url_for_diagnostics("https://api.example.com/v1/items");
4226 assert_eq!(redacted, "https://api.example.com/v1/items");
4227 }
4228
4229 #[test]
4230 fn redact_url_masks_password_only_userinfo() {
4231 let redacted = redact_url_for_diagnostics("http://:pwsecret@host.example/");
4232 assert!(
4233 !redacted.contains("pwsecret"),
4234 "password-only userinfo leaked: {redacted}"
4235 );
4236 assert_eq!(redacted, "http://***@host.example/");
4237
4238 let redacted = redact_url_for_diagnostics("http://user:pw2@host.example/api");
4239 assert!(!redacted.contains("pw2"), "password leaked: {redacted}");
4240 assert_eq!(redacted, "http://***@host.example/api");
4241
4242 let redacted = redact_url_for_diagnostics("http://host.example/api");
4243 assert_eq!(redacted, "http://host.example/api");
4244 }
4245
4246 #[test]
4247 fn redact_url_truncates_unparseable() {
4248 let long = "x".repeat(1000);
4249 let redacted = redact_url_for_diagnostics(&long);
4250 assert_eq!(redacted.len(), 256, "unparseable URL must be truncated");
4251 }
4252
4253 #[test]
4258 fn redact_url_keeps_sentinels_intact_under_256_cap() {
4259 let parsed = format!("https://example.com/{}?x=1", "a".repeat(230));
4262 assert!(
4263 url::Url::parse(&parsed).is_ok(),
4264 "fixture must parse: {parsed}"
4265 );
4266 let redacted = redact_url_for_diagnostics(&parsed);
4267 assert!(redacted.len() <= 256, "len={}", redacted.len());
4268 assert!(
4269 redacted.ends_with("?[redacted]"),
4270 "parsed-arm sentinel must render intact: {redacted}"
4271 );
4272
4273 let unparseable = format!("http://{} ?x=1", "a".repeat(240));
4276 assert!(
4277 url::Url::parse(&unparseable).is_err(),
4278 "fixture must not parse: {unparseable}"
4279 );
4280 let redacted = redact_url_for_diagnostics(&unparseable);
4281 assert!(redacted.len() <= 256, "len={}", redacted.len());
4282 assert!(
4283 redacted.ends_with("?[redacted]"),
4284 "unparseable-arm sentinel must render intact: {redacted}"
4285 );
4286 }
4287
4288 #[test]
4289 fn redact_url_suppresses_unparseable_authority_credentials() {
4290 let fixtures = [
4291 "http://u:secretpw@/x",
4292 "http://u:secretpw@host:99999/x",
4293 "http://u:secretpw@host:99999",
4294 "//u:secretpw@h/x",
4295 ];
4296 for fixture in fixtures {
4297 assert!(
4298 url::Url::parse(fixture).is_err(),
4299 "fixture must be unparseable: {fixture}"
4300 );
4301 let redacted = redact_url_for_diagnostics(fixture);
4302 assert_eq!(
4303 redacted, "[redacted]",
4304 "credential-bearing authority must be suppressed: {fixture}"
4305 );
4306 }
4307 }
4308
4309 #[test]
4310 fn redact_url_bd_repro_never_leaks_credentials() {
4311 let redacted = redact_url_for_diagnostics("http://user:pa%ss@host/path");
4312 assert!(
4313 !redacted.contains("user:pa%ss"),
4314 "bd rc-2i5c5 repro leaked userinfo: {redacted}"
4315 );
4316 assert!(
4317 !redacted.contains("pa%ss"),
4318 "bd rc-2i5c5 repro leaked password: {redacted}"
4319 );
4320 }
4321
4322 #[test]
4323 fn redact_url_unparseable_query_redacted_short_and_long() {
4324 let short = "http://host:99999/path?token=shortsecret";
4325 assert!(
4326 url::Url::parse(short).is_err(),
4327 "fixture must be unparseable: {short}"
4328 );
4329 let redacted = redact_url_for_diagnostics(short);
4330 assert_eq!(
4331 redacted, "http://host:99999/path?[redacted]",
4332 "short unparseable query must end with the suffix: {redacted}"
4333 );
4334
4335 let mut long = String::from("http://host:99999/");
4336 long.push_str(&"a".repeat(300));
4337 long.push_str("?token=longsecret");
4338 assert!(
4339 url::Url::parse(&long).is_err(),
4340 "fixture must be unparseable: {long}"
4341 );
4342 let redacted = redact_url_for_diagnostics(&long);
4343 assert!(
4344 !redacted.contains("longsecret"),
4345 "long unparseable query leaked a query byte: {redacted}"
4346 );
4347 assert!(
4348 redacted.len() <= 256,
4349 "long unparseable query must be capped: {} bytes",
4350 redacted.len()
4351 );
4352 }
4353
4354 #[test]
4355 fn redact_url_unparseable_sentinels_compose_both() {
4356 let raw = "ht tp://h.example/p?a=1#tok=x";
4359 assert!(
4360 url::Url::parse(raw).is_err(),
4361 "fixture must be unparseable: {raw}"
4362 );
4363 assert_eq!(
4364 redact_url_for_diagnostics(raw),
4365 "ht tp://h.example/p?[redacted]#[redacted]",
4366 "query and fragment sentinels must compose: {raw}"
4367 );
4368 }
4369
4370 #[test]
4371 fn redact_url_unparseable_sentinels_compose_fragment_first() {
4372 let raw = "ht tp://h.example/p#tok=x?a=1";
4373 assert!(
4374 url::Url::parse(raw).is_err(),
4375 "fixture must be unparseable: {raw}"
4376 );
4377 assert_eq!(
4378 redact_url_for_diagnostics(raw),
4379 "ht tp://h.example/p#[redacted]?[redacted]",
4380 "sentinels must follow the introducers' first-occurrence order: {raw}"
4381 );
4382 }
4383
4384 #[test]
4385 fn redact_url_unparseable_utf8_straddle_no_panic() {
4386 let fixture = format!("a{}", "é".repeat(200));
4387 let redacted = redact_url_for_diagnostics(&fixture);
4388 assert!(
4389 redacted.len() <= 256,
4390 "straddle fixture must be capped: {} bytes",
4391 redacted.len()
4392 );
4393 assert!(
4394 redacted.len() >= 253,
4395 "straddle fixture must not over-truncate: {} bytes",
4396 redacted.len()
4397 );
4398 assert!(
4399 fixture.is_char_boundary(redacted.len()),
4400 "cut must land on a UTF-8 char boundary: {} bytes",
4401 redacted.len()
4402 );
4403 }
4404
4405 #[test]
4406 fn redact_url_at_sign_outside_authority_window_visible() {
4407 let at_sign_in_path = "http://host:99999/x@y";
4408 assert!(
4409 url::Url::parse(at_sign_in_path).is_err(),
4410 "fixture must be unparseable: {at_sign_in_path}"
4411 );
4412 assert_eq!(
4413 redact_url_for_diagnostics(at_sign_in_path),
4414 at_sign_in_path,
4415 "at-sign in path must not be suppressed"
4416 );
4417 assert_eq!(
4419 redact_url_for_diagnostics("mailto:user@example.com"),
4420 "mailto:user@example.com",
4421 "at-sign in mailto must round-trip byte-identically"
4422 );
4423 }
4424
4425 #[test]
4426 fn parse_success_fragment_composes() {
4427 assert_eq!(
4430 redact_url_for_diagnostics("https://h/p#access_token=x"),
4431 "https://h/p#[redacted]"
4432 );
4433 assert_eq!(
4436 redact_url_for_diagnostics("https://h/cb#f?state=x"),
4437 "https://h/cb#[redacted]?[redacted]"
4438 );
4439 }
4440
4441 #[test]
4442 fn err_arm_delegation_pin() {
4443 assert_eq!(
4447 redact_url_for_diagnostics("http://u:secretpw@host:99999/x"),
4448 "[redacted]"
4449 );
4450 assert_eq!(
4453 redact_url_for_diagnostics("http://h:99999/p?token=secret"),
4454 "http://h:99999/p?[redacted]"
4455 );
4456 }
4457
4458 #[test]
4459 fn truncate_error_body_caps_attacker_body() {
4460 let big = vec![b'A'; 10 * 1024 * 1024];
4461 let truncated = truncate_error_body(&big);
4462 assert!(
4463 truncated.len() <= MAX_ERROR_RESPONSE_BODY_BYTES + 20,
4464 "body must be capped near {} bytes, got {}",
4465 MAX_ERROR_RESPONSE_BODY_BYTES,
4466 truncated.len()
4467 );
4468 assert!(truncated.ends_with("...[truncated]"));
4469 }
4470
4471 #[test]
4472 fn truncate_error_body_keeps_small_body() {
4473 assert_eq!(truncate_error_body(b"boom"), "boom");
4474 }
4475
4476 #[test]
4477 fn test_http_config_defaults() {
4478 let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
4479 assert_eq!(config.base_url, "http://localhost:8080/api");
4480 assert!(config.http_method.is_none());
4481 assert!(config.throw_exception_on_failure);
4482 assert_eq!(config.ok_status_code_range, (200, 299));
4483 assert!(config.response_timeout.is_none());
4484 assert!(matches!(config.auth, HttpAuth::None));
4485 assert!(!config.bridge_endpoint);
4486 assert!(!config.connection_close);
4487 }
4488
4489 #[test]
4490 fn test_http_config_scheme() {
4491 assert_eq!(HttpEndpointConfig::scheme(), "http");
4493 }
4494
4495 #[test]
4496 fn test_http_config_from_components() {
4497 let components = camel_component_api::UriComponents {
4499 scheme: "https".to_string(),
4500 path: "//api.example.com/v1".to_string(),
4501 params: std::collections::HashMap::from([(
4502 "httpMethod".to_string(),
4503 "POST".to_string(),
4504 )]),
4505 raw_query: None,
4506 };
4507 let config = HttpEndpointConfig::from_components(components).unwrap();
4508 assert_eq!(config.base_url, "https://api.example.com/v1");
4509 assert_eq!(config.http_method, Some("POST".to_string()));
4510 }
4511
4512 #[test]
4513 fn test_http_config_with_options() {
4514 let config = HttpEndpointConfig::from_uri(
4515 "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
4516 ).unwrap();
4517 assert_eq!(config.base_url, "https://api.example.com/v1");
4518 assert_eq!(config.http_method, Some("PUT".to_string()));
4519 assert!(!config.throw_exception_on_failure);
4520 assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
4521 }
4522
4523 #[test]
4524 fn test_http_endpoint_config_auth_and_headers_options() {
4525 let config = HttpEndpointConfig::from_uri(
4526 "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
4527 )
4528 .unwrap();
4529
4530 assert!(matches!(
4531 config.auth,
4532 HttpAuth::Basic { username, password } if username == "u" && password == "p"
4533 ));
4534 assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
4535 assert!(config.bridge_endpoint);
4536 assert!(config.connection_close);
4537 assert_eq!(
4538 config.skip_request_headers,
4539 vec!["authorization".to_string(), "x-secret".to_string()]
4540 );
4541 assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
4542 }
4543
4544 #[test]
4545 fn test_http_endpoint_config_bearer_auth() {
4546 let config = HttpEndpointConfig::from_uri(
4547 "http://localhost/api?authMethod=Bearer&authBearerToken=t",
4548 )
4549 .unwrap();
4550 assert!(matches!(
4551 config.auth,
4552 HttpAuth::Bearer { token } if token == "t"
4553 ));
4554 }
4555
4556 #[test]
4557 fn rejects_cookie_handling_inmemory() {
4558 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
4559 match result {
4560 Err(CamelError::InvalidUri(msg)) => {
4561 assert!(
4562 msg.contains("cookieHandling is not supported"),
4563 "expected rejection message, got: {msg}"
4564 );
4565 }
4566 other => panic!("expected InvalidUri error, got: {other:?}"),
4567 }
4568 }
4569
4570 #[test]
4571 fn rejects_cookie_handling_disabled() {
4572 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
4573 match result {
4574 Err(CamelError::InvalidUri(msg)) => {
4575 assert!(
4576 msg.contains("cookieHandling is not supported"),
4577 "expected rejection message, got: {msg}"
4578 );
4579 }
4580 other => panic!("expected InvalidUri error, got: {other:?}"),
4581 }
4582 }
4583
4584 #[test]
4585 fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
4586 let config = HttpConfig::default()
4587 .with_response_timeout_ms(999)
4588 .with_allow_internal(true)
4589 .with_blocked_hosts(vec!["evil.com".to_string()])
4590 .with_max_body_size(12345);
4591 let endpoint =
4592 HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
4593 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
4594 assert!(endpoint.allow_internal);
4595 assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
4596 assert_eq!(endpoint.max_body_size, 12345);
4597 }
4598
4599 #[test]
4600 fn test_from_uri_with_defaults_uri_overrides_config() {
4601 let config = HttpConfig::default()
4602 .with_response_timeout_ms(999)
4603 .with_allow_internal(true)
4604 .with_blocked_hosts(vec!["evil.com".to_string()])
4605 .with_max_body_size(12345);
4606 let endpoint = HttpEndpointConfig::from_uri_with_defaults(
4607 "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
4608 &config,
4609 )
4610 .unwrap();
4611 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
4612 assert!(!endpoint.allow_internal);
4613 assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
4614 assert_eq!(endpoint.max_body_size, 99);
4615 }
4616
4617 #[test]
4618 fn test_http_config_ok_status_range() {
4619 let config =
4620 HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
4621 assert_eq!(config.ok_status_code_range, (200, 204));
4622 }
4623
4624 #[test]
4625 fn test_http_config_wrong_scheme() {
4626 let result = HttpEndpointConfig::from_uri("file:/tmp");
4627 assert!(result.is_err());
4628 }
4629
4630 #[test]
4631 fn test_http_component_scheme() {
4632 let component = HttpComponent::new();
4633 assert_eq!(component.scheme(), "http");
4634 }
4635
4636 #[test]
4643 fn tls_strict_defaults_false_on_deserialize() {
4644 let tls: TlsConfig = serde_json::from_value(serde_json::json!({
4645 "enabled": true
4646 }))
4647 .unwrap();
4648 assert!(!tls.strict, "absent strict must default to false");
4649 }
4650
4651 fn strict_config(ca_path: Option<&str>, strict: bool) -> HttpConfig {
4652 HttpConfig {
4653 tls: Some(TlsConfig {
4654 enabled: true,
4655 strict,
4656 ca_cert_path: ca_path.map(|p| p.to_string()),
4657 ..TlsConfig::default()
4658 }),
4659 ..HttpConfig::default()
4660 }
4661 }
4662
4663 #[test]
4664 fn strict_tls_missing_ca_fails_endpoint_creation() {
4665 let component =
4666 HttpComponent::with_config(strict_config(Some("/nonexistent/ca.pem"), true));
4667 let err = component
4668 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4669 .err()
4670 .expect("strict + missing CA must fail endpoint creation");
4671 assert!(
4672 err.to_string().contains("tls.strict"),
4673 "must name the strict knob: {err}"
4674 );
4675 assert!(
4676 err.to_string().contains("unreadable"),
4677 "must name the failure class: {err}"
4678 );
4679 }
4680
4681 #[test]
4682 fn strict_tls_unparseable_ca_fails_endpoint_creation() {
4683 let path = camel_component_api::test_support::tls::write_pem_tmp(
4684 "strict-bad-ca.pem",
4685 "not a certificate",
4686 );
4687 let component =
4688 HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
4689 let err = component
4690 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4691 .err()
4692 .expect("strict + unparseable CA must fail endpoint creation");
4693 assert!(
4694 err.to_string()
4695 .contains("no parseable PEM CERTIFICATE section"),
4696 "must name the failure class: {err}"
4697 );
4698 }
4699
4700 #[test]
4701 fn strict_tls_der_file_rejected_not_certified() {
4702 let path = camel_component_api::test_support::tls::write_pem_tmp(
4707 "strict-der-ca.pem",
4708 "00garbage-bytes",
4709 );
4710 let component =
4711 HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
4712 let err = component
4713 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4714 .err()
4715 .expect("strict + DER file must fail endpoint creation");
4716 assert!(
4717 err.to_string().contains("convert to PEM"),
4718 "must tell the operator to convert: {err}"
4719 );
4720 }
4721
4722 #[test]
4723 fn strict_tls_half_mtls_pair_rejected() {
4724 let cfg = strict_mtls_config(Some("/any/cert.pem"), None);
4727 let component = HttpComponent::with_config(cfg);
4728 let err = component
4729 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4730 .err()
4731 .expect("strict + half mTLS pair must fail endpoint creation");
4732 assert!(
4733 err.to_string().contains("BOTH"),
4734 "must name the pair requirement: {err}"
4735 );
4736 }
4737
4738 #[test]
4739 fn strict_tls_valid_material_allows_endpoint_creation() {
4740 let (ca, _cert, _key) = camel_component_api::test_support::tls::gen_server_cert();
4741 let path = camel_component_api::test_support::tls::write_pem_tmp("strict-ok-ca.pem", &ca);
4742 let component =
4743 HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
4744 assert!(
4745 component
4746 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4747 .is_ok(),
4748 "valid CA under strict must create the endpoint"
4749 );
4750 }
4751
4752 #[test]
4753 fn permissive_missing_ca_keeps_back_compat() {
4754 let component =
4757 HttpComponent::with_config(strict_config(Some("/nonexistent/ca.pem"), false));
4758 assert!(
4759 component
4760 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4761 .is_ok(),
4762 "permissive mode must keep the back-compat fallback"
4763 );
4764 }
4765
4766 fn strict_mtls_config(cert_path: Option<&str>, key_path: Option<&str>) -> HttpConfig {
4767 HttpConfig {
4768 tls: Some(TlsConfig {
4769 enabled: true,
4770 strict: true,
4771 client_cert_path: cert_path.map(|p| p.to_string()),
4772 client_key_path: key_path.map(|p| p.to_string()),
4773 ..TlsConfig::default()
4774 }),
4775 ..HttpConfig::default()
4776 }
4777 }
4778
4779 #[test]
4780 fn strict_tls_missing_mtls_cert_fails_endpoint_creation() {
4781 let (_ca, _cert, key) = camel_component_api::test_support::tls::gen_server_cert();
4784 let key_path =
4785 camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-key.pem", &key);
4786 let component = HttpComponent::with_config(strict_mtls_config(
4787 Some("/nonexistent/cert.pem"),
4788 Some(key_path.to_str().unwrap()),
4789 ));
4790 let err = component
4791 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4792 .err()
4793 .expect("strict + unreadable mTLS pair must fail endpoint creation");
4794 assert!(
4795 err.to_string().contains("tls.strict"),
4796 "must name the strict knob: {err}"
4797 );
4798 assert!(
4799 err.to_string().contains("unreadable"),
4800 "must name the failure class: {err}"
4801 );
4802 }
4803
4804 #[test]
4805 fn strict_tls_valid_mtls_pair_allows_endpoint_creation() {
4806 let (_ca, cert, key) = camel_component_api::test_support::tls::gen_server_cert();
4807 let cert_path =
4808 camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-cert.pem", &cert);
4809 let key_path =
4810 camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-key2.pem", &key);
4811 let component = HttpComponent::with_config(strict_mtls_config(
4812 Some(cert_path.to_str().unwrap()),
4813 Some(key_path.to_str().unwrap()),
4814 ));
4815 assert!(
4816 component
4817 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4818 .is_ok(),
4819 "valid mTLS pair under strict must create the endpoint"
4820 );
4821 }
4822
4823 #[test]
4824 fn test_https_component_scheme() {
4825 let component = HttpsComponent::new();
4826 assert_eq!(component.scheme(), "https");
4827 }
4828
4829 #[test]
4830 fn test_http_endpoint_creates_consumer() {
4831 let component = HttpComponent::new();
4832 let ctx = NoOpComponentContext;
4833 let endpoint = component
4834 .create_endpoint("http://0.0.0.0:19100/test", &ctx)
4835 .unwrap();
4836 assert!(endpoint.create_consumer(rt()).is_ok());
4837 }
4838
4839 #[test]
4840 fn test_https_endpoint_creates_consumer_errors_without_tls() {
4841 let component = HttpsComponent::new();
4842 let ctx = NoOpComponentContext;
4843 let endpoint = component
4844 .create_endpoint("https://0.0.0.0:8443/test", &ctx)
4845 .unwrap();
4846 assert!(endpoint.create_consumer(rt()).is_err());
4848 }
4849
4850 #[test]
4851 fn test_http_endpoint_creates_producer() {
4852 let ctx = test_producer_ctx();
4853 let component = HttpComponent::new();
4854 let endpoint_ctx = NoOpComponentContext;
4855 let endpoint = component
4856 .create_endpoint("http://localhost/api", &endpoint_ctx)
4857 .unwrap();
4858 assert!(endpoint.create_producer(rt(), &ctx).is_ok());
4859 }
4860
4861 #[tokio::test]
4866 async fn test_producer_with_token_provider() {
4867 use camel_auth::oauth2::TokenProvider;
4868 use tower::ServiceExt;
4869
4870 let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
4871 Arc::new(std::sync::Mutex::new(None));
4872 let captured_clone = Arc::clone(&captured_auth);
4873
4874 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4875 let port = listener.local_addr().unwrap().port();
4876
4877 let _handle = tokio::spawn(async move {
4878 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4879 if let Ok((mut stream, _)) = listener.accept().await {
4880 let mut buf = vec![0u8; 8192];
4881 let n = stream.read(&mut buf).await.unwrap_or(0);
4882 let request = String::from_utf8_lossy(&buf[..n]).to_string();
4883 let auth = request
4884 .lines()
4885 .find(|l| l.to_lowercase().starts_with("authorization:"))
4886 .map(|l| {
4887 l.split(':')
4888 .nth(1)
4889 .map(|s| s.trim().to_string())
4890 .unwrap_or_default()
4891 });
4892 *captured_clone.lock().unwrap() = auth;
4893 let body = r#"{"echo":"ok"}"#;
4894 let resp = format!(
4895 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
4896 body.len(),
4897 body
4898 );
4899 let _ = stream.write_all(resp.as_bytes()).await;
4900 }
4901 });
4902
4903 #[derive(Debug)]
4904 struct StaticProvider;
4905 #[async_trait::async_trait]
4906 impl TokenProvider for StaticProvider {
4907 async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
4908 Ok("injected-token".into())
4909 }
4910 }
4911
4912 let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
4913 let ctx = test_producer_ctx();
4914 let component = HttpComponent::new();
4915 let endpoint_ctx = NoOpComponentContext;
4916 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
4917 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4918
4919 let exchange = Exchange::new(Message::new("hello"));
4920
4921 let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
4922 let mut layered = layer.layer(producer);
4923 let result = layered.ready().await.unwrap().call(exchange).await;
4924 assert!(result.is_ok(), "producer call failed: {:?}", result);
4925
4926 tokio::time::sleep(Duration::from_millis(100)).await;
4927 let auth = captured_auth.lock().unwrap().take();
4928 assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
4929 }
4930
4931 async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
4932 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4933 let addr = listener.local_addr().unwrap();
4934 let url = format!("http://127.0.0.1:{}", addr.port());
4935
4936 let handle = tokio::spawn(async move {
4937 loop {
4938 if let Ok((mut stream, _)) = listener.accept().await {
4939 tokio::spawn(async move {
4940 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4941 let mut buf = vec![0u8; 4096];
4942 let n = stream.read(&mut buf).await.unwrap_or(0);
4943 let request = String::from_utf8_lossy(&buf[..n]).to_string();
4944
4945 let method = request.split_whitespace().next().unwrap_or("GET");
4946
4947 let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
4948 let response = format!(
4949 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
4950 body.len(),
4951 body
4952 );
4953 let _ = stream.write_all(response.as_bytes()).await;
4954 });
4955 }
4956 }
4957 });
4958
4959 (url, handle)
4960 }
4961
4962 async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
4963 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4964 let addr = listener.local_addr().unwrap();
4965 let url = format!("http://127.0.0.1:{}", addr.port());
4966
4967 let handle = tokio::spawn(async move {
4968 loop {
4969 if let Ok((mut stream, _)) = listener.accept().await {
4970 let status = status;
4971 tokio::spawn(async move {
4972 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4973 let mut buf = vec![0u8; 4096];
4974 let _ = stream.read(&mut buf).await;
4975
4976 let status_text = match status {
4977 404 => "Not Found",
4978 500 => "Internal Server Error",
4979 _ => "Error",
4980 };
4981 let body = "error body";
4982 let response = format!(
4983 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
4984 status,
4985 status_text,
4986 body.len(),
4987 body
4988 );
4989 let _ = stream.write_all(response.as_bytes()).await;
4990 });
4991 }
4992 }
4993 });
4994
4995 (url, handle)
4996 }
4997
4998 async fn start_request_capturing_server() -> (
4999 String,
5000 Arc<std::sync::Mutex<Option<String>>>,
5001 tokio::task::JoinHandle<()>,
5002 ) {
5003 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5004 let port = listener.local_addr().unwrap().port();
5005 let url = format!("http://127.0.0.1:{port}");
5006 let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
5007 let captured_clone = Arc::clone(&captured);
5008 let handle = tokio::spawn(async move {
5009 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5010 if let Ok((mut stream, _)) = listener.accept().await {
5011 let mut buf = vec![0u8; 16384];
5012 let n = stream.read(&mut buf).await.unwrap_or(0);
5013 let request = String::from_utf8_lossy(&buf[..n]).to_string();
5014 if request.contains("\r\n\r\n") {
5015 *captured_clone.lock().unwrap() = Some(request);
5016 }
5017 let body = r#"{"echo":"ok"}"#;
5018 let resp = format!(
5019 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
5020 body.len(),
5021 body
5022 );
5023 let _ = stream.write_all(resp.as_bytes()).await;
5024 }
5025 });
5026 (url, captured, handle)
5027 }
5028
5029 #[tokio::test]
5030 async fn test_http_producer_get_request() {
5031 use tower::ServiceExt;
5032
5033 let (url, _handle) = start_test_server().await;
5034 let ctx = test_producer_ctx();
5035
5036 let component = HttpComponent::new();
5037 let endpoint_ctx = NoOpComponentContext;
5038 let endpoint = component
5039 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5040 .unwrap();
5041 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5042
5043 let exchange = Exchange::new(Message::default());
5044 let result = producer.oneshot(exchange).await.unwrap();
5045
5046 let status = result
5047 .input
5048 .header("CamelHttpResponseCode")
5049 .and_then(|v| v.as_u64())
5050 .unwrap();
5051 assert_eq!(status, 200);
5052
5053 assert!(!result.input.body.is_empty());
5054 }
5055
5056 #[tokio::test]
5057 async fn producer_excludes_host_and_framing() {
5058 use tower::ServiceExt;
5059
5060 let (url, captured, _handle) = start_request_capturing_server().await;
5061 let ctx = test_producer_ctx();
5062 let component = HttpComponent::new();
5063 let endpoint_ctx = NoOpComponentContext;
5064 let endpoint = component
5065 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5066 .unwrap();
5067 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5068
5069 let mut exchange = Exchange::new(Message::default());
5070 exchange.input.set_header("Host", "localhost");
5071 exchange.input.set_header("Content-Length", "42");
5072 exchange.input.set_header("Connection", "keep-alive");
5073 exchange.input.set_header("Upgrade", "h2c");
5074
5075 let result = producer.oneshot(exchange).await;
5076 assert!(result.is_ok(), "producer call failed: {:?}", result);
5077
5078 tokio::time::sleep(Duration::from_millis(100)).await;
5079 let request = captured
5080 .lock()
5081 .unwrap()
5082 .take()
5083 .expect("no outbound request captured");
5084 let lower = request.to_ascii_lowercase();
5085 assert!(
5086 !lower.contains("\r\nhost: localhost"),
5087 "forwarded Host: localhost must be stripped\n{request}"
5088 );
5089 assert!(
5090 !lower.contains("content-length: 42"),
5091 "exchange Content-Length must not be copied\n{request}"
5092 );
5093 assert!(
5094 !lower.lines().any(|l| l.starts_with("connection:")),
5095 "Connection header must not be forwarded\n{request}"
5096 );
5097 assert!(
5098 !lower.lines().any(|l| l.starts_with("upgrade:")),
5099 "Upgrade header must not be forwarded\n{request}"
5100 );
5101 let host_header = lower
5102 .lines()
5103 .find(|l| l.starts_with("host:"))
5104 .map(|l| l.split_once(':').map(|(_, v)| v).unwrap_or("").trim())
5105 .expect("outbound Host header must be set by reqwest");
5106 assert!(
5107 host_header.starts_with("127.0.0.1:"),
5108 "outbound Host '{host_header}' must match the capture-server address"
5109 );
5110 }
5111
5112 #[tokio::test]
5113 async fn producer_forwards_request_only_headers() {
5114 use tower::ServiceExt;
5115
5116 let (url, captured, _handle) = start_request_capturing_server().await;
5117 let ctx = test_producer_ctx();
5118 let component = HttpComponent::new();
5119 let endpoint_ctx = NoOpComponentContext;
5120 let endpoint = component
5121 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5122 .unwrap();
5123 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5124
5125 let mut exchange = Exchange::new(Message::default());
5126 exchange.input.set_header("Accept", "application/json");
5127 exchange.input.set_header("User-Agent", "myclient/1.0");
5128
5129 let result = producer.oneshot(exchange).await;
5130 assert!(result.is_ok(), "producer call failed: {:?}", result);
5131
5132 tokio::time::sleep(Duration::from_millis(100)).await;
5133 let request = captured
5134 .lock()
5135 .unwrap()
5136 .take()
5137 .expect("no outbound request captured");
5138 let lower = request.to_ascii_lowercase();
5139 assert!(
5140 lower.contains("accept: application/json"),
5141 "request-only Accept header must be forwarded\n{request}"
5142 );
5143 assert!(
5144 lower.contains("user-agent: myclient/1.0"),
5145 "request-only User-Agent header must be forwarded\n{request}"
5146 );
5147 }
5148
5149 fn endpoint_with_config_overrides(
5158 base_url: &str,
5159 user_agent: Option<String>,
5160 auth: HttpAuth,
5161 ) -> HttpEndpoint {
5162 let uri = format!("{base_url}/api/test?allowInternal=true");
5163 let mut config =
5164 HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses");
5165 config.user_agent = user_agent;
5166 config.auth = auth;
5167 HttpEndpoint {
5168 uri: uri.clone(),
5169 config,
5170 server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
5171 client: reqwest::Client::new(),
5172 pinned_cache: Arc::new(PinnedClientCache::new(
5173 PINNED_CLIENT_TTL,
5174 PINNED_CLIENT_MAX_ENTRIES,
5175 )),
5176 http_config: HttpConfig::default(),
5177 }
5178 }
5179
5180 #[tracing_test::traced_test]
5185 #[tokio::test]
5186 async fn producer_invalid_configured_headers_surfaced() {
5187 use tower::ServiceExt;
5188
5189 let (bad_url, bad_captured, _bad_handle) = start_request_capturing_server().await;
5190 let (ok_url, ok_captured, _ok_handle) = start_request_capturing_server().await;
5191 let ctx = test_producer_ctx();
5192
5193 let bad_producer = endpoint_with_config_overrides(
5194 &bad_url,
5195 Some("bad\r\nua".to_string()),
5196 HttpAuth::Bearer {
5197 token: "tok\r\nen".to_string(),
5198 },
5199 )
5200 .create_producer(rt(), &ctx)
5201 .unwrap();
5202 let ok_producer = endpoint_with_config_overrides(
5203 &ok_url,
5204 Some("httpsweep-ok/1".to_string()),
5205 HttpAuth::Bearer {
5206 token: "valid-token".to_string(),
5207 },
5208 )
5209 .create_producer(rt(), &ctx)
5210 .unwrap();
5211
5212 let bad_exchange = Exchange::new(Message::default());
5213 let ok_exchange = Exchange::new(Message::default());
5214 let bad_cid = bad_exchange.correlation_id().to_string();
5215 let ok_cid = ok_exchange.correlation_id().to_string();
5216
5217 let bad_result = bad_producer.oneshot(bad_exchange).await;
5218 assert!(
5219 bad_result.is_ok(),
5220 "invalid-config producer call failed: {bad_result:?}"
5221 );
5222 let ok_result = ok_producer.oneshot(ok_exchange).await;
5223 assert!(
5224 ok_result.is_ok(),
5225 "valid-config producer call failed: {ok_result:?}"
5226 );
5227
5228 tokio::time::sleep(Duration::from_millis(100)).await;
5229 let bad_request = bad_captured
5230 .lock()
5231 .unwrap()
5232 .take()
5233 .expect("no outbound request captured");
5234 let ok_request = ok_captured
5235 .lock()
5236 .unwrap()
5237 .take()
5238 .expect("no outbound request captured");
5239
5240 let bad_lower = bad_request.to_ascii_lowercase();
5243 assert!(
5244 !bad_lower.lines().any(|l| l.starts_with("authorization:")),
5245 "invalid Bearer token must not reach the wire\n{bad_request}"
5246 );
5247 assert!(
5248 !bad_request.contains("bad\r\nua"),
5249 "invalid configured user-agent must not reach the wire\n{bad_request}"
5250 );
5251
5252 logs_assert(|lines: &[&str]| {
5253 let drops: Vec<&&str> = lines
5254 .iter()
5255 .filter(|l| {
5256 l.contains("outbound header dropped")
5257 && l.contains(&format!("correlation_id={bad_cid}"))
5258 })
5259 .collect();
5260 if drops.len() != 2 {
5261 return Err(format!(
5262 "expected exactly 2 drop records for {bad_cid}, found {}",
5263 drops.len()
5264 ));
5265 }
5266 let has_ua = drops.iter().any(|l| l.contains("header=user-agent"));
5267 let has_auth = drops.iter().any(|l| l.contains("header=authorization"));
5268 let reason_ok = drops
5269 .iter()
5270 .all(|l| l.contains("outbound header dropped: invalid header value"));
5271 match (has_ua, has_auth, reason_ok) {
5272 (true, true, true) => Ok(()),
5273 _ => Err(format!(
5274 "drop records mismatched: user-agent={has_ua} \
5275 authorization={has_auth} reason-ok={reason_ok}"
5276 )),
5277 }
5278 });
5279 logs_assert(|lines: &[&str]| {
5280 if lines
5281 .iter()
5282 .any(|l| l.contains("bad\r\nua") || l.contains("tok\r\nen"))
5283 {
5284 Err("sentinel CRLF values leaked into logs".to_string())
5285 } else {
5286 Ok(())
5287 }
5288 });
5289
5290 let ok_lower = ok_request.to_ascii_lowercase();
5293 assert!(
5294 ok_lower.contains("user-agent: httpsweep-ok/1"),
5295 "valid configured user-agent must reach the wire\n{ok_request}"
5296 );
5297 assert!(
5298 ok_lower.contains("authorization: bearer valid-token"),
5299 "valid Bearer token must reach the wire\n{ok_request}"
5300 );
5301 logs_assert(|lines: &[&str]| {
5302 let hits = lines
5303 .iter()
5304 .filter(|l| {
5305 l.contains("outbound header dropped")
5306 && l.contains(&format!("correlation_id={ok_cid}"))
5307 })
5308 .count();
5309 match hits {
5310 0 => Ok(()),
5311 n => Err(format!("expected no drop records for {ok_cid}, found {n}")),
5312 }
5313 });
5314 }
5315
5316 #[tokio::test]
5317 async fn producer_honours_skip_request_headers() {
5318 use tower::ServiceExt;
5319
5320 let (url, captured, _handle) = start_request_capturing_server().await;
5321 let ctx = test_producer_ctx();
5322 let component = HttpComponent::new();
5323 let endpoint_ctx = NoOpComponentContext;
5324 let endpoint = component
5325 .create_endpoint(
5326 &format!("{url}/api/test?allowInternal=true&skipRequestHeaders=Authorization"),
5327 &endpoint_ctx,
5328 )
5329 .unwrap();
5330 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5331
5332 let mut exchange = Exchange::new(Message::default());
5333 exchange.input.set_header("Authorization", "Bearer x");
5334
5335 let result = producer.oneshot(exchange).await;
5336 assert!(result.is_ok(), "producer call failed: {:?}", result);
5337
5338 tokio::time::sleep(Duration::from_millis(100)).await;
5339 let request = captured
5340 .lock()
5341 .unwrap()
5342 .take()
5343 .expect("no outbound request captured");
5344 assert!(
5345 !request.to_ascii_lowercase().contains("authorization"),
5346 "Authorization must be stripped by skipRequestHeaders\n{request}"
5347 );
5348 }
5349
5350 #[tokio::test]
5351 async fn producer_stringifies_scalar_header_values_on_wire() {
5352 use tower::ServiceExt;
5353
5354 let (url, captured, _handle) = start_request_capturing_server().await;
5355 let ctx = test_producer_ctx();
5356 let component = HttpComponent::new();
5357 let endpoint_ctx = NoOpComponentContext;
5358 let endpoint = component
5359 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5360 .unwrap();
5361 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5362
5363 let mut exchange = Exchange::new(Message::default());
5364 exchange.input.set_header("X-Retries", serde_json::json!(3));
5365 exchange
5366 .input
5367 .set_header("X-Enabled", serde_json::json!(true));
5368 exchange
5369 .input
5370 .set_header("X-Obj", serde_json::json!({"a": 1}));
5371
5372 let result = producer.oneshot(exchange).await;
5373 assert!(result.is_ok(), "producer call failed: {:?}", result);
5374
5375 tokio::time::sleep(Duration::from_millis(100)).await;
5376 let request = captured
5377 .lock()
5378 .unwrap()
5379 .take()
5380 .expect("no outbound request captured");
5381 let lower = request.to_ascii_lowercase();
5382 assert!(
5383 lower.contains("x-retries: 3"),
5384 "numeric header must reach the wire stringified\n{request}"
5385 );
5386 assert!(
5387 lower.contains("x-enabled: true"),
5388 "bool header must reach the wire stringified\n{request}"
5389 );
5390 assert!(
5391 !lower.contains("x-obj:"),
5392 "object header has no single-value form and must not reach the wire\n{request}"
5393 );
5394 }
5395
5396 #[tokio::test]
5397 async fn test_http_producer_post_with_body() {
5398 use tower::ServiceExt;
5399
5400 let (url, _handle) = start_test_server().await;
5401 let ctx = test_producer_ctx();
5402
5403 let component = HttpComponent::new();
5404 let endpoint_ctx = NoOpComponentContext;
5405 let endpoint = component
5406 .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
5407 .unwrap();
5408 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5409
5410 let exchange = Exchange::new(Message::new("request body"));
5411 let result = producer.oneshot(exchange).await.unwrap();
5412
5413 let status = result
5414 .input
5415 .header("CamelHttpResponseCode")
5416 .and_then(|v| v.as_u64())
5417 .unwrap();
5418 assert_eq!(status, 200);
5419 }
5420
5421 #[tokio::test]
5422 async fn test_http_producer_method_from_header() {
5423 use tower::ServiceExt;
5424
5425 let (url, _handle) = start_test_server().await;
5426 let ctx = test_producer_ctx();
5427
5428 let component = HttpComponent::new();
5429 let endpoint_ctx = NoOpComponentContext;
5430 let endpoint = component
5431 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5432 .unwrap();
5433 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5434
5435 let mut exchange = Exchange::new(Message::default());
5436 exchange.input.set_header(
5437 "CamelHttpMethod",
5438 serde_json::Value::String("DELETE".to_string()),
5439 );
5440
5441 let result = producer.oneshot(exchange).await.unwrap();
5442 let status = result
5443 .input
5444 .header("CamelHttpResponseCode")
5445 .and_then(|v| v.as_u64())
5446 .unwrap();
5447 assert_eq!(status, 200);
5448 }
5449
5450 #[tokio::test]
5451 async fn test_http_producer_forced_method() {
5452 use tower::ServiceExt;
5453
5454 let (url, _handle) = start_test_server().await;
5455 let ctx = test_producer_ctx();
5456
5457 let component = HttpComponent::new();
5458 let endpoint_ctx = NoOpComponentContext;
5459 let endpoint = component
5460 .create_endpoint(
5461 &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
5462 &endpoint_ctx,
5463 )
5464 .unwrap();
5465 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5466
5467 let exchange = Exchange::new(Message::default());
5468 let result = producer.oneshot(exchange).await.unwrap();
5469
5470 let status = result
5471 .input
5472 .header("CamelHttpResponseCode")
5473 .and_then(|v| v.as_u64())
5474 .unwrap();
5475 assert_eq!(status, 200);
5476 }
5477
5478 #[tokio::test]
5479 async fn test_http_producer_throw_exception_on_failure() {
5480 use tower::ServiceExt;
5481
5482 let (url, _handle) = start_status_server(404).await;
5483 let ctx = test_producer_ctx();
5484
5485 let component = HttpComponent::new();
5486 let endpoint_ctx = NoOpComponentContext;
5487 let endpoint = component
5488 .create_endpoint(
5489 &format!("{url}/not-found?allowInternal=true"),
5490 &endpoint_ctx,
5491 )
5492 .unwrap();
5493 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5494
5495 let exchange = Exchange::new(Message::default());
5496 let result = producer.oneshot(exchange).await;
5497 assert!(result.is_err());
5498
5499 match result.unwrap_err() {
5500 CamelError::HttpOperationFailed { status_code, .. } => {
5501 assert_eq!(status_code, 404);
5502 }
5503 e => panic!("Expected HttpOperationFailed, got: {e}"),
5504 }
5505 }
5506
5507 #[tokio::test]
5508 async fn test_http_producer_no_throw_on_failure() {
5509 use tower::ServiceExt;
5510
5511 let (url, _handle) = start_status_server(500).await;
5512 let ctx = test_producer_ctx();
5513
5514 let component = HttpComponent::new();
5515 let endpoint_ctx = NoOpComponentContext;
5516 let endpoint = component
5517 .create_endpoint(
5518 &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
5519 &endpoint_ctx,
5520 )
5521 .unwrap();
5522 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5523
5524 let exchange = Exchange::new(Message::default());
5525 let result = producer.oneshot(exchange).await.unwrap();
5526
5527 let status = result
5528 .input
5529 .header("CamelHttpResponseCode")
5530 .and_then(|v| v.as_u64())
5531 .unwrap();
5532 assert_eq!(status, 500);
5533 }
5534
5535 #[tokio::test]
5536 async fn test_http_producer_uri_override() {
5537 use tower::ServiceExt;
5538
5539 let (url, _handle) = start_test_server().await;
5540 let ctx = test_producer_ctx();
5541
5542 let component = HttpComponent::new();
5543 let endpoint_ctx = NoOpComponentContext;
5544 let endpoint = component
5545 .create_endpoint(
5546 "http://localhost:1/does-not-exist?allowInternal=true",
5547 &endpoint_ctx,
5548 )
5549 .unwrap();
5550 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5551
5552 let mut exchange = Exchange::new(Message::default());
5553 exchange.input.set_header(
5554 "CamelHttpUri",
5555 serde_json::Value::String(format!("{url}/api")),
5556 );
5557
5558 let result = producer.oneshot(exchange).await.unwrap();
5559 let status = result
5560 .input
5561 .header("CamelHttpResponseCode")
5562 .and_then(|v| v.as_u64())
5563 .unwrap();
5564 assert_eq!(status, 200);
5565 }
5566
5567 #[tokio::test]
5568 async fn test_http_producer_response_headers_mapped() {
5569 use tower::ServiceExt;
5570
5571 let (url, _handle) = start_test_server().await;
5572 let ctx = test_producer_ctx();
5573
5574 let component = HttpComponent::new();
5575 let endpoint_ctx = NoOpComponentContext;
5576 let endpoint = component
5577 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5578 .unwrap();
5579 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5580
5581 let exchange = Exchange::new(Message::default());
5582 let result = producer.oneshot(exchange).await.unwrap();
5583
5584 assert!(
5585 result.input.header("Content-Type").is_some(),
5586 "Response should have Content-Type header"
5587 );
5588 assert!(result.input.header("CamelHttpResponseText").is_some());
5589 }
5590
5591 async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
5596 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5597 let addr = listener.local_addr().unwrap();
5598 let url = format!("http://127.0.0.1:{}", addr.port());
5599
5600 let handle = tokio::spawn(async move {
5601 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5602 loop {
5603 if let Ok((mut stream, _)) = listener.accept().await {
5604 tokio::spawn(async move {
5605 let mut buf = vec![0u8; 4096];
5606 let n = stream.read(&mut buf).await.unwrap_or(0);
5607 let request = String::from_utf8_lossy(&buf[..n]).to_string();
5608
5609 if request.contains("GET /final") {
5611 let body = r#"{"status":"final"}"#;
5612 let response = format!(
5613 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
5614 body.len(),
5615 body
5616 );
5617 let _ = stream.write_all(response.as_bytes()).await;
5618 } else {
5619 let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
5624 let _ = stream.write_all(response.as_bytes()).await;
5625 }
5626 });
5627 }
5628 }
5629 });
5630
5631 (url, handle)
5632 }
5633
5634 struct CapturedRequest {
5635 method: String,
5636 path: String,
5637 body: Vec<u8>,
5638 content_length: Option<String>,
5639 transfer_encoding: Option<String>,
5640 }
5641
5642 async fn capture_request(stream: &mut tokio::net::TcpStream) -> Option<CapturedRequest> {
5648 use tokio::io::AsyncReadExt;
5649
5650 let mut buf: Vec<u8> = Vec::new();
5652 let mut chunk = [0u8; 4096];
5653 let head_end: usize;
5654 loop {
5655 let n = stream.read(&mut chunk).await.unwrap_or(0);
5656 if n == 0 {
5657 return None;
5658 }
5659 buf.extend_from_slice(&chunk[..n]);
5660 if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
5661 head_end = pos + 4;
5662 break;
5663 }
5664 }
5665
5666 let head = String::from_utf8_lossy(&buf[..head_end]);
5668 let mut lines = head.split("\r\n");
5669 let request_line = lines.next().unwrap_or("");
5670 let mut parts = request_line.split_whitespace();
5671 let method = parts.next().unwrap_or("").to_string();
5672 let path = parts.next().unwrap_or("").to_string();
5673
5674 let mut content_length: Option<String> = None;
5675 let mut transfer_encoding: Option<String> = None;
5676 for line in lines {
5677 if let Some((name, value)) = line.split_once(':') {
5678 let name = name.trim().to_ascii_lowercase();
5679 let value = value.trim().to_string();
5680 if name == "content-length" {
5681 content_length = Some(value);
5682 } else if name == "transfer-encoding" {
5683 transfer_encoding = Some(value);
5684 }
5685 }
5686 }
5687
5688 let body_len: usize = content_length
5690 .as_deref()
5691 .and_then(|v| v.parse::<usize>().ok())
5692 .unwrap_or(0);
5693
5694 let mut body: Vec<u8> = buf[head_end..].to_vec();
5695 while body.len() < body_len {
5696 let n = stream.read(&mut chunk).await.unwrap_or(0);
5697 if n == 0 {
5698 break;
5699 }
5700 body.extend_from_slice(&chunk[..n]);
5701 }
5702 body.truncate(body_len);
5703
5704 Some(CapturedRequest {
5705 method,
5706 path,
5707 body,
5708 content_length,
5709 transfer_encoding,
5710 })
5711 }
5712
5713 async fn start_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 captured.lock().unwrap().push(req);
5739
5740 let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
5743 let _ = stream.write_all(response.as_bytes()).await;
5744 });
5745 }
5746 }
5747 });
5748
5749 (url, handle, captured_for_return)
5750 }
5751
5752 async fn start_redirect_capture_server() -> (
5758 String,
5759 tokio::task::JoinHandle<()>,
5760 Arc<Mutex<Vec<CapturedRequest>>>,
5761 ) {
5762 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5763 let addr = listener.local_addr().unwrap();
5764 let url = format!("http://127.0.0.1:{}", addr.port());
5765
5766 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
5767 let captured_for_return = Arc::clone(&captured);
5768
5769 let handle = tokio::spawn(async move {
5770 use tokio::io::AsyncWriteExt;
5771 loop {
5772 if let Ok((mut stream, _)) = listener.accept().await {
5773 let captured = Arc::clone(&captured);
5774 tokio::spawn(async move {
5775 let Some(req) = capture_request(&mut stream).await else {
5776 return;
5777 };
5778 let path = req.path.clone();
5779 captured.lock().unwrap().push(req);
5780
5781 let (status_line, location) = match path.as_str() {
5782 "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
5783 "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
5784 "/final" => ("HTTP/1.1 200 OK", None),
5785 _ => ("HTTP/1.1 404 Not Found", None),
5786 };
5787
5788 let response = match location {
5789 Some(loc) => format!(
5793 "{status_line}\r\nLocation: {loc}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
5794 ),
5795 None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
5796 };
5797 let _ = stream.write_all(response.as_bytes()).await;
5798 });
5799 }
5800 }
5801 });
5802
5803 (url, handle, captured_for_return)
5804 }
5805
5806 #[tokio::test]
5807 async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
5808 use tower::ServiceExt;
5809
5810 let (url, _handle, captured) = start_capture_server().await;
5811 let ctx = test_producer_ctx();
5812
5813 let component = HttpComponent::with_config(HttpConfig::default());
5814 let endpoint_ctx = NoOpComponentContext;
5815 let endpoint = component
5816 .create_endpoint(
5817 &format!("{url}?httpMethod=GET&allowInternal=true"),
5818 &endpoint_ctx,
5819 )
5820 .unwrap();
5821 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5822
5823 let mut exchange = Exchange::new(Message::default());
5824 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5825
5826 let result = producer.oneshot(exchange).await.unwrap();
5827
5828 let status = result
5829 .input
5830 .header("CamelHttpResponseCode")
5831 .and_then(|v| v.as_u64())
5832 .unwrap();
5833 assert_eq!(status, 200);
5834
5835 let captured = captured.lock().unwrap();
5836 assert_eq!(captured.len(), 1, "expected exactly one captured request");
5837 let req = &captured[0];
5838 assert_eq!(req.method, "GET");
5839 assert_eq!(req.path, "/");
5842 assert!(req.body.is_empty(), "GET must not carry a body");
5843 assert!(
5844 req.content_length.is_none(),
5845 "suppressed request must not carry Content-Length"
5846 );
5847 assert!(
5848 req.transfer_encoding.is_none(),
5849 "suppressed request must not carry Transfer-Encoding"
5850 );
5851
5852 assert!(
5854 result.input.body.is_empty(),
5855 "exchange body must be consumed"
5856 );
5857 }
5858
5859 #[tokio::test]
5860 async fn test_head_with_body_suppressed_via_header() {
5861 use tower::ServiceExt;
5862
5863 let (url, _handle, captured) = start_capture_server().await;
5864 let ctx = test_producer_ctx();
5865
5866 let component = HttpComponent::with_config(HttpConfig::default());
5867 let endpoint_ctx = NoOpComponentContext;
5868 let endpoint = component
5869 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5870 .unwrap();
5871 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5872
5873 let mut exchange = Exchange::new(Message::default());
5874 exchange.input.set_header(
5875 "CamelHttpMethod",
5876 serde_json::Value::String("HEAD".to_string()),
5877 );
5878 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5879
5880 let result = producer.oneshot(exchange).await.unwrap();
5881 let status = result
5882 .input
5883 .header("CamelHttpResponseCode")
5884 .and_then(|v| v.as_u64())
5885 .unwrap();
5886 assert_eq!(status, 200);
5887
5888 let captured = captured.lock().unwrap();
5889 assert_eq!(captured.len(), 1);
5890 let req = &captured[0];
5891 assert_eq!(req.method, "HEAD");
5892 assert!(req.body.is_empty(), "HEAD must not carry a body");
5893 }
5894
5895 #[tokio::test]
5896 async fn test_delete_options_trace_with_body_suppressed() {
5897 use tower::ServiceExt;
5898
5899 let (url, _handle, captured) = start_capture_server().await;
5900 let ctx = test_producer_ctx();
5901 let component = HttpComponent::with_config(HttpConfig::default());
5902 let endpoint_ctx = NoOpComponentContext;
5903
5904 for method in ["DELETE", "OPTIONS", "TRACE"] {
5905 let endpoint = component
5906 .create_endpoint(
5907 &format!("{url}?httpMethod={method}&allowInternal=true"),
5908 &endpoint_ctx,
5909 )
5910 .unwrap();
5911 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5912
5913 let mut exchange = Exchange::new(Message::default());
5914 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5915
5916 let result = producer.oneshot(exchange).await.unwrap();
5917 let status = result
5918 .input
5919 .header("CamelHttpResponseCode")
5920 .and_then(|v| v.as_u64())
5921 .unwrap();
5922 assert_eq!(status, 200, "method {method} should succeed");
5923 }
5924
5925 let captured = captured.lock().unwrap();
5926 assert_eq!(captured.len(), 3, "expected three captured requests");
5927 for method in ["DELETE", "OPTIONS", "TRACE"] {
5928 let req = captured
5929 .iter()
5930 .find(|r| r.method == method)
5931 .unwrap_or_else(|| panic!("missing captured request for {method}"));
5932 assert!(req.body.is_empty(), "{} must not carry a body", req.method);
5933 }
5934 }
5935
5936 #[tokio::test]
5937 async fn test_post_put_patch_with_body_still_sent() {
5938 use tower::ServiceExt;
5939
5940 let (url, _handle, captured) = start_capture_server().await;
5941 let ctx = test_producer_ctx();
5942 let component = HttpComponent::with_config(HttpConfig::default());
5943 let endpoint_ctx = NoOpComponentContext;
5944
5945 for method in ["POST", "PUT", "PATCH"] {
5946 let endpoint = component
5947 .create_endpoint(
5948 &format!("{url}?httpMethod={method}&allowInternal=true"),
5949 &endpoint_ctx,
5950 )
5951 .unwrap();
5952 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5953
5954 let payload = format!("body-for-{method}");
5955 let mut exchange = Exchange::new(Message::default());
5956 exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
5957
5958 let result = producer.oneshot(exchange).await.unwrap();
5959 let status = result
5960 .input
5961 .header("CamelHttpResponseCode")
5962 .and_then(|v| v.as_u64())
5963 .unwrap();
5964 assert_eq!(status, 200, "method {method} should succeed");
5965 }
5966
5967 let captured = captured.lock().unwrap();
5968 assert_eq!(captured.len(), 3, "expected three captured requests");
5969 for method in ["POST", "PUT", "PATCH"] {
5970 let req = captured
5971 .iter()
5972 .find(|r| r.method == method)
5973 .unwrap_or_else(|| panic!("missing captured request for {method}"));
5974 let expected = format!("body-for-{method}");
5975 assert!(!req.body.is_empty(), "{method} must still carry its body");
5976 assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
5977 }
5978 }
5979
5980 #[tokio::test]
5984 async fn test_stream_body_under_get_not_attached() {
5985 use tower::ServiceExt;
5986
5987 let (url, _handle, captured) = start_capture_server().await;
5988 let ctx = test_producer_ctx();
5989
5990 let component = HttpComponent::with_config(HttpConfig::default());
5991 let endpoint_ctx = NoOpComponentContext;
5992 let endpoint = component
5993 .create_endpoint(
5994 &format!("{url}?httpMethod=GET&allowInternal=true"),
5995 &endpoint_ctx,
5996 )
5997 .unwrap();
5998 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5999
6000 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
6001 vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
6002 let stream = Box::pin(futures::stream::iter(chunks));
6003 let mut exchange = Exchange::new(Message::default());
6004 exchange.input.body = Body::Stream(StreamBody {
6005 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
6006 metadata: StreamMetadata::default(),
6007 });
6008
6009 let result = producer.oneshot(exchange).await.unwrap();
6010
6011 let status = result
6012 .input
6013 .header("CamelHttpResponseCode")
6014 .and_then(|v| v.as_u64())
6015 .unwrap();
6016 assert_eq!(status, 200);
6017
6018 let captured = captured.lock().unwrap();
6019 assert_eq!(captured.len(), 1, "expected exactly one captured request");
6020 assert!(
6021 captured[0].body.is_empty(),
6022 "GET must not carry a stream body"
6023 );
6024 assert!(
6025 captured[0].transfer_encoding.is_none(),
6026 "suppressed request must not carry Transfer-Encoding"
6027 );
6028 assert!(
6029 captured[0].content_length.is_none(),
6030 "suppressed request must not carry Content-Length"
6031 );
6032 assert!(
6033 result.input.body.is_empty(),
6034 "exchange body must be consumed to Empty, not left as a stream"
6035 );
6036 }
6037
6038 #[tokio::test]
6042 async fn test_redirect_hops_never_replay_suppressed_body() {
6043 use tower::ServiceExt;
6044
6045 let (url, _handle, captured) = start_redirect_capture_server().await;
6046 let ctx = test_producer_ctx();
6047
6048 let component =
6049 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6050 let endpoint_ctx = NoOpComponentContext;
6051
6052 for path in ["/hop307", "/hop308"] {
6053 let endpoint = component
6054 .create_endpoint(
6055 &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
6056 &endpoint_ctx,
6057 )
6058 .unwrap();
6059 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6060
6061 let mut exchange = Exchange::new(Message::default());
6062 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6063
6064 let result = producer.oneshot(exchange).await.unwrap();
6065 let status = result
6066 .input
6067 .header("CamelHttpResponseCode")
6068 .and_then(|v| v.as_u64())
6069 .unwrap();
6070 assert_eq!(
6071 status, 200,
6072 "redirect chain for {path} should end at /final"
6073 );
6074 }
6075
6076 let captured = captured.lock().unwrap();
6078 assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
6079 for req in captured.iter() {
6080 assert!(
6081 req.body.is_empty(),
6082 "hop {} {} must not carry a body",
6083 req.method,
6084 req.path
6085 );
6086 }
6087 }
6088
6089 #[tracing_test::traced_test]
6098 #[tokio::test]
6099 async fn test_suppressed_body_logs_exactly_one_warn() {
6100 use tower::ServiceExt;
6101
6102 let (url, _handle, _captured) = start_capture_server().await;
6103 let ctx = test_producer_ctx();
6104
6105 let component = HttpComponent::with_config(HttpConfig::default());
6106 let endpoint_ctx = NoOpComponentContext;
6107 let endpoint = component
6108 .create_endpoint(
6109 &format!("{url}?httpMethod=GET&allowInternal=true"),
6110 &endpoint_ctx,
6111 )
6112 .unwrap();
6113 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6114
6115 let mut exchange = Exchange::new(Message::default());
6116 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6117 let correlation_id = exchange.correlation_id().to_string();
6118
6119 let result = producer.oneshot(exchange).await.unwrap();
6120 let status = result
6121 .input
6122 .header("CamelHttpResponseCode")
6123 .and_then(|v| v.as_u64())
6124 .unwrap();
6125 assert_eq!(status, 200);
6126
6127 logs_assert(|lines: &[&str]| {
6128 let hits = lines
6129 .iter()
6130 .filter(|l| {
6131 l.contains("dropping request body")
6132 && l.contains("method=GET")
6133 && l.contains(&format!("correlation_id={correlation_id}"))
6134 })
6135 .count();
6136 match hits {
6137 1 => Ok(()),
6138 n => Err(format!("expected exactly one body-drop warn, found {n}")),
6139 }
6140 });
6141 }
6142
6143 #[tracing_test::traced_test]
6144 #[tokio::test]
6145 async fn test_empty_body_get_emits_no_warn() {
6146 use tower::ServiceExt;
6147
6148 let (url, _handle, _captured) = start_capture_server().await;
6149 let ctx = test_producer_ctx();
6150
6151 let component = HttpComponent::with_config(HttpConfig::default());
6152 let endpoint_ctx = NoOpComponentContext;
6153 let endpoint = component
6154 .create_endpoint(
6155 &format!("{url}?httpMethod=GET&allowInternal=true"),
6156 &endpoint_ctx,
6157 )
6158 .unwrap();
6159 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6160
6161 let exchange = Exchange::new(Message::default());
6162 let result = producer.oneshot(exchange).await.unwrap();
6163 let status = result
6164 .input
6165 .header("CamelHttpResponseCode")
6166 .and_then(|v| v.as_u64())
6167 .unwrap();
6168 assert_eq!(status, 200);
6169
6170 logs_assert(|lines: &[&str]| {
6171 let hits = lines
6172 .iter()
6173 .filter(|l| l.contains("dropping request body"))
6174 .count();
6175 match hits {
6176 0 => Ok(()),
6177 n => Err(format!("expected no body-drop warn, found {n}")),
6178 }
6179 });
6180 }
6181
6182 #[tokio::test]
6183 async fn test_follow_redirects_false_does_not_follow() {
6184 use tower::ServiceExt;
6185
6186 let (url, _handle) = start_redirect_server().await;
6187 let ctx = test_producer_ctx();
6188
6189 let component =
6190 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
6191 let endpoint_ctx = NoOpComponentContext;
6192 let endpoint = component
6193 .create_endpoint(
6194 &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
6195 &endpoint_ctx,
6196 )
6197 .unwrap();
6198 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6199
6200 let exchange = Exchange::new(Message::default());
6201 let result = producer.oneshot(exchange).await.unwrap();
6202
6203 let status = result
6205 .input
6206 .header("CamelHttpResponseCode")
6207 .and_then(|v| v.as_u64())
6208 .unwrap();
6209 assert_eq!(
6210 status, 302,
6211 "Should NOT follow redirect when followRedirects=false"
6212 );
6213 }
6214
6215 #[tokio::test]
6216 async fn test_follow_redirects_true_follows_redirect() {
6217 use tower::ServiceExt;
6218
6219 let (url, _handle) = start_redirect_server().await;
6220 let ctx = test_producer_ctx();
6221
6222 let component =
6223 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6224 let endpoint_ctx = NoOpComponentContext;
6225 let endpoint = component
6226 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6227 .unwrap();
6228 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6229
6230 let exchange = Exchange::new(Message::default());
6231 let result = producer.oneshot(exchange).await.unwrap();
6232
6233 let status = result
6235 .input
6236 .header("CamelHttpResponseCode")
6237 .and_then(|v| v.as_u64())
6238 .unwrap();
6239 assert_eq!(
6240 status, 200,
6241 "Should follow redirect when followRedirects=true"
6242 );
6243 }
6244
6245 #[tokio::test]
6248 async fn test_redirect_to_private_ip_is_ssrf_blocked() {
6249 use tower::ServiceExt;
6250
6251 let (url, _handle) = start_redirect_server().await;
6253 let ctx = test_producer_ctx();
6254
6255 let component =
6256 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6257 let endpoint_ctx = NoOpComponentContext;
6258 let endpoint = component
6259 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6260 .unwrap();
6261 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6262
6263 let exchange = Exchange::new(Message::default());
6264 let result = producer.oneshot(exchange).await;
6265
6266 assert!(
6268 result.is_ok(),
6269 "Redirect should succeed with allowInternal=true, got: {:?}",
6270 result
6271 );
6272 let exchange = result.unwrap();
6273 let status = exchange
6274 .input
6275 .header("CamelHttpResponseCode")
6276 .and_then(|v| v.as_u64())
6277 .unwrap();
6278 assert_eq!(status, 200, "Should follow redirect to /final");
6279 }
6280
6281 #[tokio::test]
6283 async fn test_redirect_to_private_ip_allowed_when_configured() {
6284 use tower::ServiceExt;
6285
6286 let (url, _handle) = start_redirect_server().await;
6288 let ctx = test_producer_ctx();
6289
6290 let component =
6291 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6292 let endpoint_ctx = NoOpComponentContext;
6293 let endpoint = component
6294 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6295 .unwrap();
6296 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6297
6298 let exchange = Exchange::new(Message::default());
6299 let result = producer.oneshot(exchange).await.unwrap();
6300
6301 let status = result
6302 .input
6303 .header("CamelHttpResponseCode")
6304 .and_then(|v| v.as_u64())
6305 .unwrap();
6306 assert_eq!(
6307 status, 200,
6308 "Should follow redirect to private IP when allowInternal=true"
6309 );
6310 }
6311
6312 #[tokio::test]
6315 async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
6316 use tower::ServiceExt;
6317
6318 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6320 let addr = listener.local_addr().unwrap();
6321 let url = format!("http://127.0.0.1:{}", addr.port());
6322
6323 let handle = tokio::spawn(async move {
6324 use tokio::io::{AsyncReadExt, AsyncWriteExt};
6325 loop {
6326 if let Ok((mut stream, _)) = listener.accept().await {
6327 tokio::spawn(async move {
6328 let mut buf = vec![0u8; 4096];
6329 let _ = stream.read(&mut buf).await;
6330 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";
6332 let _ = stream.write_all(response.as_bytes()).await;
6333 });
6334 }
6335 }
6336 });
6337
6338 let ctx = test_producer_ctx();
6339 let component =
6340 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6341 let endpoint_ctx = NoOpComponentContext;
6342 let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
6344 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6345
6346 let exchange = Exchange::new(Message::default());
6347 let result = producer.oneshot(exchange).await;
6348
6349 assert!(
6351 result.is_err(),
6352 "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
6353 );
6354 let err = result.unwrap_err().to_string();
6355 assert!(
6356 err.contains("blocked IP")
6357 || err.contains("private IP")
6358 || err.contains("SSRF")
6359 || err.contains("not allowed"),
6360 "Error should mention SSRF/IP blocking, got: {err}"
6361 );
6362
6363 handle.abort();
6364 }
6365
6366 #[tokio::test]
6368 async fn test_too_many_redirects_returns_error() {
6369 use tower::ServiceExt;
6370
6371 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6373 let addr = listener.local_addr().unwrap();
6374 let url = format!("http://127.0.0.1:{}", addr.port());
6375
6376 let handle = tokio::spawn(async move {
6377 use tokio::io::{AsyncReadExt, AsyncWriteExt};
6378 loop {
6379 if let Ok((mut stream, _)) = listener.accept().await {
6380 tokio::spawn(async move {
6381 let mut buf = vec![0u8; 4096];
6382 let _ = stream.read(&mut buf).await;
6383 let response = "HTTP/1.1 302 Found\r\nLocation: /loop\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
6388 let _ = stream.write_all(response.as_bytes()).await;
6389 });
6390 }
6391 }
6392 });
6393
6394 let ctx = test_producer_ctx();
6395 let component =
6396 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6397 let endpoint_ctx = NoOpComponentContext;
6398 let endpoint = component
6399 .create_endpoint(
6400 &format!("{url}?allowInternal=true&maxRedirects=2"),
6401 &endpoint_ctx,
6402 )
6403 .unwrap();
6404 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6405
6406 let exchange = Exchange::new(Message::default());
6407 let result = producer.oneshot(exchange).await;
6408
6409 match result {
6417 Err(e) => {
6418 let msg = e.to_string();
6420 assert!(
6421 msg.contains("HTTP operation failed") || msg.contains("302"),
6422 "expected redirect-after-exhaustion error, got: {msg}"
6423 );
6424 }
6425 Ok(ex) => {
6426 let response_code = ex
6427 .input
6428 .header("CamelHttpResponseCode")
6429 .and_then(|v| v.as_u64());
6430 assert_eq!(
6431 response_code,
6432 Some(302),
6433 "expected 302 after exhausting redirects"
6434 );
6435 }
6436 }
6437
6438 handle.abort();
6439 }
6440
6441 #[tokio::test]
6442 async fn test_query_params_forwarded_to_http_request() {
6443 use tower::ServiceExt;
6444
6445 let (url, _handle) = start_test_server().await;
6446 let ctx = test_producer_ctx();
6447
6448 let component = HttpComponent::new();
6449 let endpoint_ctx = NoOpComponentContext;
6450 let endpoint = component
6452 .create_endpoint(
6453 &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
6454 &endpoint_ctx,
6455 )
6456 .unwrap();
6457 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6458
6459 let exchange = Exchange::new(Message::default());
6460 let result = producer.oneshot(exchange).await.unwrap();
6461
6462 let status = result
6465 .input
6466 .header("CamelHttpResponseCode")
6467 .and_then(|v| v.as_u64())
6468 .unwrap();
6469 assert_eq!(status, 200);
6470 }
6471
6472 #[test]
6473 fn test_non_camel_query_params_are_forwarded() {
6474 let config = HttpEndpointConfig::from_uri(
6477 "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
6478 )
6479 .unwrap();
6480
6481 assert_eq!(
6484 config.raw_query.as_deref(),
6485 Some("apiKey=secret123&httpMethod=GET&token=abc456")
6486 );
6487 assert!(config.query_params.is_empty());
6488 }
6489
6490 #[test]
6491 fn test_authored_query_bytes_survive_resolve_url() {
6492 let config =
6493 HttpEndpointConfig::from_uri("http://example.com/api?q=hello%20world&tag=a+b").unwrap();
6494 let exchange = Exchange::new(Message::default());
6495
6496 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
6497
6498 assert!(url.contains("q=hello%20world"), "url was: {url}");
6501 assert!(url.contains("tag=a+b"), "url was: {url}");
6502 }
6503
6504 async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
6509 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6510 let addr = listener.local_addr().unwrap();
6511 let url = format!("http://127.0.0.1:{}", addr.port());
6512
6513 let handle = tokio::spawn(async move {
6514 loop {
6515 if let Ok((mut stream, _)) = listener.accept().await {
6516 let delay = delay_ms;
6517 tokio::spawn(async move {
6518 use tokio::io::{AsyncReadExt, AsyncWriteExt};
6519 let mut buf = vec![0u8; 4096];
6520 let _ = stream.read(&mut buf).await;
6521 let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
6523 let _ = stream.write_all(headers.as_bytes()).await;
6524 tokio::time::sleep(Duration::from_millis(delay)).await;
6526 let body = r#"{"status":"slow"}"#;
6527 let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
6528 let _ = stream.write_all(chunk.as_bytes()).await;
6529 });
6530 }
6531 }
6532 });
6533
6534 (url, handle)
6535 }
6536
6537 #[tokio::test]
6538 async fn test_http_producer_timeout() {
6539 use tower::ServiceExt;
6540
6541 let (url, _handle) = start_slow_server(500).await;
6543 let ctx = test_producer_ctx();
6544
6545 let component = HttpComponent::with_config(
6546 HttpConfig::default()
6547 .with_read_timeout_ms(100)
6548 .with_response_timeout_ms(30_000), );
6550 let endpoint_ctx = NoOpComponentContext;
6551 let endpoint = component
6552 .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
6553 .unwrap();
6554 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6555
6556 let exchange = Exchange::new(Message::default());
6557 let result = producer.oneshot(exchange).await;
6558
6559 assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
6560 let err = result.unwrap_err().to_string();
6561 assert!(
6562 err.contains("Read timeout") || err.contains("timeout"),
6563 "Error should mention timeout, got: {}",
6564 err
6565 );
6566 }
6567
6568 #[tokio::test]
6569 async fn test_http_producer_no_timeout_when_fast() {
6570 use tower::ServiceExt;
6571
6572 let (url, _handle) = start_test_server().await;
6573 let ctx = test_producer_ctx();
6574
6575 let component =
6576 HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
6577 let endpoint_ctx = NoOpComponentContext;
6578 let endpoint = component
6579 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
6580 .unwrap();
6581 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6582
6583 let exchange = Exchange::new(Message::default());
6584 let result = producer.oneshot(exchange).await.unwrap();
6585
6586 let status = result
6587 .input
6588 .header("CamelHttpResponseCode")
6589 .and_then(|v| v.as_u64())
6590 .unwrap();
6591 assert_eq!(status, 200);
6592 }
6593
6594 #[tokio::test]
6599 async fn test_http_producer_blocks_metadata_endpoint() {
6600 use tower::ServiceExt;
6601
6602 let ctx = test_producer_ctx();
6603 let component = HttpComponent::new();
6604 let endpoint_ctx = NoOpComponentContext;
6605 let endpoint = component
6606 .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
6607 .unwrap();
6608 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6609
6610 let mut exchange = Exchange::new(Message::default());
6611 exchange.input.set_header(
6612 "CamelHttpUri",
6613 serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
6614 );
6615
6616 let result = producer.oneshot(exchange).await;
6617 assert!(result.is_err(), "Should block AWS metadata endpoint");
6618
6619 let err = result.unwrap_err();
6620 assert!(
6621 err.to_string().contains("Private IP"),
6622 "Error should mention private IP blocking, got: {}",
6623 err
6624 );
6625 }
6626
6627 #[test]
6628 fn test_ssrf_config_defaults() {
6629 let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
6630 assert!(
6631 !config.allow_internal,
6632 "Private IPs should be blocked by default"
6633 );
6634 assert!(
6635 config.blocked_hosts.is_empty(),
6636 "Blocked hosts should be empty by default"
6637 );
6638 }
6639
6640 #[test]
6641 fn test_ssrf_config_allow_internal() {
6642 let config =
6643 HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
6644 assert!(
6645 config.allow_internal,
6646 "Private IPs should be allowed when explicitly set"
6647 );
6648 }
6649
6650 #[test]
6651 fn test_uri_option_allow_cleartext_parses() {
6652 let config =
6653 HttpEndpointConfig::from_uri("http://example.com/?allowCleartext=true").unwrap();
6654 assert!(
6655 config.allow_cleartext,
6656 "allowCleartext=true must parse into the endpoint config"
6657 );
6658
6659 let plain = HttpEndpointConfig::from_uri("http://example.com/").unwrap();
6660 assert!(
6661 !plain.allow_cleartext,
6662 "cleartext consent must default to false"
6663 );
6664
6665 let err =
6666 HttpEndpointConfig::from_uri("http://example.com/?allowCleartext=banana").unwrap_err();
6667 assert!(
6668 matches!(&err, CamelError::InvalidUri(msg) if msg.contains("allowCleartext")),
6669 "bad allowCleartext value must yield InvalidUri naming the option, got: {err:?}"
6670 );
6671 }
6672
6673 #[test]
6677 fn test_camel_http_uri_override_public_cleartext_follows_endpoint_flags() {
6678 let endpoint =
6679 HttpEndpointConfig::from_uri("http://localhost/?allowCleartext=false").unwrap();
6680 let err = crate::ssrf::validate_url_for_ssrf("http://93.184.216.34/exfil", &endpoint)
6681 .expect_err("public cleartext override must be rejected without consent");
6682 assert!(
6683 err.to_string().contains("allowCleartext"),
6684 "error must name the remedy, got: {err}"
6685 );
6686
6687 let endpoint =
6688 HttpEndpointConfig::from_uri("http://localhost/?allowCleartext=true").unwrap();
6689 assert!(
6690 crate::ssrf::validate_url_for_ssrf("http://93.184.216.34/exfil", &endpoint).is_ok(),
6691 "endpoint consent must admit a public cleartext override"
6692 );
6693 }
6694
6695 #[test]
6696 fn test_ssrf_config_blocked_hosts() {
6697 let config = HttpEndpointConfig::from_uri(
6698 "http://example.com/api?blockedHosts=evil.com,malware.net",
6699 )
6700 .unwrap();
6701 assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
6702 }
6703
6704 #[tokio::test]
6705 async fn test_http_producer_blocks_localhost() {
6706 use tower::ServiceExt;
6707
6708 let ctx = test_producer_ctx();
6709 let component = HttpComponent::new();
6710 let endpoint_ctx = NoOpComponentContext;
6711 let endpoint = component
6712 .create_endpoint("http://example.com/api", &endpoint_ctx)
6713 .unwrap();
6714 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6715
6716 let mut exchange = Exchange::new(Message::default());
6717 exchange.input.set_header(
6718 "CamelHttpUri",
6719 serde_json::Value::String("http://localhost:8080/internal".to_string()),
6720 );
6721
6722 let result = producer.oneshot(exchange).await;
6723 assert!(result.is_err(), "Should block localhost");
6724 }
6725
6726 #[tokio::test]
6727 async fn test_http_producer_blocks_loopback_ip() {
6728 use tower::ServiceExt;
6729
6730 let ctx = test_producer_ctx();
6731 let component = HttpComponent::new();
6732 let endpoint_ctx = NoOpComponentContext;
6733 let endpoint = component
6734 .create_endpoint("http://example.com/api", &endpoint_ctx)
6735 .unwrap();
6736 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6737
6738 let mut exchange = Exchange::new(Message::default());
6739 exchange.input.set_header(
6740 "CamelHttpUri",
6741 serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
6742 );
6743
6744 let result = producer.oneshot(exchange).await;
6745 assert!(result.is_err(), "Should block loopback IP");
6746 }
6747
6748 #[tokio::test]
6749 async fn test_http_producer_allows_private_ip_when_enabled() {
6750 use tower::ServiceExt;
6751
6752 let ctx = test_producer_ctx();
6753 let component = HttpComponent::new();
6754 let endpoint_ctx = NoOpComponentContext;
6755 let endpoint = component
6758 .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
6759 .unwrap();
6760 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6761
6762 let exchange = Exchange::new(Message::default());
6763
6764 let result = producer.oneshot(exchange).await;
6767 if let Err(ref e) = result {
6769 let err_str = e.to_string();
6770 assert!(
6771 !err_str.contains("Private IP") && !err_str.contains("not allowed"),
6772 "Should not be SSRF error, got: {}",
6773 err_str
6774 );
6775 }
6776 }
6777
6778 #[test]
6783 fn test_http_server_config_parse() {
6784 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
6785 assert_eq!(cfg.host, "0.0.0.0");
6786 assert_eq!(cfg.port, 8080);
6787 assert_eq!(cfg.path, "/orders");
6788 assert_eq!(cfg.max_inflight_requests, 1024);
6789 }
6790
6791 #[test]
6792 fn test_http_server_config_scheme() {
6793 assert_eq!(HttpServerConfig::scheme(), "http");
6795 }
6796
6797 #[test]
6798 fn test_http_server_config_from_components() {
6799 let components = camel_component_api::UriComponents {
6801 scheme: "https".to_string(),
6802 path: "//0.0.0.0:8443/api".to_string(),
6803 params: std::collections::HashMap::from([
6804 ("maxRequestBody".to_string(), "5242880".to_string()),
6805 ("maxInflightRequests".to_string(), "7".to_string()),
6806 ]),
6807 raw_query: None,
6808 };
6809 let cfg = HttpServerConfig::from_components(components).unwrap();
6810 assert_eq!(cfg.host, "0.0.0.0");
6811 assert_eq!(cfg.port, 8443);
6812 assert_eq!(cfg.path, "/api");
6813 assert_eq!(cfg.max_request_body, 5242880);
6814 assert_eq!(cfg.max_inflight_requests, 7);
6815 }
6816
6817 #[test]
6818 fn test_http_server_config_default_path() {
6819 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
6820 assert_eq!(cfg.path, "/");
6821 }
6822
6823 #[test]
6824 fn test_http_server_config_wrong_scheme() {
6825 assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
6826 }
6827
6828 #[test]
6829 fn test_http_server_config_invalid_port() {
6830 assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
6831 }
6832
6833 #[test]
6834 fn test_http_server_config_default_port_by_scheme() {
6835 let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
6837 assert_eq!(cfg_http.port, 80);
6838
6839 let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
6841 assert_eq!(cfg_https.port, 443);
6842 }
6843
6844 #[test]
6845 fn test_request_envelope_and_reply_are_send() {
6846 fn assert_send<T: Send>() {}
6847 assert_send::<RequestEnvelope>();
6848 assert_send::<HttpReply>();
6849 }
6850
6851 #[test]
6856 fn test_server_registry_global_is_singleton() {
6857 let r1 = ServerRegistry::global();
6858 let r2 = ServerRegistry::global();
6859 assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
6860 }
6861
6862 #[allow(clippy::await_holding_lock)]
6863 #[tokio::test]
6864 async fn test_concurrent_get_or_spawn_returns_same_registry() {
6865 let _guard = lock_registry_test_mutex();
6866 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6867 let port = listener.local_addr().unwrap().port();
6868 drop(listener);
6869
6870 let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
6871 Arc::new(std::sync::Mutex::new(Vec::new()));
6872
6873 let mut handles = Vec::new();
6874 for _ in 0..4 {
6875 let results = results.clone();
6876 handles.push(tokio::spawn(async move {
6877 let registry = ServerRegistry::global()
6878 .get_or_spawn(
6879 "127.0.0.1",
6880 port,
6881 2 * 1024 * 1024,
6882 10 * 1024 * 1024,
6883 1024,
6884 test_rt(),
6885 "test-route".into(),
6886 None,
6887 )
6888 .await
6889 .unwrap();
6890 results.lock().unwrap().push(registry);
6891 }));
6892 }
6893
6894 for h in handles {
6895 h.await.unwrap();
6896 }
6897
6898 let registries = results.lock().unwrap();
6899 assert_eq!(registries.len(), 4);
6900 for i in 1..registries.len() {
6901 assert!(
6902 Arc::ptr_eq(®istries[0].inner, ®istries[i].inner),
6903 "all concurrent callers should get same route registry"
6904 );
6905 }
6906 }
6907
6908 #[test]
6909 fn test_server_registry_distinguishes_host_and_port() {
6910 let _guard = lock_registry_test_mutex();
6911 let rt = tokio::runtime::Runtime::new().expect("runtime");
6912 rt.block_on(async {
6913 let registry = ServerRegistry::global();
6914 let d1 = registry
6918 .get_or_spawn(
6919 "127.0.0.1",
6920 0,
6921 1024 * 1024,
6922 10 * 1024 * 1024,
6923 1024,
6924 test_rt(),
6925 "test-route-1".into(),
6926 None,
6927 )
6928 .await;
6929 let d2 = registry
6930 .get_or_spawn(
6931 "0.0.0.0",
6932 0,
6933 1024 * 1024,
6934 10 * 1024 * 1024,
6935 1024,
6936 test_rt(),
6937 "test-route-2".into(),
6938 None,
6939 )
6940 .await;
6941 assert!(d1.is_ok());
6942 assert!(d2.is_ok());
6943 assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
6944 });
6945 }
6946
6947 #[allow(clippy::await_holding_lock)]
6948 #[tokio::test]
6949 async fn test_shared_server_max_request_body_policy_is_deterministic() {
6950 let _guard = lock_registry_test_mutex();
6951 let registry = ServerRegistry::global();
6952 let d1 = registry
6954 .get_or_spawn(
6955 "127.0.0.1",
6956 9991,
6957 1024 * 1024,
6958 10 * 1024 * 1024,
6959 1024,
6960 test_rt(),
6961 "test-route".into(),
6962 None,
6963 )
6964 .await;
6965 assert!(d1.is_ok());
6966
6967 let d2 = registry
6970 .get_or_spawn(
6971 "127.0.0.1",
6972 9991,
6973 2 * 1024 * 1024,
6974 10 * 1024 * 1024,
6975 1024,
6976 test_rt(),
6977 "test-route-2".into(),
6978 None,
6979 )
6980 .await;
6981 assert!(d2.is_err());
6982 let err = d2.unwrap_err();
6983 assert!(
6984 err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
6985 "Expected incompatible maxRequestBody error, got: {}",
6986 err
6987 );
6988 }
6989
6990 #[test]
6991 fn test_server_registry_reset_clears_entries() {
6992 let _guard = lock_registry_test_mutex();
6993 let rt = tokio::runtime::Runtime::new().expect("runtime");
6994 rt.block_on(async {
6995 let d1 = ServerRegistry::global()
6997 .get_or_spawn(
6998 "127.0.0.1",
6999 9992,
7000 1024 * 1024,
7001 10 * 1024 * 1024,
7002 1024,
7003 test_rt(),
7004 "test-route".into(),
7005 None,
7006 )
7007 .await;
7008 assert!(d1.is_ok());
7009
7010 let guard = ServerRegistry::global().inner.lock().expect("lock");
7012 assert!(guard.entries.contains_key(&("127.0.0.1".to_string(), 9992)));
7013 drop(guard);
7014
7015 ServerRegistry::reset();
7017
7018 let guard = ServerRegistry::global().inner.lock().expect("lock");
7020 assert!(
7021 guard.entries.is_empty(),
7022 "registry should be empty after reset, has {} entries",
7023 guard.entries.len()
7024 );
7025 });
7026 }
7027
7028 #[allow(clippy::await_holding_lock)]
7029 #[tokio::test]
7030 async fn registry_rejects_tls_on_plain_port() {
7031 let _guard = lock_registry_test_mutex();
7036 ServerRegistry::reset();
7037 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7038
7039 let _r1 = ServerRegistry::global()
7041 .get_or_spawn(
7042 "127.0.0.1",
7043 0,
7044 1024,
7045 1024,
7046 16,
7047 Arc::clone(&rt),
7048 "route-1".into(),
7049 None, )
7051 .await;
7052
7053 let result = ServerRegistry::global()
7055 .get_or_spawn(
7056 "127.0.0.1",
7057 0,
7058 1024,
7059 1024,
7060 16,
7061 Arc::clone(&rt),
7062 "route-2".into(),
7063 Some(crate::config::ServerTlsConfig {
7064 cert_path: "/x.pem".into(),
7065 key_path: "/y.pem".into(),
7066 }),
7067 )
7068 .await;
7069 assert!(result.is_err(), "must reject TLS on plain port");
7070 }
7071
7072 #[allow(clippy::await_holding_lock)]
7078 #[tokio::test]
7079 async fn test_unregister_last_http_route_keeps_server_alive() {
7080 let _guard = lock_registry_test_mutex();
7081 ServerRegistry::reset();
7082 let registry = ServerRegistry::global();
7083
7084 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7085 let port = listener.local_addr().unwrap().port();
7086 drop(listener); let rt = test_rt();
7088
7089 let _r1 = registry
7092 .get_or_spawn(
7093 "127.0.0.1",
7094 port,
7095 1024 * 1024,
7096 10 * 1024 * 1024,
7097 16,
7098 rt.clone(),
7099 "test-route-1".into(),
7100 None,
7101 )
7102 .await
7103 .unwrap();
7104 let _r2 = registry
7105 .get_or_spawn(
7106 "127.0.0.1",
7107 port,
7108 1024 * 1024,
7109 10 * 1024 * 1024,
7110 16,
7111 rt,
7112 "test-route-2".into(),
7113 None,
7114 )
7115 .await
7116 .unwrap();
7117
7118 let key = ("127.0.0.1".to_string(), port);
7119 let cell = {
7120 let guard = registry.inner.lock().expect("lock");
7121 guard.entries.get(&key).expect("entry should exist").clone()
7122 };
7123
7124 registry.unregister("127.0.0.1", port).await;
7126 {
7127 let handle = cell
7128 .get()
7129 .expect("handle should still exist after first unregister");
7130 assert!(
7131 !handle.monitor_task.is_finished(),
7132 "monitor task should still be alive after first unregister"
7133 );
7134 }
7135
7136 registry.unregister("127.0.0.1", port).await;
7138 tokio::time::sleep(Duration::from_millis(20)).await;
7139 {
7140 let handle = cell
7141 .get()
7142 .expect("handle should still exist after last unregister");
7143 assert!(
7144 !handle.monitor_task.is_finished(),
7145 "monitor task should still be alive — server is process-lifetime"
7146 );
7147 }
7148
7149 {
7151 let guard = registry.inner.lock().expect("lock");
7152 assert!(
7153 guard.entries.contains_key(&key),
7154 "entry should remain in registry — server kept alive for restart"
7155 );
7156 }
7157 }
7158
7159 async fn clone_fixture_listener() -> (
7168 tokio::net::TcpListener,
7169 std::net::TcpListener,
7170 std::net::SocketAddr,
7171 ) {
7172 let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind std listener");
7173 let probe = l.try_clone().expect("clone probe");
7174 l.set_nonblocking(true).expect("set_nonblocking");
7175 let listener = tokio::net::TcpListener::from_std(l).expect("from_std");
7176 let addr = listener.local_addr().expect("local_addr");
7177 (listener, probe, addr)
7178 }
7179
7180 fn staged_limits() -> (usize, usize, usize) {
7182 (1024 * 1024, 10 * 1024 * 1024, 1024)
7183 }
7184
7185 #[allow(clippy::await_holding_lock)]
7186 #[tokio::test]
7187 async fn staged_listener_first_spawn_serves_without_second_bind() {
7188 let _guard = lock_registry_test_mutex();
7189 ServerRegistry::reset();
7190 let registry = ServerRegistry::global();
7191 let (listener, _probe, addr) = clone_fixture_listener().await;
7192 let port = addr.port();
7193 registry
7194 .stage_listener(listener)
7195 .await
7196 .expect("stage listener");
7197
7198 let (max_req, max_res, max_inflight) = staged_limits();
7199 let routes = registry
7200 .get_or_spawn(
7201 "127.0.0.1",
7202 port,
7203 max_req,
7204 max_res,
7205 max_inflight,
7206 test_rt(),
7207 "staged-first-spawn".into(),
7208 None,
7209 )
7210 .await
7211 .expect("spawn from staged listener must succeed");
7212
7213 assert_eq!(
7214 registry.bound_addr("127.0.0.1", port),
7215 Some(addr),
7216 "served socket must be the staged listener's addr"
7217 );
7218 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__staged_probe__"))
7221 .await
7222 .expect("http request against staged listener must connect");
7223 assert!(
7224 resp.status().as_u16() >= 200,
7225 "any status proves the staged socket serves"
7226 );
7227 drop(routes);
7228 }
7229
7230 #[allow(clippy::await_holding_lock)]
7231 #[tokio::test]
7232 async fn staged_entry_reused_by_second_caller() {
7233 let _guard = lock_registry_test_mutex();
7234 ServerRegistry::reset();
7235 let registry = ServerRegistry::global();
7236 let (listener, _probe, addr) = clone_fixture_listener().await;
7237 let port = addr.port();
7238 registry
7239 .stage_listener(listener)
7240 .await
7241 .expect("stage listener");
7242
7243 let (max_req, max_res, max_inflight) = staged_limits();
7244 let first = registry
7245 .get_or_spawn(
7246 "127.0.0.1",
7247 port,
7248 max_req,
7249 max_res,
7250 max_inflight,
7251 test_rt(),
7252 "staged-reuse-1".into(),
7253 None,
7254 )
7255 .await
7256 .expect("first spawn from staged listener");
7257 let second = registry
7258 .get_or_spawn(
7259 "127.0.0.1",
7260 port,
7261 max_req,
7262 max_res,
7263 max_inflight,
7264 test_rt(),
7265 "staged-reuse-2".into(),
7266 None,
7267 )
7268 .await
7269 .expect("second caller must reuse the entry");
7270 assert_eq!(
7271 registry.bound_addr("127.0.0.1", port),
7272 Some(addr),
7273 "entry reused — bound addr unchanged, no second bind"
7274 );
7275 drop(first);
7276 drop(second);
7277 }
7278
7279 #[allow(clippy::await_holding_lock)]
7280 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7281 async fn staged_race_two_callers_single_resolver() {
7282 let _guard = lock_registry_test_mutex();
7283 ServerRegistry::reset();
7284 let registry = ServerRegistry::global();
7285 let (listener, _probe, addr) = clone_fixture_listener().await;
7286 let port = addr.port();
7287 registry
7288 .stage_listener(listener)
7289 .await
7290 .expect("stage listener");
7291
7292 let (max_req, max_res, max_inflight) = staged_limits();
7297 let (first, second) = tokio::join!(
7298 registry.get_or_spawn(
7299 "127.0.0.1",
7300 port,
7301 max_req,
7302 max_res,
7303 max_inflight,
7304 test_rt(),
7305 "staged-race-1".into(),
7306 None,
7307 ),
7308 registry.get_or_spawn(
7309 "127.0.0.1",
7310 port,
7311 max_req,
7312 max_res,
7313 max_inflight,
7314 test_rt(),
7315 "staged-race-2".into(),
7316 None,
7317 ),
7318 );
7319 let first = first.expect("first racing caller must succeed");
7320 let second = second.expect("second racing caller must succeed");
7321 assert_eq!(
7322 registry.bound_addr("127.0.0.1", port),
7323 Some(addr),
7324 "single entry must be served from the staged socket — no EADDRINUSE path"
7325 );
7326 drop(first);
7327 drop(second);
7328 }
7329
7330 #[allow(clippy::await_holding_lock)]
7331 #[tokio::test]
7332 async fn unstaged_spawn_binds_legacy() {
7333 let _guard = lock_registry_test_mutex();
7334 ServerRegistry::reset();
7335 let registry = ServerRegistry::global();
7336 let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe bind");
7338 let port = probe.local_addr().expect("local addr").port();
7339 drop(probe);
7340
7341 let (max_req, max_res, max_inflight) = staged_limits();
7342 registry
7343 .get_or_spawn(
7344 "127.0.0.1",
7345 port,
7346 max_req,
7347 max_res,
7348 max_inflight,
7349 test_rt(),
7350 "legacy-bind".into(),
7351 None,
7352 )
7353 .await
7354 .expect("legacy bind spawn");
7355 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__legacy_probe__"))
7356 .await
7357 .expect("connect to freshly bound port must succeed");
7358 assert!(resp.status().as_u16() >= 200);
7359 assert_eq!(
7360 registry.bound_addr("127.0.0.1", port),
7361 Some(std::net::SocketAddr::from(([127, 0, 0, 1], port))),
7362 "bound addr must be the legacy bound (host, port)"
7363 );
7364 }
7365
7366 #[allow(clippy::await_holding_lock)]
7367 #[tokio::test]
7368 async fn wrong_host_staged_port_fails_deterministically() {
7369 let _guard = lock_registry_test_mutex();
7370 ServerRegistry::reset();
7371 let registry = ServerRegistry::global();
7372 let (listener, _probe, addr) = clone_fixture_listener().await;
7373 let port = addr.port();
7374 registry
7375 .stage_listener(listener)
7376 .await
7377 .expect("stage listener under 127.0.0.1");
7378
7379 let (max_req, max_res, max_inflight) = staged_limits();
7380 let err = registry
7381 .get_or_spawn(
7382 "localhost",
7383 port,
7384 max_req,
7385 max_res,
7386 max_inflight,
7387 test_rt(),
7388 "conflict-probe".into(),
7389 None,
7390 )
7391 .await
7392 .expect_err("wrong host on staged port must fail deterministically");
7393 assert!(
7394 err.to_string().contains("staged listener conflict on port"),
7395 "unexpected error: {err}"
7396 );
7397
7398 registry
7400 .get_or_spawn(
7401 "127.0.0.1",
7402 port,
7403 max_req,
7404 max_res,
7405 max_inflight,
7406 test_rt(),
7407 "conflict-after".into(),
7408 None,
7409 )
7410 .await
7411 .expect("correct host must serve the staged listener");
7412 assert_eq!(
7413 registry.bound_addr("127.0.0.1", port),
7414 Some(addr),
7415 "staged slot must be untouched by the conflicting call"
7416 );
7417 }
7418
7419 #[allow(clippy::await_holding_lock)]
7420 #[tokio::test]
7421 async fn duplicate_stage_same_key_rejected() {
7422 let _guard = lock_registry_test_mutex();
7423 ServerRegistry::reset();
7424 let registry = ServerRegistry::global();
7425 let (listener, probe, addr) = clone_fixture_listener().await;
7426 registry
7427 .stage_listener(listener)
7428 .await
7429 .expect("stage listener A");
7430
7431 let dup = probe.try_clone().expect("clone2");
7433 dup.set_nonblocking(true).expect("set_nonblocking2");
7434 let b = tokio::net::TcpListener::from_std(dup).expect("from_std2");
7435
7436 let err = registry
7437 .stage_listener(b)
7438 .await
7439 .expect_err("duplicate stage must be rejected");
7440 assert!(
7441 err.to_string().contains("listener already staged"),
7442 "unexpected error: {err}"
7443 );
7444
7445 let (max_req, max_res, max_inflight) = staged_limits();
7446 registry
7447 .get_or_spawn(
7448 "127.0.0.1",
7449 addr.port(),
7450 max_req,
7451 max_res,
7452 max_inflight,
7453 test_rt(),
7454 "dup-stage-after".into(),
7455 None,
7456 )
7457 .await
7458 .expect("spawn from first staged listener");
7459 assert_eq!(
7460 registry.bound_addr("127.0.0.1", addr.port()),
7461 Some(addr),
7462 "first staged listener retained"
7463 );
7464 }
7465
7466 #[allow(clippy::await_holding_lock)]
7467 #[tokio::test]
7468 async fn distinct_keys_stage_independently() {
7469 let _guard = lock_registry_test_mutex();
7470 ServerRegistry::reset();
7471 let registry = ServerRegistry::global();
7472 let (l1, _p1, addr1) = clone_fixture_listener().await;
7473 let (l2, _p2, addr2) = clone_fixture_listener().await;
7474 registry.stage_listener(l1).await.expect("stage P1");
7475 registry.stage_listener(l2).await.expect("stage P2");
7476
7477 let (max_req, max_res, max_inflight) = staged_limits();
7478 registry
7479 .get_or_spawn(
7480 "127.0.0.1",
7481 addr1.port(),
7482 max_req,
7483 max_res,
7484 max_inflight,
7485 test_rt(),
7486 "distinct-1".into(),
7487 None,
7488 )
7489 .await
7490 .expect("spawn P1");
7491 registry
7492 .get_or_spawn(
7493 "127.0.0.1",
7494 addr2.port(),
7495 max_req,
7496 max_res,
7497 max_inflight,
7498 test_rt(),
7499 "distinct-2".into(),
7500 None,
7501 )
7502 .await
7503 .expect("spawn P2");
7504 assert_eq!(
7505 registry.bound_addr("127.0.0.1", addr1.port()),
7506 Some(addr1),
7507 "P1 bound addr must be its own listener"
7508 );
7509 assert_eq!(
7510 registry.bound_addr("127.0.0.1", addr2.port()),
7511 Some(addr2),
7512 "P2 bound addr must be its own listener"
7513 );
7514 let r1 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr1.port()))
7515 .await
7516 .expect("connect P1");
7517 assert!(r1.status().as_u16() >= 200);
7518 let r2 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr2.port()))
7519 .await
7520 .expect("connect P2");
7521 assert!(r2.status().as_u16() >= 200);
7522 }
7523
7524 #[allow(clippy::await_holding_lock)]
7525 #[tokio::test]
7526 async fn tls_prebound_listener_served() {
7527 use camel_component_api::test_support::tls;
7528
7529 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7532
7533 let _guard = lock_registry_test_mutex();
7534 ServerRegistry::reset();
7535 let registry = ServerRegistry::global();
7536 let (listener, _probe, addr) = clone_fixture_listener().await;
7537 let port = addr.port();
7538
7539 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
7540 let cert_path = tls::write_pem_tmp("http-staged-tls-cert.pem", &cert_pem);
7541 let key_path = tls::write_pem_tmp("http-staged-tls-key.pem", &key_pem);
7542 let ca_path = tls::write_pem_tmp("http-staged-tls-ca.pem", &ca_pem);
7543
7544 let (max_req, max_res, max_inflight) = staged_limits();
7545 let routes = registry
7546 .get_or_spawn_with_listener(
7547 listener,
7548 max_req,
7549 max_res,
7550 max_inflight,
7551 test_rt(),
7552 "staged-tls".into(),
7553 Some(crate::config::ServerTlsConfig {
7554 cert_path: cert_path.to_string_lossy().into_owned(),
7555 key_path: key_path.to_string_lossy().into_owned(),
7556 }),
7557 )
7558 .await
7559 .expect("spawn TLS server from pre-bound listener");
7560
7561 let ca_bytes = std::fs::read(&ca_path).expect("read ca pem");
7564 let client = reqwest::Client::builder()
7565 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).expect("parse ca pem"))
7566 .build()
7567 .expect("build tls client");
7568
7569 let resp = client
7570 .get(format!("https://127.0.0.1:{port}/__staged_tls__"))
7571 .send()
7572 .await
7573 .expect("TLS handshake + request must succeed");
7574 assert_eq!(resp.status().as_u16(), 404, "unknown path 404s through TLS");
7575 assert_eq!(
7576 registry.bound_addr("127.0.0.1", port),
7577 Some(addr),
7578 "bound addr equals the pre-bound listener addr"
7579 );
7580 drop(routes);
7581 }
7582
7583 #[allow(clippy::await_holding_lock)]
7584 #[tokio::test]
7585 async fn with_listener_direct_spawn_keyed_by_actual_addr() {
7586 let _guard = lock_registry_test_mutex();
7587 ServerRegistry::reset();
7588 let registry = ServerRegistry::global();
7589 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
7590 .await
7591 .expect("bind un-staged listener");
7592 let addr = listener.local_addr().expect("local addr");
7593 let port = addr.port();
7594
7595 let (max_req, max_res, max_inflight) = staged_limits();
7596 registry
7597 .get_or_spawn_with_listener(
7598 listener,
7599 max_req,
7600 max_res,
7601 max_inflight,
7602 test_rt(),
7603 "with-listener".into(),
7604 None,
7605 )
7606 .await
7607 .expect("direct spawn from un-staged listener");
7608 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__with_listener__"))
7609 .await
7610 .expect("connect on actual port");
7611 assert!(resp.status().as_u16() >= 200);
7612 assert_eq!(
7613 registry.bound_addr("127.0.0.1", port),
7614 Some(addr),
7615 "registry key is the listener's actual port"
7616 );
7617
7618 registry
7619 .get_or_spawn(
7620 "127.0.0.1",
7621 port,
7622 max_req,
7623 max_res,
7624 max_inflight,
7625 test_rt(),
7626 "with-listener-reuse".into(),
7627 None,
7628 )
7629 .await
7630 .expect("legacy caller must reuse the entry");
7631 assert_eq!(
7632 registry.bound_addr("127.0.0.1", port),
7633 Some(addr),
7634 "entry reused — no second bind"
7635 );
7636 }
7637
7638 #[tokio::test]
7643 async fn test_dispatch_handler_returns_404_for_unknown_path() {
7644 let registry = HttpRouteRegistry::new();
7645 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7647 let port = listener.local_addr().unwrap().port();
7648 tokio::spawn(run_axum_server(
7649 listener,
7650 registry,
7651 2 * 1024 * 1024,
7652 10 * 1024 * 1024,
7653 Arc::new(tokio::sync::Semaphore::new(1024)),
7654 test_rt(),
7655 "test-route".into(),
7656 ));
7657
7658 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
7660
7661 let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
7662 .await
7663 .unwrap();
7664 assert_eq!(resp.status().as_u16(), 404);
7665 }
7666
7667 #[tokio::test]
7672 async fn test_http_consumer_start_registers_path() {
7673 use camel_component_api::ConsumerContext;
7674
7675 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7677 let port = listener.local_addr().unwrap().port();
7678 drop(listener); let consumer_cfg = HttpServerConfig {
7681 scheme: "http".to_string(),
7682 host: "127.0.0.1".to_string(),
7683 port,
7684 path: "/ping".to_string(),
7685 max_request_body: 2 * 1024 * 1024,
7686 max_response_body: 10 * 1024 * 1024,
7687 max_inflight_requests: 1024,
7688 method: None,
7689 tls_config: None,
7690 };
7691 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7692
7693 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7694 let token = tokio_util::sync::CancellationToken::new();
7695 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7696
7697 tokio::spawn(async move {
7698 consumer.start(ctx).await.unwrap();
7699 });
7700
7701 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7702
7703 let client = reqwest::Client::new();
7704 let resp_future = client
7705 .post(format!("http://127.0.0.1:{port}/ping"))
7706 .body("hello world")
7707 .send();
7708
7709 let (http_result, _) = tokio::join!(resp_future, async {
7710 if let Some(mut envelope) = rx.recv().await {
7711 envelope.exchange.input.set_header(
7713 "CamelHttpResponseCode",
7714 serde_json::Value::Number(201.into()),
7715 );
7716 if let Some(reply_tx) = envelope.reply_tx {
7717 let _ = reply_tx.send(Ok(envelope.exchange));
7718 }
7719 }
7720 });
7721
7722 let resp = http_result.unwrap();
7723 assert_eq!(resp.status().as_u16(), 201);
7724
7725 token.cancel();
7726 }
7727
7728 #[tokio::test]
7733 async fn http_consumer_raw_dispatch_carries_in_flight_claim() {
7734 use std::sync::atomic::{AtomicU64, Ordering};
7735
7736 use camel_component_api::ConsumerContext;
7737 use camel_component_api::StartupSignal;
7738
7739 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7740 let port = listener.local_addr().unwrap().port();
7741 drop(listener);
7742
7743 let consumer_cfg = HttpServerConfig {
7744 scheme: "http".to_string(),
7745 host: "127.0.0.1".to_string(),
7746 port,
7747 path: "/claim".to_string(),
7748 max_request_body: 2 * 1024 * 1024,
7749 max_response_body: 10 * 1024 * 1024,
7750 max_inflight_requests: 1024,
7751 method: None,
7752 tls_config: None,
7753 };
7754 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7755
7756 let counter = std::sync::Arc::new(AtomicU64::new(0));
7757 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7758 let token = tokio_util::sync::CancellationToken::new();
7759 let (signal, startup_rx) = StartupSignal::pair();
7760 let ctx = ConsumerContext::new(tx, token.clone(), "http-claim-route".to_string())
7761 .with_startup(signal)
7762 .with_in_flight_counter(std::sync::Arc::clone(&counter));
7763
7764 tokio::spawn(async move {
7765 consumer.start(ctx).await.unwrap();
7766 });
7767
7768 tokio::time::timeout(std::time::Duration::from_secs(5), startup_rx.await_ready())
7771 .await
7772 .expect("startup must resolve within 5s")
7773 .expect("startup must be Ok");
7774
7775 let client = reqwest::Client::new();
7776 let (http_result, claim) = tokio::join!(
7777 client
7778 .post(format!("http://127.0.0.1:{port}/claim"))
7779 .body("hello")
7780 .send(),
7781 async {
7782 let mut envelope = rx.recv().await.expect("envelope must arrive");
7783 let claim = envelope
7784 .in_flight_claim
7785 .take()
7786 .expect("raw dispatch must carry an acceptance-minted claim");
7787 assert_eq!(
7788 counter.load(Ordering::Acquire),
7789 1,
7790 "exact total: the acceptance mint is the only live claim"
7791 );
7792 let reply_tx = envelope.reply_tx.take().expect("reply channel must be set");
7796 let reply_exchange = Exchange::new(camel_component_api::Message::new(
7797 camel_component_api::Body::Text("done".to_string()),
7798 ));
7799 reply_tx
7800 .send(Ok(reply_exchange))
7801 .expect("reply must be taken");
7802 claim
7803 },
7804 );
7805
7806 let resp = http_result.expect("http roundtrip must complete");
7807 assert_eq!(resp.status().as_u16(), 200);
7808
7809 drop(claim);
7810 assert_eq!(
7811 counter.load(Ordering::Acquire),
7812 0,
7813 "release exactly once when the holder drops"
7814 );
7815 token.cancel();
7816 }
7817
7818 #[test]
7822 fn test_envelope_channel_capacity_follows_max_inflight() {
7823 assert_eq!(envelope_channel_capacity(0), 1);
7824 assert_eq!(envelope_channel_capacity(1), 1);
7825 assert_eq!(envelope_channel_capacity(7), 7);
7826 assert_eq!(envelope_channel_capacity(64), 64);
7827 assert_eq!(envelope_channel_capacity(1024), 1024);
7828 }
7829
7830 #[tokio::test]
7834 async fn test_http_consumer_start_with_zero_max_inflight_rejects_503() {
7835 use camel_component_api::ConsumerContext;
7836
7837 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7838 let port = listener.local_addr().unwrap().port();
7839 drop(listener);
7840
7841 let consumer_cfg = HttpServerConfig {
7842 scheme: "http".to_string(),
7843 host: "127.0.0.1".to_string(),
7844 port,
7845 path: "/ping".to_string(),
7846 max_request_body: 2 * 1024 * 1024,
7847 max_response_body: 10 * 1024 * 1024,
7848 max_inflight_requests: 0,
7849 method: None,
7850 tls_config: None,
7851 };
7852 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7853
7854 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7855 let token = tokio_util::sync::CancellationToken::new();
7856 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7857
7858 let start_handle = tokio::spawn(async move {
7859 consumer.start(ctx).await.unwrap();
7860 });
7861
7862 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7863
7864 let client = reqwest::Client::new();
7865 let resp = client
7866 .post(format!("http://127.0.0.1:{port}/ping"))
7867 .body("hello world")
7868 .send()
7869 .await
7870 .unwrap();
7871 assert_eq!(resp.status().as_u16(), 503);
7872
7873 token.cancel();
7874 let _ = start_handle.await;
7875 }
7876
7877 #[test]
7880 fn test_http_consumer_startup_mode_is_explicit() {
7881 use camel_component_api::ConsumerStartupMode;
7882 let consumer_cfg = HttpServerConfig {
7883 scheme: "http".to_string(),
7884 host: "127.0.0.1".to_string(),
7885 port: 0,
7886 path: "/x".to_string(),
7887 max_request_body: 2 * 1024 * 1024,
7888 max_response_body: 10 * 1024 * 1024,
7889 max_inflight_requests: 1024,
7890 method: None,
7891 tls_config: None,
7892 };
7893 let consumer = HttpConsumer::new(consumer_cfg, test_rt());
7894 assert_eq!(
7895 consumer.startup_mode(),
7896 ConsumerStartupMode::Explicit,
7897 "HttpConsumer must opt into Explicit startup"
7898 );
7899 }
7900
7901 #[allow(clippy::await_holding_lock)]
7907 #[tokio::test]
7908 async fn test_http_consumer_emits_mark_ready_after_bind() {
7909 use camel_component_api::{ConsumerContext, StartupSignal};
7910
7911 let _guard = lock_registry_test_mutex();
7912
7913 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7914 let port = listener.local_addr().unwrap().port();
7915 drop(listener);
7916
7917 let consumer_cfg = HttpServerConfig {
7918 scheme: "http".to_string(),
7919 host: "127.0.0.1".to_string(),
7920 port,
7921 path: "/ready-probe".to_string(),
7922 max_request_body: 2 * 1024 * 1024,
7923 max_response_body: 10 * 1024 * 1024,
7924 max_inflight_requests: 1024,
7925 method: None,
7926 tls_config: None,
7927 };
7928 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7929
7930 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7931 let token = tokio_util::sync::CancellationToken::new();
7932 let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
7933
7934 let (signal, startup_rx) = StartupSignal::pair();
7936 let ctx = ctx.with_startup(signal);
7937
7938 tokio::spawn(async move {
7941 let _ = consumer.start(ctx).await;
7942 });
7943
7944 let result =
7949 tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
7950 .await
7951 .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
7952 assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
7953
7954 token.cancel();
7956 }
7957
7958 #[derive(Default, Clone)]
7965 struct ErrorRecordingRuntime {
7966 errors: std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>>,
7967 }
7968
7969 impl camel_api::MetricsCollector for ErrorRecordingRuntime {
7970 fn record_exchange_duration(&self, _route_id: &str, _duration: std::time::Duration) {}
7971 fn increment_errors(&self, route_id: &str, error_type: &str) {
7972 self.errors
7973 .lock()
7974 .expect("error recorder lock")
7975 .push((route_id.to_string(), error_type.to_string()));
7976 }
7977 fn increment_exchanges(&self, _route_id: &str) {}
7978 fn set_queue_depth(&self, _queue: &str, _depth: usize) {}
7979 fn record_circuit_breaker_change(&self, _route_id: &str, _from: &str, _to: &str) {}
7980 }
7981
7982 impl camel_component_api::HealthCheckRegistry for ErrorRecordingRuntime {
7983 fn force_unhealthy_for_route(&self, _route_id: &str, _name: &str, _reason: &str) {}
7984 }
7985
7986 impl camel_component_api::RuntimeObservability for ErrorRecordingRuntime {
7987 fn metrics(&self) -> std::sync::Arc<dyn camel_api::MetricsCollector> {
7988 std::sync::Arc::new(self.clone())
7989 }
7990 fn health(&self) -> std::sync::Arc<dyn camel_component_api::HealthCheckRegistry> {
7991 std::sync::Arc::new(self.clone())
7992 }
7993 }
7994
7995 #[allow(clippy::await_holding_lock)]
8010 #[tokio::test]
8011 async fn shared_server_death_fails_every_hosted_consumer() {
8012 use camel_component_api::{ConsumerContext, StartupSignal};
8013
8014 let _guard = lock_registry_test_mutex();
8015 ServerRegistry::reset();
8016
8017 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8019 let port = listener.local_addr().unwrap().port();
8020 drop(listener);
8021
8022 let rt = ErrorRecordingRuntime::default();
8023
8024 let make_consumer = |path: &str| {
8025 HttpConsumer::new(
8026 HttpServerConfig {
8027 scheme: "http".to_string(),
8028 host: "127.0.0.1".to_string(),
8029 port,
8030 path: path.to_string(),
8031 max_request_body: 2 * 1024 * 1024,
8032 max_response_body: 10 * 1024 * 1024,
8033 max_inflight_requests: 16,
8034 method: None,
8035 tls_config: None,
8036 },
8037 std::sync::Arc::new(rt.clone()),
8038 )
8039 };
8040
8041 let spawn_consumer = |path: &str, route_id: &str| {
8042 let mut consumer = make_consumer(path);
8043 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8044 let token = tokio_util::sync::CancellationToken::new();
8045 let ctx = ConsumerContext::new(tx, token, route_id.to_string());
8046 let (signal, startup_rx) = StartupSignal::pair();
8047 let ctx = ctx.with_startup(signal);
8048 let task = tokio::spawn(async move { consumer.start(ctx).await });
8049 (task, startup_rx)
8050 };
8051
8052 let (task_a, ready_a) = spawn_consumer("/zombie-a", "zombie-route-a");
8054 let (task_b, ready_b) = spawn_consumer("/zombie-b", "zombie-route-b");
8055
8056 for (name, ready) in [("a", ready_a), ("b", ready_b)] {
8058 let result =
8059 tokio::time::timeout(std::time::Duration::from_secs(2), ready.await_ready())
8060 .await
8061 .unwrap_or_else(|_| panic!("consumer {name} never became ready"));
8062 assert!(
8063 result.is_ok(),
8064 "consumer {name} readiness must resolve Ok (bind + registration complete)"
8065 );
8066 }
8067
8068 {
8071 let registry = ServerRegistry::global();
8072 let guard = registry.inner.lock().expect("ServerRegistry lock");
8073 let cell = guard
8074 .entries
8075 .get(&("127.0.0.1".to_string(), port))
8076 .expect("shared server entry must exist");
8077 let handle = cell.get().expect("server handle must be initialized");
8078 handle.server_abort.abort();
8079 }
8080
8081 let outcome_a = tokio::time::timeout(std::time::Duration::from_secs(2), task_a)
8084 .await
8085 .expect("ZOMBIE: consumer-a still running after shared server death (rc-szmob)");
8086 let outcome_b = tokio::time::timeout(std::time::Duration::from_secs(2), task_b)
8087 .await
8088 .expect("ZOMBIE: consumer-b still running after shared server death (rc-szmob)");
8089
8090 let err_a = outcome_a
8091 .expect("consumer-a task must join")
8092 .expect_err("consumer-a start() must return Err when the shared server dies");
8093 let err_b = outcome_b
8094 .expect("consumer-b task must join")
8095 .expect_err("consumer-b start() must return Err when the shared server dies");
8096
8097 for (name, err) in [("a", &err_a), ("b", &err_b)] {
8100 assert!(
8101 err.to_string().contains("127.0.0.1")
8102 && err.to_string().contains(&port.to_string()),
8103 "consumer-{name} error must name the dead shared server, got: {err}"
8104 );
8105 }
8106
8107 let recorded = rt.errors.lock().expect("error recorder lock").clone();
8110 assert!(
8111 recorded
8112 .iter()
8113 .any(|(route, label)| label == "e:http:server-task-exited"
8114 && route == "zombie-route-a"),
8115 "expected e:http:server-task-exited for the spawning route, got: {recorded:?}"
8116 );
8117 }
8118
8119 #[tokio::test]
8120 async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
8121 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8122
8123 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8124 let port = listener.local_addr().unwrap().port();
8125 drop(listener);
8126
8127 let consumer_cfg = HttpServerConfig {
8128 scheme: "http".to_string(),
8129 host: "127.0.0.1".to_string(),
8130 port,
8131 path: "/saturation".to_string(),
8132 max_request_body: 2 * 1024 * 1024,
8133 max_response_body: 10 * 1024 * 1024,
8134 max_inflight_requests: 1,
8135 method: None,
8136 tls_config: None,
8137 };
8138 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8139
8140 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8141 let token = tokio_util::sync::CancellationToken::new();
8142 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8143 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8144 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8145
8146 let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
8147 let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
8148
8149 tokio::spawn(async move {
8150 let mut first_seen_tx = Some(first_seen_tx);
8151 let mut unblock_first_rx = Some(unblock_first_rx);
8152
8153 while let Some(envelope) = rx.recv().await {
8154 if let Some(tx) = first_seen_tx.take() {
8155 let _ = tx.send(());
8156 if let Some(rx_unblock) = unblock_first_rx.take() {
8157 let _ = rx_unblock.await;
8158 }
8159 }
8160
8161 if let Some(reply_tx) = envelope.reply_tx {
8162 let _ = reply_tx.send(Ok(envelope.exchange));
8163 }
8164 }
8165 });
8166
8167 let client = reqwest::Client::new();
8168 let first_req = {
8169 let client = client.clone();
8170 async move {
8171 client
8172 .get(format!("http://127.0.0.1:{port}/saturation"))
8173 .send()
8174 .await
8175 .unwrap()
8176 }
8177 };
8178
8179 let first_handle = tokio::spawn(first_req);
8180 first_seen_rx.await.unwrap();
8181
8182 let second_resp = client
8183 .get(format!("http://127.0.0.1:{port}/saturation"))
8184 .send()
8185 .await
8186 .unwrap();
8187
8188 assert_eq!(second_resp.status().as_u16(), 503);
8189
8190 let _ = unblock_first_tx.send(());
8191 let first_resp = first_handle.await.unwrap();
8192 assert_eq!(first_resp.status().as_u16(), 200);
8193
8194 token.cancel();
8195 }
8196
8197 #[tokio::test]
8201 async fn test_http_consumer_chunked_body_is_capped() {
8202 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8203
8204 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8205 let port = listener.local_addr().unwrap().port();
8206 drop(listener);
8207
8208 let consumer_cfg = HttpServerConfig {
8209 scheme: "http".to_string(),
8210 host: "127.0.0.1".to_string(),
8211 port,
8212 path: "/chunked-cap".to_string(),
8213 max_request_body: 1024, max_response_body: 10 * 1024 * 1024,
8215 max_inflight_requests: 16,
8216 method: None,
8217 tls_config: None,
8218 };
8219 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8220
8221 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8222 let token = tokio_util::sync::CancellationToken::new();
8223 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8224 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8225 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8226
8227 let chunks: Vec<Result<bytes::Bytes, std::io::Error>> = (0..8)
8229 .map(|_| Ok(bytes::Bytes::from(vec![b'A'; 512])))
8230 .collect();
8231 let stream_body = reqwest::Body::wrap_stream(futures::stream::iter(chunks));
8232
8233 let client = reqwest::Client::new();
8234 let send_fut = client
8235 .post(format!("http://127.0.0.1:{port}/chunked-cap"))
8236 .body(stream_body)
8237 .send();
8238
8239 let (http_result, _) = tokio::join!(send_fut, async {
8240 if let Some(mut envelope) = rx.recv().await {
8241 let materialized = envelope
8243 .exchange
8244 .input
8245 .body
8246 .clone()
8247 .into_bytes(64 * 1024)
8248 .await;
8249 assert!(
8250 materialized.is_err(),
8251 "materializing a 4 KiB chunked body under a 1 KiB cap must fail"
8252 );
8253 let err = materialized.unwrap_err().to_string();
8254 assert!(
8255 err.contains("limit") || err.contains("exceeds"),
8256 "error should mention the limit: {err}"
8257 );
8258 if let Some(reply_tx) = envelope.reply_tx {
8259 envelope.exchange.input.body =
8260 camel_component_api::Body::Text("handled".to_string());
8261 let _ = reply_tx.send(Ok(envelope.exchange));
8262 }
8263 }
8264 });
8265
8266 let resp = http_result.unwrap();
8267 assert_eq!(resp.status().as_u16(), 200);
8268
8269 token.cancel();
8270 }
8271
8272 #[tokio::test]
8273 #[allow(clippy::await_holding_lock)]
8274 async fn test_http_consumer_enforces_max_response_body_for_bytes() {
8275 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8276
8277 let _guard = lock_registry_test_mutex();
8278
8279 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8280 let port = listener.local_addr().unwrap().port();
8281 drop(listener);
8282
8283 let consumer_cfg = HttpServerConfig {
8284 scheme: "http".to_string(),
8285 host: "127.0.0.1".to_string(),
8286 port,
8287 path: "/limit-bytes".to_string(),
8288 max_request_body: 2 * 1024 * 1024,
8289 max_response_body: 16,
8290 max_inflight_requests: 1024,
8291 method: None,
8292 tls_config: None,
8293 };
8294 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8295
8296 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8297 let token = tokio_util::sync::CancellationToken::new();
8298 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8299 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8300 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8301
8302 let client = reqwest::Client::new();
8303 let send_fut = client
8304 .get(format!("http://127.0.0.1:{port}/limit-bytes"))
8305 .send();
8306
8307 let (http_result, _) = tokio::join!(send_fut, async {
8308 if let Some(mut envelope) = rx.recv().await {
8309 envelope.exchange.input.body =
8310 camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
8311 if let Some(reply_tx) = envelope.reply_tx {
8312 let _ = reply_tx.send(Ok(envelope.exchange));
8313 }
8314 }
8315 });
8316
8317 let resp = http_result.unwrap();
8318 assert_eq!(resp.status().as_u16(), 500);
8319 let body = resp.text().await.unwrap();
8320 assert_eq!(body, "Response body exceeds configured limit");
8321 token.cancel();
8322 }
8323
8324 #[tokio::test]
8325 #[allow(clippy::await_holding_lock)]
8326 async fn test_http_consumer_enforces_max_response_body_for_json() {
8327 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8328
8329 let _guard = lock_registry_test_mutex();
8330
8331 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8332 let port = listener.local_addr().unwrap().port();
8333 drop(listener);
8334
8335 let consumer_cfg = HttpServerConfig {
8336 scheme: "http".to_string(),
8337 host: "127.0.0.1".to_string(),
8338 port,
8339 path: "/limit-json".to_string(),
8340 max_request_body: 2 * 1024 * 1024,
8341 max_response_body: 16,
8342 max_inflight_requests: 1024,
8343 method: None,
8344 tls_config: None,
8345 };
8346 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8347
8348 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8349 let token = tokio_util::sync::CancellationToken::new();
8350 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8351 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8352 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8353
8354 let client = reqwest::Client::new();
8355 let send_fut = client
8356 .get(format!("http://127.0.0.1:{port}/limit-json"))
8357 .send();
8358
8359 let (http_result, _) = tokio::join!(send_fut, async {
8360 if let Some(mut envelope) = rx.recv().await {
8361 envelope.exchange.input.body = camel_component_api::Body::Json(
8362 serde_json::json!({"message":"this response is bigger than sixteen"}),
8363 );
8364 if let Some(reply_tx) = envelope.reply_tx {
8365 let _ = reply_tx.send(Ok(envelope.exchange));
8366 }
8367 }
8368 });
8369
8370 let resp = http_result.unwrap();
8371 assert_eq!(resp.status().as_u16(), 500);
8372 let body = resp.text().await.unwrap();
8373 assert_eq!(body, "Response body exceeds configured limit");
8374 token.cancel();
8375 }
8376
8377 #[tokio::test]
8378 #[allow(clippy::await_holding_lock)]
8379 async fn test_http_consumer_enforces_max_response_body_for_xml() {
8380 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8381
8382 let _guard = lock_registry_test_mutex();
8383
8384 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8385 let port = listener.local_addr().unwrap().port();
8386 drop(listener);
8387
8388 let consumer_cfg = HttpServerConfig {
8389 scheme: "http".to_string(),
8390 host: "127.0.0.1".to_string(),
8391 port,
8392 path: "/limit-xml".to_string(),
8393 max_request_body: 2 * 1024 * 1024,
8394 max_response_body: 16,
8395 max_inflight_requests: 1024,
8396 method: None,
8397 tls_config: None,
8398 };
8399 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8400
8401 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8402 let token = tokio_util::sync::CancellationToken::new();
8403 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8404 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8405 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8406
8407 let client = reqwest::Client::new();
8408 let send_fut = client
8409 .get(format!("http://127.0.0.1:{port}/limit-xml"))
8410 .send();
8411
8412 let (http_result, _) = tokio::join!(send_fut, async {
8413 if let Some(mut envelope) = rx.recv().await {
8414 envelope.exchange.input.body = camel_component_api::Body::Xml(
8415 "<root><value>way-too-large</value></root>".into(),
8416 );
8417 if let Some(reply_tx) = envelope.reply_tx {
8418 let _ = reply_tx.send(Ok(envelope.exchange));
8419 }
8420 }
8421 });
8422
8423 let resp = http_result.unwrap();
8424 assert_eq!(resp.status().as_u16(), 500);
8425 let body = resp.text().await.unwrap();
8426 assert_eq!(body, "Response body exceeds configured limit");
8427 token.cancel();
8428 }
8429
8430 #[tokio::test]
8431 #[allow(clippy::await_holding_lock)]
8432 async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
8433 use camel_component_api::{
8434 CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
8435 };
8436 use futures::stream;
8437
8438 let _guard = lock_registry_test_mutex();
8439
8440 let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
8441 let port = listener.local_addr().unwrap().port();
8442 drop(listener);
8443
8444 let consumer_cfg = HttpServerConfig {
8445 scheme: "http".to_string(),
8446 host: "0.0.0.0".to_string(),
8447 port,
8448 path: "/limit-stream".to_string(),
8449 max_request_body: 2 * 1024 * 1024,
8450 max_response_body: 16,
8451 max_inflight_requests: 1024,
8452 method: None,
8453 tls_config: None,
8454 };
8455 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8456
8457 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8458 let token = tokio_util::sync::CancellationToken::new();
8459 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8460 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8461 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8462
8463 let client = reqwest::Client::new();
8464 let send_fut = client
8465 .get(format!("http://127.0.0.1:{port}/limit-stream"))
8466 .send();
8467
8468 let (http_result, _) = tokio::join!(send_fut, async {
8469 if let Some(mut envelope) = rx.recv().await {
8470 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
8471 vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
8472 let stream = Box::pin(stream::iter(chunks));
8473 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
8474 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
8475 metadata: StreamMetadata {
8476 size_hint: Some(32),
8477 content_type: Some("application/octet-stream".into()),
8478 origin: None,
8479 },
8480 });
8481 if let Some(reply_tx) = envelope.reply_tx {
8482 let _ = reply_tx.send(Ok(envelope.exchange));
8483 }
8484 }
8485 });
8486
8487 let resp = http_result.unwrap();
8488 assert_eq!(resp.status().as_u16(), 200);
8489 let body = resp.bytes().await.unwrap();
8490 assert_eq!(body.len(), 32);
8491 token.cancel();
8492 }
8493
8494 #[tokio::test]
8499 #[allow(clippy::await_holding_lock)]
8500 async fn test_integration_single_consumer_round_trip() {
8501 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8502
8503 let _guard = lock_registry_test_mutex();
8507
8508 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8510 let port = listener.local_addr().unwrap().port();
8511 drop(listener); let component = HttpComponent::new();
8514 let endpoint_ctx = NoOpComponentContext;
8515 let endpoint = component
8516 .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
8517 .unwrap();
8518 let mut consumer = endpoint.create_consumer(rt()).unwrap();
8519
8520 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8521 let token = tokio_util::sync::CancellationToken::new();
8522 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8523
8524 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8525 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8526
8527 let client = reqwest::Client::new();
8528 let send_fut = client
8529 .post(format!("http://127.0.0.1:{port}/echo"))
8530 .header("Content-Type", "text/plain")
8531 .body("ping")
8532 .send();
8533
8534 let (http_result, _) = tokio::join!(send_fut, async {
8535 if let Some(mut envelope) = rx.recv().await {
8536 assert_eq!(
8537 envelope.exchange.input.header("CamelHttpMethod"),
8538 Some(&serde_json::Value::String("POST".into()))
8539 );
8540 assert_eq!(
8541 envelope.exchange.input.header("CamelHttpPath"),
8542 Some(&serde_json::Value::String("/echo".into()))
8543 );
8544 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
8545 if let Some(reply_tx) = envelope.reply_tx {
8546 let _ = reply_tx.send(Ok(envelope.exchange));
8547 }
8548 }
8549 });
8550
8551 let resp = http_result.unwrap();
8552 assert_eq!(resp.status().as_u16(), 200);
8553 let body = resp.text().await.unwrap();
8554 assert_eq!(body, "pong");
8555
8556 token.cancel();
8557 }
8558
8559 #[tokio::test]
8560 #[allow(clippy::await_holding_lock)]
8561 async fn test_integration_two_consumers_shared_port() {
8562 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8563
8564 let _guard = lock_registry_test_mutex();
8565
8566 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8568 let port = listener.local_addr().unwrap().port();
8569 drop(listener);
8570
8571 let component = HttpComponent::new();
8572 let endpoint_ctx = NoOpComponentContext;
8573
8574 let endpoint_a = component
8576 .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
8577 .unwrap();
8578 let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
8579
8580 let endpoint_b = component
8582 .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
8583 .unwrap();
8584 let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
8585
8586 let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8587 let token_a = tokio_util::sync::CancellationToken::new();
8588 let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
8589
8590 let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8591 let token_b = tokio_util::sync::CancellationToken::new();
8592 let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
8593
8594 tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
8595 tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
8596 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8597
8598 let client = reqwest::Client::new();
8599
8600 let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
8602 let (resp_hello, _) = tokio::join!(fut_hello, async {
8603 if let Some(mut envelope) = rx_a.recv().await {
8604 envelope.exchange.input.body =
8605 camel_component_api::Body::Text("hello-response".to_string());
8606 if let Some(reply_tx) = envelope.reply_tx {
8607 let _ = reply_tx.send(Ok(envelope.exchange));
8608 }
8609 }
8610 });
8611
8612 let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
8614 let (resp_world, _) = tokio::join!(fut_world, async {
8615 if let Some(mut envelope) = rx_b.recv().await {
8616 envelope.exchange.input.body =
8617 camel_component_api::Body::Text("world-response".to_string());
8618 if let Some(reply_tx) = envelope.reply_tx {
8619 let _ = reply_tx.send(Ok(envelope.exchange));
8620 }
8621 }
8622 });
8623
8624 let body_a = resp_hello.unwrap().text().await.unwrap();
8625 let body_b = resp_world.unwrap().text().await.unwrap();
8626
8627 assert_eq!(body_a, "hello-response");
8628 assert_eq!(body_b, "world-response");
8629
8630 token_a.cancel();
8631 token_b.cancel();
8632 }
8633
8634 #[tokio::test]
8635 #[allow(clippy::await_holding_lock)]
8636 async fn test_integration_unregistered_path_returns_404() {
8637 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8638
8639 let _guard = lock_registry_test_mutex();
8640
8641 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8643 let port = listener.local_addr().unwrap().port();
8644 drop(listener);
8645
8646 let component = HttpComponent::new();
8647 let endpoint_ctx = NoOpComponentContext;
8648 let endpoint = component
8649 .create_endpoint(
8650 &format!("http://127.0.0.1:{port}/registered"),
8651 &endpoint_ctx,
8652 )
8653 .unwrap();
8654 let mut consumer = endpoint.create_consumer(rt()).unwrap();
8655
8656 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8657 let token = tokio_util::sync::CancellationToken::new();
8658 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8659
8660 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8661
8662 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
8664 loop {
8665 if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
8666 .await
8667 .is_ok()
8668 {
8669 break;
8670 }
8671 if std::time::Instant::now() >= deadline {
8672 panic!("HTTP server did not start within 5s on port {port}");
8673 }
8674 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
8675 }
8676
8677 let client = reqwest::Client::new();
8678 let resp = client
8679 .get(format!("http://127.0.0.1:{port}/not-there"))
8680 .send()
8681 .await
8682 .unwrap();
8683 assert_eq!(resp.status().as_u16(), 404);
8684
8685 token.cancel();
8686 }
8687
8688 #[test]
8689 fn test_http_consumer_declares_concurrent() {
8690 use camel_component_api::ConcurrencyModel;
8691
8692 let config = HttpServerConfig {
8693 scheme: "http".to_string(),
8694 host: "127.0.0.1".to_string(),
8695 port: 19999,
8696 path: "/test".to_string(),
8697 max_request_body: 2 * 1024 * 1024,
8698 max_response_body: 10 * 1024 * 1024,
8699 max_inflight_requests: 1024,
8700 method: None,
8701 tls_config: None,
8702 };
8703 let consumer = HttpConsumer::new(config, test_rt());
8704 assert_eq!(
8705 consumer.concurrency_model(),
8706 ConcurrencyModel::Concurrent { max: None }
8707 );
8708 }
8709
8710 #[test]
8711 fn server_config_parses_tls_cert_and_key() {
8712 let cfg = HttpServerConfig::from_uri(
8713 "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
8714 )
8715 .unwrap();
8716 assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
8717 assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
8718 }
8719
8720 #[test]
8721 fn server_config_no_tls_when_params_absent() {
8722 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
8723 assert!(cfg.tls_config.is_none());
8724 }
8725
8726 #[tokio::test]
8731 async fn test_http_reply_body_stream_variant_exists() {
8732 use bytes::Bytes;
8733 use camel_component_api::CamelError;
8734 use futures::stream;
8735
8736 let chunks: Vec<Result<Bytes, CamelError>> =
8737 vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
8738 let stream = Box::pin(stream::iter(chunks));
8739 let reply_body = HttpReplyBody::Stream(stream);
8740 match reply_body {
8742 HttpReplyBody::Stream(_) => {}
8743 HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
8744 }
8745 }
8746
8747 #[cfg(feature = "otel")]
8752 mod otel_tests {
8753 use super::*;
8754 use camel_component_api::Message;
8755 use tower::ServiceExt;
8756
8757 #[tokio::test]
8758 async fn test_producer_injects_traceparent_header() {
8759 let (url, _handle) = start_test_server_with_header_capture().await;
8760 let ctx = test_producer_ctx();
8761
8762 let component = HttpComponent::new();
8763 let endpoint_ctx = NoOpComponentContext;
8764 let endpoint = component
8765 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
8766 .unwrap();
8767 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
8768
8769 let mut exchange = Exchange::new(Message::default());
8771 let mut headers = std::collections::HashMap::new();
8772 headers.insert(
8773 "traceparent".to_string(),
8774 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
8775 );
8776 camel_otel::extract_into_exchange(&mut exchange, &headers);
8777
8778 let result = producer.oneshot(exchange).await.unwrap();
8779
8780 let status = result
8782 .input
8783 .header("CamelHttpResponseCode")
8784 .and_then(|v| v.as_u64())
8785 .unwrap();
8786 assert_eq!(status, 200);
8787
8788 let traceparent = result.input.header("X-Received-Traceparent");
8790 assert!(
8791 traceparent.is_some(),
8792 "traceparent header should have been sent"
8793 );
8794
8795 let traceparent_str = traceparent.unwrap().as_str().unwrap();
8796 let parts: Vec<&str> = traceparent_str.split('-').collect();
8798 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
8799 assert_eq!(parts[0], "00", "version should be 00");
8800 assert_eq!(
8801 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
8802 "trace-id should match"
8803 );
8804 assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
8805 assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
8806 }
8807
8808 #[tokio::test]
8809 async fn test_consumer_extracts_traceparent_header() {
8810 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8811
8812 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8814 let port = listener.local_addr().unwrap().port();
8815 drop(listener);
8816
8817 let component = HttpComponent::new();
8818 let endpoint_ctx = NoOpComponentContext;
8819 let endpoint = component
8820 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
8821 .unwrap();
8822 let mut consumer = endpoint.create_consumer(rt()).unwrap();
8823
8824 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8825 let token = tokio_util::sync::CancellationToken::new();
8826 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8827
8828 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8829 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8830
8831 let client = reqwest::Client::new();
8833 let send_fut = client
8834 .post(format!("http://127.0.0.1:{port}/trace"))
8835 .header(
8836 "traceparent",
8837 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
8838 )
8839 .body("test")
8840 .send();
8841
8842 let (http_result, _) = tokio::join!(send_fut, async {
8843 if let Some(envelope) = rx.recv().await {
8844 let mut injected_headers = std::collections::HashMap::new();
8847 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
8848
8849 assert!(
8850 injected_headers.contains_key("traceparent"),
8851 "Exchange should have traceparent after extraction"
8852 );
8853
8854 let traceparent = injected_headers.get("traceparent").unwrap();
8855 let parts: Vec<&str> = traceparent.split('-').collect();
8856 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
8857 assert_eq!(
8858 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
8859 "Trace ID should match the original traceparent header"
8860 );
8861
8862 if let Some(reply_tx) = envelope.reply_tx {
8863 let _ = reply_tx.send(Ok(envelope.exchange));
8864 }
8865 }
8866 });
8867
8868 let resp = http_result.unwrap();
8869 assert_eq!(resp.status().as_u16(), 200);
8870
8871 token.cancel();
8872 }
8873
8874 #[tokio::test]
8875 async fn test_consumer_extracts_mixed_case_traceparent_header() {
8876 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8877
8878 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8880 let port = listener.local_addr().unwrap().port();
8881 drop(listener);
8882
8883 let component = HttpComponent::new();
8884 let endpoint_ctx = NoOpComponentContext;
8885 let endpoint = component
8886 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
8887 .unwrap();
8888 let mut consumer = endpoint.create_consumer(rt()).unwrap();
8889
8890 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8891 let token = tokio_util::sync::CancellationToken::new();
8892 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8893
8894 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8895 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8896
8897 let client = reqwest::Client::new();
8899 let send_fut = client
8900 .post(format!("http://127.0.0.1:{port}/trace"))
8901 .header(
8902 "TraceParent",
8903 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
8904 )
8905 .body("test")
8906 .send();
8907
8908 let (http_result, _) = tokio::join!(send_fut, async {
8909 if let Some(envelope) = rx.recv().await {
8910 let mut injected_headers = HashMap::new();
8913 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
8914
8915 assert!(
8916 injected_headers.contains_key("traceparent"),
8917 "Exchange should have traceparent after extraction from mixed-case header"
8918 );
8919
8920 let traceparent = injected_headers.get("traceparent").unwrap();
8921 let parts: Vec<&str> = traceparent.split('-').collect();
8922 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
8923 assert_eq!(
8924 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
8925 "Trace ID should match the original mixed-case TraceParent header"
8926 );
8927
8928 if let Some(reply_tx) = envelope.reply_tx {
8929 let _ = reply_tx.send(Ok(envelope.exchange));
8930 }
8931 }
8932 });
8933
8934 let resp = http_result.unwrap();
8935 assert_eq!(resp.status().as_u16(), 200);
8936
8937 token.cancel();
8938 }
8939
8940 #[tokio::test]
8941 async fn test_producer_no_trace_context_no_crash() {
8942 let (url, _handle) = start_test_server().await;
8943 let ctx = test_producer_ctx();
8944
8945 let component = HttpComponent::new();
8946 let endpoint_ctx = NoOpComponentContext;
8947 let endpoint = component
8948 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
8949 .unwrap();
8950 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
8951
8952 let exchange = Exchange::new(Message::default());
8954
8955 let result = producer.oneshot(exchange).await.unwrap();
8957
8958 let status = result
8960 .input
8961 .header("CamelHttpResponseCode")
8962 .and_then(|v| v.as_u64())
8963 .unwrap();
8964 assert_eq!(status, 200);
8965 }
8966
8967 async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
8969 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8970 let addr = listener.local_addr().unwrap();
8971 let url = format!("http://127.0.0.1:{}", addr.port());
8972
8973 let handle = tokio::spawn(async move {
8974 loop {
8975 if let Ok((mut stream, _)) = listener.accept().await {
8976 tokio::spawn(async move {
8977 use tokio::io::{AsyncReadExt, AsyncWriteExt};
8978 let mut buf = vec![0u8; 8192];
8979 let n = stream.read(&mut buf).await.unwrap_or(0);
8980 let request = String::from_utf8_lossy(&buf[..n]).to_string();
8981
8982 let traceparent = request
8984 .lines()
8985 .find(|line| line.to_lowercase().starts_with("traceparent:"))
8986 .map(|line| {
8987 line.split(':')
8988 .nth(1)
8989 .map(|s| s.trim().to_string())
8990 .unwrap_or_default()
8991 })
8992 .unwrap_or_default();
8993
8994 let body =
8995 format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
8996 let response = format!(
8997 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
8998 body.len(),
8999 traceparent,
9000 body
9001 );
9002 let _ = stream.write_all(response.as_bytes()).await;
9003 });
9004 }
9005 }
9006 });
9007
9008 (url, handle)
9009 }
9010 }
9011
9012 #[tokio::test]
9021 async fn test_request_body_arrives_as_stream() {
9022 use camel_component_api::Body;
9023 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9024
9025 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9026 let port = listener.local_addr().unwrap().port();
9027 drop(listener);
9028
9029 let component = HttpComponent::new();
9030 let endpoint_ctx = NoOpComponentContext;
9031 let endpoint = component
9032 .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
9033 .unwrap();
9034 let mut consumer = endpoint.create_consumer(rt()).unwrap();
9035
9036 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9037 let token = tokio_util::sync::CancellationToken::new();
9038 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9039
9040 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9041 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9042
9043 let client = reqwest::Client::new();
9044 let send_fut = client
9045 .post(format!("http://127.0.0.1:{port}/upload"))
9046 .body("hello streaming world")
9047 .send();
9048
9049 let (http_result, _) = tokio::join!(send_fut, async {
9050 if let Some(mut envelope) = rx.recv().await {
9051 assert!(
9053 matches!(envelope.exchange.input.body, Body::Stream(_)),
9054 "expected Body::Stream, got discriminant {:?}",
9055 std::mem::discriminant(&envelope.exchange.input.body)
9056 );
9057 let bytes = envelope
9059 .exchange
9060 .input
9061 .body
9062 .into_bytes(1024 * 1024)
9063 .await
9064 .unwrap();
9065 assert_eq!(&bytes[..], b"hello streaming world");
9066
9067 envelope.exchange.input.body = camel_component_api::Body::Empty;
9068 if let Some(reply_tx) = envelope.reply_tx {
9069 let _ = reply_tx.send(Ok(envelope.exchange));
9070 }
9071 }
9072 });
9073
9074 let resp = http_result.unwrap();
9075 assert_eq!(resp.status().as_u16(), 200);
9076
9077 token.cancel();
9078 }
9079
9080 #[tokio::test]
9085 async fn test_streaming_response_chunked() {
9086 use bytes::Bytes;
9087 use camel_component_api::Body;
9088 use camel_component_api::CamelError;
9089 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9090 use camel_component_api::{StreamBody, StreamMetadata};
9091 use futures::stream;
9092 use std::sync::Arc;
9093 use tokio::sync::Mutex;
9094
9095 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9096 let port = listener.local_addr().unwrap().port();
9097 drop(listener);
9098
9099 let component = HttpComponent::new();
9100 let endpoint_ctx = NoOpComponentContext;
9101 let endpoint = component
9102 .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
9103 .unwrap();
9104 let mut consumer = endpoint.create_consumer(rt()).unwrap();
9105
9106 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9107 let token = tokio_util::sync::CancellationToken::new();
9108 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9109
9110 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9111 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9112
9113 let client = reqwest::Client::new();
9114 let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
9115
9116 let (http_result, _) = tokio::join!(send_fut, async {
9117 if let Some(mut envelope) = rx.recv().await {
9118 let chunks: Vec<Result<Bytes, CamelError>> =
9120 vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
9121 let stream = Box::pin(stream::iter(chunks));
9122 envelope.exchange.input.body = Body::Stream(StreamBody {
9123 stream: Arc::new(Mutex::new(Some(stream))),
9124 metadata: StreamMetadata::default(),
9125 });
9126 if let Some(reply_tx) = envelope.reply_tx {
9127 let _ = reply_tx.send(Ok(envelope.exchange));
9128 }
9129 }
9130 });
9131
9132 let resp = http_result.unwrap();
9133 assert_eq!(resp.status().as_u16(), 200);
9134 let body = resp.text().await.unwrap();
9135 assert_eq!(body, "chunk1chunk2");
9136
9137 token.cancel();
9138 }
9139
9140 #[tokio::test]
9145 async fn test_413_when_content_length_exceeds_limit() {
9146 use camel_component_api::ConsumerContext;
9147
9148 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9149 let port = listener.local_addr().unwrap().port();
9150 drop(listener);
9151
9152 let component = HttpComponent::new();
9154 let endpoint_ctx = NoOpComponentContext;
9155 let endpoint = component
9156 .create_endpoint(
9157 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
9158 &endpoint_ctx,
9159 )
9160 .unwrap();
9161 let mut consumer = endpoint.create_consumer(rt()).unwrap();
9162
9163 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
9164 let token = tokio_util::sync::CancellationToken::new();
9165 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9166
9167 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9168 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9169
9170 let client = reqwest::Client::new();
9171 let resp = client
9172 .post(format!("http://127.0.0.1:{port}/upload"))
9173 .header("Content-Length", "1000") .body("x".repeat(1000))
9175 .send()
9176 .await
9177 .unwrap();
9178
9179 assert_eq!(resp.status().as_u16(), 413);
9180
9181 token.cancel();
9182 }
9183
9184 #[tokio::test]
9188 async fn test_chunked_upload_without_content_length_bypasses_limit() {
9189 use bytes::Bytes;
9190 use camel_component_api::Body;
9191 use camel_component_api::ConsumerContext;
9192 use futures::stream;
9193
9194 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9195 let port = listener.local_addr().unwrap().port();
9196 drop(listener);
9197
9198 let component = HttpComponent::new();
9200 let endpoint_ctx = NoOpComponentContext;
9201 let endpoint = component
9202 .create_endpoint(
9203 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
9204 &endpoint_ctx,
9205 )
9206 .unwrap();
9207 let mut consumer = endpoint.create_consumer(rt()).unwrap();
9208
9209 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
9210 let token = tokio_util::sync::CancellationToken::new();
9211 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9212
9213 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9214 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9215
9216 let client = reqwest::Client::new();
9217
9218 let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
9222 Ok(Bytes::from("y".repeat(50))),
9223 Ok(Bytes::from("y".repeat(50))),
9224 ];
9225 let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
9226 let send_fut = client
9227 .post(format!("http://127.0.0.1:{port}/upload"))
9228 .body(stream_body)
9229 .send();
9230
9231 let consumer_fut = async {
9232 match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
9234 Ok(Some(mut envelope)) => {
9235 assert!(
9236 matches!(envelope.exchange.input.body, Body::Stream(_)),
9237 "expected Body::Stream"
9238 );
9239 envelope.exchange.input.body = camel_component_api::Body::Empty;
9240 if let Some(reply_tx) = envelope.reply_tx {
9241 let _ = reply_tx.send(Ok(envelope.exchange));
9242 }
9243 }
9244 Ok(None) => panic!("consumer channel closed unexpectedly"),
9245 Err(_) => {
9246 }
9249 }
9250 };
9251
9252 let (http_result, _) = tokio::join!(send_fut, consumer_fut);
9253
9254 let resp = http_result.unwrap();
9255 assert_ne!(
9262 resp.status().as_u16(),
9263 413,
9264 "chunked upload has no Content-Length to pre-check"
9265 );
9266 assert_eq!(resp.status().as_u16(), 200);
9267
9268 token.cancel();
9269 }
9270
9271 #[test]
9272 fn test_is_private_ip_ranges() {
9273 use camel_api::is_ssrf_blocked_ip;
9274 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(
9293 &"2001:4860:4860::8888".parse().unwrap()
9294 )); }
9296
9297 #[test]
9298 fn test_title_case_header() {
9299 assert_eq!(title_case_header("content-type"), "Content-Type");
9300 assert_eq!(title_case_header("authorization"), "Authorization");
9301 assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
9302 assert_eq!(title_case_header("host"), "Host");
9303 assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
9304 assert_eq!(title_case_header("single"), "Single");
9305 assert_eq!(title_case_header(""), "");
9306 }
9307
9308 #[test]
9309 fn test_resolve_url_combines_path_and_query_sources() {
9310 let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
9311 let mut exchange = Exchange::new(Message::default());
9312 exchange.input.set_header(
9313 "CamelHttpPath",
9314 serde_json::Value::String("next".to_string()),
9315 );
9316 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9317 assert!(url.starts_with("http://example.com/base/next?"));
9318 assert!(url.contains("foo=bar"));
9319
9320 exchange.input.set_header(
9321 "CamelHttpUri",
9322 serde_json::Value::String("http://other.test/root".to_string()),
9323 );
9324 exchange.input.set_header(
9325 "CamelHttpQuery",
9326 serde_json::Value::String("a=1&b=2".to_string()),
9327 );
9328
9329 let override_url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9330 assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
9331 }
9332
9333 fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
9334 let mut exchange = Exchange::new(Message::default());
9335 exchange
9336 .input
9337 .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
9338 exchange.input.set_header(
9339 "CamelHttpQuery",
9340 serde_json::Value::String(query.to_string()),
9341 );
9342 exchange
9343 }
9344
9345 #[test]
9346 fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
9347 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9348 cfg.bridge_endpoint = true;
9349 cfg.query_params
9350 .push(("token".to_string(), "secret".to_string()));
9351 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
9352 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9353 assert_eq!(url, "http://x?token=secret");
9357 assert!(!url.contains("/foo"));
9358 assert!(!url.contains("dropme"));
9359 }
9360
9361 #[test]
9362 fn resolve_url_bridge_endpoint_false_merges_path() {
9363 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9364 cfg.bridge_endpoint = false;
9365 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
9366 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9367 assert!(url.contains("/foo"), "url should contain /foo: {url}");
9368 assert!(
9369 url.contains("dropme=1"),
9370 "url should contain dropme=1: {url}"
9371 );
9372 }
9373
9374 #[test]
9375 fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
9376 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9377 cfg.bridge_endpoint = true;
9378 let mut exchange = Exchange::new(Message::default());
9379 exchange.input.set_header(
9380 "CamelHttpPath",
9381 serde_json::Value::String("/foo".to_string()),
9382 );
9383 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9384 assert_eq!(url, "http://x");
9385 assert!(!url.contains("/foo"));
9386 }
9387
9388 #[test]
9389 fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
9390 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9391 cfg.bridge_endpoint = true;
9392 let mut exchange = Exchange::new(Message::default());
9394 exchange.input.set_header(
9395 "CamelHttpUri",
9396 serde_json::Value::String("http://dest/explicit".to_string()),
9397 );
9398 exchange.input.set_header(
9399 "CamelHttpPath",
9400 serde_json::Value::String("/foo".to_string()),
9401 );
9402 exchange.input.set_header(
9403 "CamelHttpQuery",
9404 serde_json::Value::String("x=1".to_string()),
9405 );
9406 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9407 assert_eq!(url, "http://x");
9411 }
9412
9413 #[test]
9414 fn bridge_programmatic_params_use_percent20() {
9415 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9416 cfg.bridge_endpoint = true;
9417 cfg.query_params = vec![("b".to_string(), "x y".to_string())];
9418 let exchange = Exchange::new(Message::default());
9419
9420 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9421
9422 assert_eq!(url, "http://x?b=x%20y");
9426 assert!(!url.contains('+'));
9427 }
9428
9429 #[test]
9430 fn bridge_arm_carries_authored_raw_query() {
9431 let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
9432 let exchange = exchange_with_path_and_query("/ignored", "dropme=1");
9435
9436 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9437
9438 assert_eq!(url, "http://h/p?a=1");
9441 assert!(!url.contains("dropme"), "exchange query leaked: {url}");
9442 assert!(!url.contains("/ignored"), "exchange path leaked: {url}");
9443 }
9444
9445 #[test]
9452 fn resolve_url_bridge_preserves_dot_segments() {
9453 let mut cfg = HttpEndpointConfig::from_uri("http://h/a/../b").unwrap();
9454 cfg.bridge_endpoint = true;
9455 cfg.query_params.push(("k".to_string(), "1".to_string()));
9456 let exchange = Exchange::new(Message::default());
9457
9458 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9459
9460 assert_eq!(url, "http://h/a/../b?k=1");
9463 }
9464
9465 #[test]
9466 fn resolve_url_bridge_preserves_default_port() {
9467 let mut cfg = HttpEndpointConfig::from_uri("http://h:80/p").unwrap();
9468 cfg.bridge_endpoint = true;
9469 cfg.query_params.push(("k".to_string(), "1".to_string()));
9470 let exchange = Exchange::new(Message::default());
9471
9472 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9473
9474 assert_eq!(url, "http://h:80/p?k=1");
9477 }
9478
9479 #[test]
9480 fn resolve_url_bridge_preserves_scheme_and_host_case() {
9481 let mut cfg = HttpEndpointConfig::from_uri("http://ExAMPLE.COM/p").unwrap();
9482 cfg.bridge_endpoint = true;
9483 cfg.query_params.push(("k".to_string(), "1".to_string()));
9484 cfg.base_url = "HTTP://ExAMPLE.COM/p".to_string();
9488 let exchange = Exchange::new(Message::default());
9489
9490 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9491
9492 assert_eq!(url, "HTTP://ExAMPLE.COM/p?k=1");
9495 }
9496
9497 #[test]
9498 fn resolve_url_bridge_no_query_emits_base_verbatim() {
9499 let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9500 cfg.bridge_endpoint = true;
9501 let exchange = Exchange::new(Message::default());
9502
9503 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9504
9505 assert_eq!(url, "http://h/p");
9508 }
9509
9510 #[test]
9511 fn resolve_url_bridge_and_non_bridge_byte_identical() {
9512 let mut bridged = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
9515 bridged.bridge_endpoint = true;
9516 bridged
9517 .query_params
9518 .push(("k".to_string(), "1".to_string()));
9519 let bridge_url =
9520 HttpProducer::resolve_url(&Exchange::new(Message::default()), &bridged).unwrap();
9521
9522 let plain = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
9525 let mut exchange = Exchange::new(Message::default());
9526 exchange.input.set_header(
9527 "CamelHttpQuery",
9528 serde_json::Value::String("k=1".to_string()),
9529 );
9530 let plain_url = HttpProducer::resolve_url(&exchange, &plain).unwrap();
9531
9532 assert_eq!(bridge_url, plain_url);
9533 assert_eq!(bridge_url, "http://H:80/a/../b?k=1");
9534 }
9535
9536 #[test]
9537 fn resolve_url_bridge_preserves_ipv6_authority_verbatim() {
9538 let mut cfg = HttpEndpointConfig::from_uri("http://[::1]:8080/p").unwrap();
9539 cfg.bridge_endpoint = true;
9540 cfg.query_params.push(("k".to_string(), "1".to_string()));
9541 let exchange = Exchange::new(Message::default());
9542
9543 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9544
9545 assert_eq!(url, "http://[::1]:8080/p?k=1");
9546 }
9547
9548 #[test]
9549 fn resolve_url_bridge_empty_base_path_keeps_no_synthetic_slash() {
9550 let cfg = HttpEndpointConfig::from_uri("http://h?x=1&bridgeEndpoint=true").unwrap();
9551 let exchange = Exchange::new(Message::default());
9552
9553 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9554
9555 assert_eq!(url, "http://h?x=1");
9558 }
9559
9560 #[test]
9565 fn resolve_url_preserves_authored_query_order_and_bytes() {
9566 let config =
9567 HttpEndpointConfig::from_uri("http://h/p?a=1&b=x,y&c=t:1&connectTimeout=5000").unwrap();
9568 let exchange = Exchange::new(Message::default());
9569
9570 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9571
9572 assert_eq!(url, "http://h/p?a=1&b=x,y&c=t:1");
9575 }
9576
9577 #[test]
9578 fn resolve_url_consumes_encoded_option_key() {
9579 let config = HttpEndpointConfig::from_uri("http://h/p?connect%54imeout=5000&a=1").unwrap();
9580 let exchange = Exchange::new(Message::default());
9581
9582 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9583
9584 assert_eq!(url, "http://h/p?a=1");
9586 }
9587
9588 #[test]
9589 fn resolve_url_all_options_consumed_drops_query() {
9590 let config = HttpEndpointConfig::from_uri("http://h/p?connectTimeout=5000").unwrap();
9591 let exchange = Exchange::new(Message::default());
9592
9593 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9594
9595 assert_eq!(url, "http://h/p");
9598 assert!(!url.contains('?'));
9599 }
9600
9601 #[test]
9602 fn resolve_url_preserves_empty_query_marker() {
9603 let config = HttpEndpointConfig::from_uri("http://h/p?").unwrap();
9604 let exchange = Exchange::new(Message::default());
9605
9606 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9607
9608 assert_eq!(url, "http://h/p?");
9611 }
9612
9613 #[test]
9614 fn resolve_url_raw_wrapper_not_re_encoded() {
9615 let config = HttpEndpointConfig::from_uri("http://h/p?token=RAW(abc)").unwrap();
9616 let exchange = Exchange::new(Message::default());
9617
9618 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9619
9620 assert_eq!(url, "http://h/p?token=RAW(abc)");
9622 assert!(!url.contains("%28"), "RAW( wrapper re-encoded: {url}");
9623 }
9624
9625 #[test]
9626 fn resolve_url_camel_http_query_composes_verbatim_span() {
9627 let config = HttpEndpointConfig::from_uri("http://h/p?x=1").unwrap();
9628 let mut exchange = Exchange::new(Message::default());
9629 exchange.input.set_header(
9630 "CamelHttpQuery",
9631 serde_json::Value::String("userFilter=a%2Cb".to_string()),
9632 );
9633
9634 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9635
9636 assert_eq!(url, "http://h/p?x=1&userFilter=a%2Cb");
9641 assert!(!url.contains("%252C"), "header bytes re-encoded: {url}");
9642 }
9643
9644 #[test]
9649 fn header_composes_with_endpoint_query() {
9650 let config =
9651 HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret&lang=en").unwrap();
9652 let mut exchange = Exchange::new(Message::default());
9653 exchange.input.set_header(
9654 "CamelHttpQuery",
9655 serde_json::Value::String("lang=es&page=2".to_string()),
9656 );
9657
9658 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9659
9660 assert_eq!(url, "http://upstream/api?apiKey=secret&lang=en&page=2");
9663 }
9664
9665 #[test]
9666 fn header_alone_still_rides() {
9667 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9668 let mut exchange = Exchange::new(Message::default());
9669 exchange.input.set_header(
9670 "CamelHttpQuery",
9671 serde_json::Value::String("page=2".to_string()),
9672 );
9673
9674 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9675
9676 assert_eq!(url, "http://upstream/api?page=2");
9678 }
9679
9680 #[test]
9681 fn empty_reflected_query_leaves_endpoint_query_intact() {
9682 let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
9683 let mut exchange = Exchange::new(Message::default());
9684 exchange
9687 .input
9688 .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
9689
9690 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9691
9692 assert_eq!(url, "http://upstream/api?apiKey=secret");
9694 assert!(!url.ends_with('?'), "dangling '?' marker: {url}");
9695 }
9696
9697 #[test]
9698 fn forbidden_byte_in_header_query_errors() {
9699 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9700 let mut exchange = Exchange::new(Message::default());
9701 exchange.input.set_header(
9702 "CamelHttpQuery",
9703 serde_json::Value::String("q=ab<cd".to_string()),
9704 );
9705
9706 let err = HttpProducer::resolve_url(&exchange, &config)
9707 .unwrap_err()
9708 .to_string();
9709
9710 assert!(err.contains("0x3C"), "error must name the byte: {err}");
9713 }
9714
9715 #[test]
9716 fn override_uri_with_query_plus_header_query() {
9717 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9718 let mut exchange = Exchange::new(Message::default());
9719 exchange.input.set_header(
9720 "CamelHttpUri",
9721 serde_json::Value::String("http://host/api?a=1".to_string()),
9722 );
9723 exchange.input.set_header(
9724 "CamelHttpQuery",
9725 serde_json::Value::String("a=2&b=3".to_string()),
9726 );
9727
9728 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9729
9730 assert_eq!(url, "http://host/api?a=1&b=3");
9733 }
9734
9735 #[test]
9736 fn path_applies_before_query_composition() {
9737 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9738 let mut exchange = Exchange::new(Message::default());
9739 exchange.input.set_header(
9740 "CamelHttpUri",
9741 serde_json::Value::String("http://host/api?a=1".to_string()),
9742 );
9743 exchange.input.set_header(
9744 "CamelHttpPath",
9745 serde_json::Value::String("/extra".to_string()),
9746 );
9747 exchange.input.set_header(
9748 "CamelHttpQuery",
9749 serde_json::Value::String("b=2".to_string()),
9750 );
9751
9752 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9753
9754 assert_eq!(url, "http://host/api/extra?a=1&b=2");
9757 }
9758
9759 #[test]
9760 fn plain_proxy_reflection_composes() {
9761 let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
9762 let exchange = exchange_with_path_and_query("/in/extra", "page=2");
9764
9765 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9766
9767 assert_eq!(url, "http://upstream/api/in/extra?apiKey=secret&page=2");
9770 }
9771
9772 #[test]
9773 fn bridge_endpoint_ignores_url_headers() {
9774 let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
9775 let mut exchange = Exchange::new(Message::default());
9776 exchange.input.set_header(
9777 "CamelHttpUri",
9778 serde_json::Value::String("http://evil.test/x".to_string()),
9779 );
9780 exchange.input.set_header(
9781 "CamelHttpPath",
9782 serde_json::Value::String("/foo".to_string()),
9783 );
9784 exchange.input.set_header(
9785 "CamelHttpQuery",
9786 serde_json::Value::String("z=9".to_string()),
9787 );
9788
9789 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9790
9791 assert_eq!(url, "http://h/p?a=1");
9794 assert!(!url.contains("evil"), "override leaked: {url}");
9795 assert!(!url.contains("z=9"), "header query leaked: {url}");
9796 assert!(!url.contains("/foo"), "header path leaked: {url}");
9797 }
9798
9799 #[test]
9800 fn resolve_url_programmatic_params_use_percent20_deterministic() {
9801 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9802 config.query_params = vec![
9803 ("b".to_string(), "x y".to_string()),
9804 ("a".to_string(), "1".to_string()),
9805 ];
9806 let exchange = Exchange::new(Message::default());
9807
9808 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9809
9810 assert_eq!(url, "http://h/p?b=x%20y&a=1");
9813 assert!(!url.contains('+'));
9814 }
9815
9816 #[test]
9817 fn resolve_url_authored_and_programmatic_merge() {
9818 let mut config = HttpEndpointConfig::from_uri("http://h/p?a=1&c=t:1").unwrap();
9819 config.query_params = vec![
9820 ("b".to_string(), "2".to_string()),
9821 ("a".to_string(), "9".to_string()),
9822 ];
9823 let exchange = Exchange::new(Message::default());
9824
9825 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9826
9827 assert_eq!(url, "http://h/p?a=1&c=t:1&b=2");
9830 }
9831
9832 #[test]
9833 fn from_uri_no_longer_fills_query_params_from_uri() {
9834 let config = HttpEndpointConfig::from_uri("http://h/p?a=1&connectTimeout=5000").unwrap();
9835
9836 assert!(
9838 config.query_params.is_empty(),
9839 "query_params is programmatic-only: {:?}",
9840 config.query_params
9841 );
9842 assert_eq!(config.raw_query.as_deref(), Some("a=1&connectTimeout=5000"));
9843 }
9844
9845 #[test]
9846 fn resolve_url_forbidden_raw_byte_errors() {
9847 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9848 config.raw_query = Some("a=x y".to_string());
9849 let exchange = Exchange::new(Message::default());
9850
9851 let err = HttpProducer::resolve_url(&exchange, &config)
9852 .expect_err("literal space in raw query must error");
9853
9854 assert!(
9856 err.to_string().contains("0x20"),
9857 "error must name the forbidden byte: {err}"
9858 );
9859 }
9860
9861 #[test]
9865 fn resolve_url_override_query_forbidden_byte_errors() {
9866 let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9867 let mut exchange = Exchange::new(Message::default());
9868 exchange.input.set_header(
9869 "CamelHttpUri",
9870 serde_json::Value::String("http://h2/p?a=x y".to_string()),
9871 );
9872
9873 let err = HttpProducer::resolve_url(&exchange, &config)
9874 .expect_err("literal space in the override URI's query must error");
9875
9876 assert!(
9877 err.to_string().contains("0x20"),
9878 "error must name the forbidden byte from the override query: {err}"
9879 );
9880 }
9881
9882 #[test]
9887 fn merge_header_query_decoded_key_collision_drops_header_pair() {
9888 let merged = merge_header_query(Some("a=1"), "%61=2")
9889 .expect("decoded-key collision must not be a parse error");
9890 assert_eq!(
9891 merged.as_deref(),
9892 Some("a=1"),
9893 "the higher-precedence span wins and the colliding header pair is dropped"
9894 );
9895 }
9896
9897 #[test]
9900 fn merge_header_query_duplicate_keys_within_header_ride_verbatim() {
9901 let merged = merge_header_query(None, "k=1&k=2")
9902 .expect("duplicate header keys must not be a parse error");
9903 assert_eq!(
9904 merged.as_deref(),
9905 Some("k=1&k=2"),
9906 "intra-header duplicate keys ride verbatim"
9907 );
9908 }
9909
9910 #[test]
9917 fn endpoint_config_debug_masks_base_url_userinfo() {
9918 let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
9919 config.base_url = "http://user:pass@h.example/p".to_string();
9920 let rendered = format!("{config:?}");
9921 assert!(
9922 rendered.contains("***@h.example"),
9923 "userinfo must render masked: {rendered}"
9924 );
9925 assert!(
9926 !rendered.contains("user:pass"),
9927 "no credentials in Debug output: {rendered}"
9928 );
9929
9930 let plain = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
9931 let rendered_plain = format!("{plain:?}");
9932 assert!(
9933 rendered_plain.contains("http://h.example/p"),
9934 "a base without userinfo renders unchanged: {rendered_plain}"
9935 );
9936 }
9937
9938 #[test]
9943 fn endpoint_config_debug_base_url_converges_on_canonical_redact() {
9944 let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
9945
9946 config.base_url = "http://h.example/p?token=secret#access_token=x".to_string();
9947 let rendered = format!("{config:?}");
9948 assert!(
9949 rendered.contains("base_url: \"http://h.example/p?[redacted]#[redacted]\""),
9950 "query and fragment must render as composed sentinels: {rendered}"
9951 );
9952 assert!(
9953 !rendered.contains("token=secret") && !rendered.contains("access_token"),
9954 "query/fragment credential bytes must not render: {rendered}"
9955 );
9956
9957 config.base_url = "http://h.example//u2:p2@evil/".to_string();
9958 let rendered = format!("{config:?}");
9959 assert!(
9960 rendered.contains("base_url: \"http://h.example//***@evil/\""),
9961 "later //window userinfo must mask (canonical window rule): {rendered}"
9962 );
9963 assert!(
9964 !rendered.contains("u2:p2"),
9965 "later-window credentials must not render: {rendered}"
9966 );
9967
9968 config.base_url = "http://user:pass@h.example/p?token=x".to_string();
9971 let canonical = camel_api::redact::redact_url(&config.base_url);
9972 assert_eq!(canonical, "http://***@h.example/p?[redacted]");
9973 let rendered = format!("{config:?}");
9974 assert!(
9975 rendered.contains(&format!("base_url: \"{canonical}\"")),
9976 "Debug base_url must equal canonical redact_url output: {rendered}"
9977 );
9978 }
9979
9980 #[test]
9986 fn resolve_url_authored_apostrophe_rejected_percent_escape_rides() {
9987 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9988
9989 config.raw_query = Some("q=it's".to_string());
9990 let exchange = Exchange::new(Message::default());
9991 let err = HttpProducer::resolve_url(&exchange, &config)
9992 .expect_err("authored apostrophe must be rejected, not silently %27-normalized");
9993 assert!(
9994 err.to_string().contains("0x27"),
9995 "error must name the apostrophe byte: {err}"
9996 );
9997
9998 config.raw_query = Some("q=it%27s".to_string());
9999 let url = HttpProducer::resolve_url(&exchange, &config)
10000 .expect("authored %27 escape is wire-legal");
10001 assert!(
10002 url.contains("q=it%27s"),
10003 "the authored escape must ride byte-for-byte: {url}"
10004 );
10005
10006 for &byte in b"\"`<>" {
10010 config.raw_query = Some(format!("k={}x", byte as char));
10011 let err = HttpProducer::resolve_url(&exchange, &config)
10012 .expect_err("WHATWG special-query byte must be rejected");
10013 assert!(
10014 err.to_string().contains(&format!("0x{byte:02X}")),
10015 "error must name byte 0x{byte:02X}: {err}"
10016 );
10017 }
10018 }
10019
10020 #[test]
10021 fn armed_fence_rejects_unknown_host_redacted() {
10022 let cfg = HttpEndpointConfig::from_uri(
10023 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10024 )
10025 .unwrap();
10026 let mut exchange = Exchange::new(Message::default());
10027 exchange.input.set_header(
10028 "CamelHttpUri",
10029 serde_json::Value::String(
10030 "http://user:pass@evil.example.com/x?token=s3cret".to_string(),
10031 ),
10032 );
10033
10034 let err = HttpProducer::resolve_url(&exchange, &cfg)
10035 .expect_err("override host outside the fence must fail resolution");
10036
10037 let message = err.to_string();
10038 assert!(!message.contains("pass"), "userinfo leaked: {message}");
10039 assert!(!message.contains("s3cret"), "query leaked: {message}");
10040 }
10041
10042 #[test]
10043 fn armed_fence_rejects_unparseable_override_redacted() {
10044 let cfg = HttpEndpointConfig::from_uri(
10045 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10046 )
10047 .unwrap();
10048 let mut exchange = Exchange::new(Message::default());
10049 exchange.input.set_header(
10050 "CamelHttpUri",
10051 serde_json::Value::String("http://u:fencesecret@evil.example.com:99999/x".to_string()),
10052 );
10053
10054 let err = HttpProducer::resolve_url(&exchange, &cfg)
10055 .expect_err("unparseable override outside the fence must fail resolution");
10056
10057 let message = err.to_string();
10058 assert!(
10059 message.contains("allowedUriHosts fence"),
10060 "fence must be named: {message}"
10061 );
10062 assert!(
10063 message.contains("[redacted]"),
10064 "suppression sentinel missing: {message}"
10065 );
10066 assert!(
10067 !message.contains("evil.example.com"),
10068 "host leaked: fail-closed arm must render only the sentinel: {message}"
10069 );
10070 assert!(
10071 !message.contains("fencesecret"),
10072 "password leaked: {message}"
10073 );
10074 assert!(!message.contains("u:"), "userinfo leaked: {message}");
10075 }
10076
10077 #[test]
10078 fn armed_fence_rejects_password_only_userinfo_redacted() {
10079 let cfg = HttpEndpointConfig::from_uri(
10080 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10081 )
10082 .unwrap();
10083 let mut exchange = Exchange::new(Message::default());
10084 exchange.input.set_header(
10085 "CamelHttpUri",
10086 serde_json::Value::String(
10087 "http://:passwordonly@evil.example.com/x?token=querysecret".to_string(),
10088 ),
10089 );
10090
10091 let err = HttpProducer::resolve_url(&exchange, &cfg)
10092 .expect_err("password-only override outside the fence must fail resolution");
10093
10094 let message = err.to_string();
10095 assert!(
10096 !message.contains("passwordonly"),
10097 "password-only userinfo leaked: {message}"
10098 );
10099 assert!(!message.contains("querysecret"), "query leaked: {message}");
10100 assert!(
10101 message.contains("http://***@evil.example.com/x?[redacted]"),
10102 "masked shape missing: {message}"
10103 );
10104 }
10105
10106 #[test]
10107 fn armed_fence_allows_listed_host() {
10108 let cfg = HttpEndpointConfig::from_uri(
10109 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
10110 )
10111 .unwrap();
10112 let mut exchange = Exchange::new(Message::default());
10113 exchange.input.set_header(
10114 "CamelHttpUri",
10115 serde_json::Value::String("http://cdn.example.com/x".to_string()),
10116 );
10117
10118 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10119 assert_eq!(url, "http://cdn.example.com/x");
10120 }
10121
10122 #[test]
10123 fn host_only_entry_permits_any_port() {
10124 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=cdn.example.com").unwrap();
10125 let mut exchange = Exchange::new(Message::default());
10126 exchange.input.set_header(
10127 "CamelHttpUri",
10128 serde_json::Value::String("http://cdn.example.com:9443/x".to_string()),
10129 );
10130
10131 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10132 assert_eq!(url, "http://cdn.example.com:9443/x");
10133 }
10134
10135 #[test]
10136 fn unarmed_endpoint_unchanged() {
10137 let cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
10138 let mut exchange = Exchange::new(Message::default());
10139 exchange.input.set_header(
10140 "CamelHttpUri",
10141 serde_json::Value::String("http://any.example.com/path".to_string()),
10142 );
10143
10144 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10145 assert_eq!(url, "http://any.example.com/path");
10146 }
10147
10148 #[test]
10149 fn empty_allowlist_fails_endpoint_creation() {
10150 assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=,,").is_err());
10151 }
10152
10153 #[test]
10154 fn malformed_entry_fails_endpoint_creation() {
10155 assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=not a host!").is_err());
10156 }
10157
10158 #[test]
10159 fn fence_entry_with_path_fails_creation() {
10160 assert!(
10163 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=api.internal:8443/v2").is_err()
10164 );
10165 }
10166
10167 #[test]
10168 fn fence_entry_with_userinfo_fails_creation() {
10169 assert!(
10170 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=user@cdn.example.com").is_err()
10171 );
10172 }
10173
10174 #[test]
10175 fn ipv6_fence_entry_allows_bracketed_host() {
10176 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=[::1]:8443").unwrap();
10177 for uri in ["http://[::1]:8443/x", "http://[0:0:0:0:0:0:0:1]:8443/x"] {
10180 let mut exchange = Exchange::new(Message::default());
10181 exchange
10182 .input
10183 .set_header("CamelHttpUri", serde_json::Value::String(uri.to_string()));
10184 let url = HttpProducer::resolve_url(&exchange, &cfg)
10185 .unwrap_or_else(|e| panic!("override {uri} must be honored: {e}"));
10186 assert_eq!(url, uri, "bracketed IPv6 override not honored");
10187 }
10188 }
10189
10190 #[test]
10191 fn dns_case_insensitive_fence_match() {
10192 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=CDN.Example.COM").unwrap();
10195 let mut exchange = Exchange::new(Message::default());
10196 exchange.input.set_header(
10197 "CamelHttpUri",
10198 serde_json::Value::String("http://cdn.example.com/x".to_string()),
10199 );
10200 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10201 assert_eq!(url, "http://cdn.example.com/x");
10202 }
10203
10204 #[test]
10205 fn fence_allowed_override_query_merges_with_header() {
10206 let cfg =
10209 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example&k=v").unwrap();
10210 let mut exchange = Exchange::new(Message::default());
10211 exchange.input.set_header(
10212 "CamelHttpUri",
10213 serde_json::Value::String("http://host.example/api?a=1".to_string()),
10214 );
10215 exchange.input.set_header(
10216 "CamelHttpQuery",
10217 serde_json::Value::String("b=2".to_string()),
10218 );
10219
10220 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10221 assert_eq!(url, "http://host.example/api?a=1&b=2");
10222 }
10223
10224 #[test]
10225 fn empty_header_with_armed_fence_leaves_no_query() {
10226 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example").unwrap();
10227 let mut exchange = Exchange::new(Message::default());
10228 exchange.input.set_header(
10229 "CamelHttpUri",
10230 serde_json::Value::String("http://host.example/api".to_string()),
10231 );
10232 exchange
10233 .input
10234 .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
10235
10236 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10237 assert_eq!(url, "http://host.example/api");
10238 assert!(!url.contains('?'), "query marker leaked: {url}");
10239 }
10240
10241 #[test]
10242 fn fence_option_is_consumed() {
10243 let cfg =
10247 HttpEndpointConfig::from_uri("http://h/p?x=1&allowedUriHosts=cdn.example.com").unwrap();
10248 let exchange = Exchange::new(Message::default());
10249
10250 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10251 assert!(!url.contains("allowedUriHosts"), "option leaked: {url}");
10252 assert!(url.contains("x=1"), "authored query lost: {url}");
10253 }
10254
10255 #[tokio::test]
10256 async fn resolve_url_malformed_base_url_errors_no_panic() {
10257 use tower::ServiceExt;
10258
10259 let (url, _handle) = start_test_server().await;
10260 let mut config = HttpEndpointConfig::from_uri("http://[::1:bad").unwrap();
10261 config.allow_internal = true; let producer = HttpProducer {
10263 config: Arc::new(config),
10264 client: build_client(&HttpConfig::default(), None),
10265 pinned_cache: Arc::new(PinnedClientCache::new(
10266 PINNED_CLIENT_TTL,
10267 PINNED_CLIENT_MAX_ENTRIES,
10268 )),
10269 http_config: Arc::new(HttpConfig::default()),
10270 runtime: rt(),
10271 };
10272
10273 let first = producer
10276 .clone()
10277 .oneshot(Exchange::new(Message::default()))
10278 .await;
10279 let err = first.expect_err("malformed base URL must error, not panic");
10280 assert!(
10281 err.to_string().to_lowercase().contains("url"),
10282 "error must name the malformed URL: {err}"
10283 );
10284
10285 let mut exchange = Exchange::new(Message::default());
10288 exchange.input.set_header(
10289 "CamelHttpUri",
10290 serde_json::Value::String(format!("{url}/api")),
10291 );
10292 let response = producer
10293 .oneshot(exchange)
10294 .await
10295 .expect("valid request through same producer must succeed");
10296 let status = response
10297 .input
10298 .header("CamelHttpResponseCode")
10299 .and_then(|v| v.as_u64())
10300 .unwrap();
10301 assert_eq!(status, 200);
10302 }
10303
10304 #[test]
10305 fn resolve_url_bridge_malformed_base_errors_no_panic() {
10306 let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10307 cfg.bridge_endpoint = true;
10308 cfg.query_params.push(("k".to_string(), "1".to_string()));
10309 cfg.base_url = "http://[::1:bad".to_string();
10314 let exchange = Exchange::new(Message::default());
10315
10316 let err = HttpProducer::resolve_url(&exchange, &cfg)
10317 .expect_err("malformed bridge base URL must error");
10318 assert!(
10319 err.to_string().contains("invalid base URL"),
10320 "error must name the invalid base URL: {err}"
10321 );
10322 }
10323
10324 #[test]
10325 fn test_http_producer_helpers_status_and_size_boundaries() {
10326 assert!(HttpProducer::is_ok_status(200, (200, 299)));
10327 assert!(HttpProducer::is_ok_status(299, (200, 299)));
10328 assert!(!HttpProducer::is_ok_status(199, (200, 299)));
10329 assert!(!HttpProducer::is_ok_status(300, (200, 299)));
10330
10331 assert!(!exceeds_max_response_body(10, 10));
10332 assert!(exceeds_max_response_body(11, 10));
10333 }
10334
10335 #[allow(clippy::await_holding_lock)]
10340 async fn setup_consumer_on_free_port(
10341 path: &str,
10342 ) -> (
10343 u16,
10344 tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
10345 tokio_util::sync::CancellationToken,
10346 ) {
10347 use camel_component_api::ConsumerContext;
10348
10349 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10354 let port = listener.local_addr().unwrap().port();
10355
10356 let _registry_guard = lock_registry_test_mutex();
10365
10366 ServerRegistry::global()
10367 .stage_listener(listener)
10368 .await
10369 .expect("stage consumer test listener");
10370
10371 let consumer_cfg = HttpServerConfig {
10372 scheme: "http".to_string(),
10373 host: "127.0.0.1".to_string(),
10374 port,
10375 path: path.to_string(),
10376 max_request_body: 2 * 1024 * 1024,
10377 max_response_body: 10 * 1024 * 1024,
10378 max_inflight_requests: 1024,
10379 method: None,
10380 tls_config: None,
10381 };
10382 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
10383
10384 let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
10385 let token = tokio_util::sync::CancellationToken::new();
10386 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
10387
10388 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10389
10390 wait_for_registry_ready("127.0.0.1", port).await;
10396 for _ in 0..8 {
10397 tokio::task::yield_now().await;
10398 }
10399
10400 (port, rx, token)
10401 }
10402
10403 async fn wait_for_registry_ready(host: &str, port: u16) {
10408 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
10409 let mut backoff = std::time::Duration::from_millis(1);
10410 while ServerRegistry::global().bound_addr(host, port).is_none() {
10411 assert!(
10412 tokio::time::Instant::now() < deadline,
10413 "consumer server did not become ready on port {port} — registry entry absent (concurrent reset or starvation)"
10414 );
10415 tokio::time::sleep(backoff).await;
10416 backoff = (backoff * 2).min(std::time::Duration::from_millis(64));
10417 }
10418 }
10419
10420 #[tokio::test]
10421 #[should_panic(expected = "registry entry absent (concurrent reset or starvation)")]
10422 async fn readiness_deadline_fires_loud_with_hint() {
10423 let held = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10434 let port = held.local_addr().unwrap().port();
10435 wait_for_registry_ready("httpflake-unreachable-host", port).await;
10436 }
10437
10438 #[tokio::test]
10443 async fn readiness_survives_concurrent_registry_reset() {
10444 let contended = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
10445 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
10446
10447 let contended_hammer = std::sync::Arc::clone(&contended);
10451 let stop_hammer = std::sync::Arc::clone(&stop);
10452 let handle = std::thread::spawn(move || {
10453 while !stop_hammer.load(std::sync::atomic::Ordering::Relaxed) {
10454 let _guard = match REGISTRY_TEST_MUTEX.try_lock() {
10455 Err(_) => {
10456 contended_hammer.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
10457 lock_registry_test_mutex()
10458 }
10459 Ok(guard) => guard,
10460 };
10461 ServerRegistry::reset();
10462 }
10463 });
10464
10465 struct StopHammerOnDrop {
10468 handle: Option<std::thread::JoinHandle<()>>,
10469 stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
10470 }
10471 impl Drop for StopHammerOnDrop {
10472 fn drop(&mut self) {
10473 self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
10474 if let Some(handle) = self.handle.take() {
10475 let _ = handle.join();
10476 }
10477 }
10478 }
10479 let _hammer_guard = StopHammerOnDrop {
10480 handle: Some(handle),
10481 stop,
10482 };
10483
10484 let mut setups = 0;
10487 loop {
10488 setups += 1;
10489 let (_port, rx, token) = setup_consumer_on_free_port("/reset-hammer").await;
10490 drop(rx);
10491 token.cancel();
10492 if (setups >= 25 && contended.load(std::sync::atomic::Ordering::SeqCst) >= 1)
10493 || setups >= 50
10494 {
10495 break;
10496 }
10497 }
10498
10499 let contended_hits = contended.load(std::sync::atomic::Ordering::SeqCst);
10500 assert!(
10501 contended_hits >= 1,
10502 "expected at least one contended registry reset across {setups} setups, got {contended_hits}"
10503 );
10504 }
10505
10506 #[tokio::test]
10507 async fn test_content_type_inferred_for_json_body() {
10508 let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
10509
10510 let client = reqwest::Client::new();
10511 let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
10512
10513 let (http_result, _) = tokio::join!(send_fut, async {
10514 if let Some(mut envelope) = rx.recv().await {
10515 envelope.exchange.input.body =
10516 camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
10517 if let Some(reply_tx) = envelope.reply_tx {
10518 let _ = reply_tx.send(Ok(envelope.exchange));
10519 }
10520 }
10521 });
10522
10523 let resp = http_result.unwrap();
10524 assert_eq!(resp.status().as_u16(), 200);
10525 let ct = resp
10526 .headers()
10527 .get("content-type")
10528 .expect("Content-Type header should be present");
10529 assert_eq!(ct, "application/json");
10530 let body = resp.text().await.unwrap();
10531 assert_eq!(body, r#"{"message":"hello"}"#);
10532
10533 token.cancel();
10534 }
10535
10536 #[tokio::test]
10537 async fn test_content_type_inferred_for_text_body() {
10538 let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
10539
10540 let client = reqwest::Client::new();
10541 let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
10542
10543 let (http_result, _) = tokio::join!(send_fut, async {
10544 if let Some(mut envelope) = rx.recv().await {
10545 envelope.exchange.input.body =
10546 camel_component_api::Body::Text("plain text response".to_string());
10547 if let Some(reply_tx) = envelope.reply_tx {
10548 let _ = reply_tx.send(Ok(envelope.exchange));
10549 }
10550 }
10551 });
10552
10553 let resp = http_result.unwrap();
10554 assert_eq!(resp.status().as_u16(), 200);
10555 let ct = resp
10556 .headers()
10557 .get("content-type")
10558 .expect("Content-Type header should be present");
10559 assert_eq!(ct, "text/plain; charset=utf-8");
10560 let body = resp.text().await.unwrap();
10561 assert_eq!(body, "plain text response");
10562
10563 token.cancel();
10564 }
10565
10566 #[tokio::test]
10567 async fn test_content_type_inferred_for_xml_body() {
10568 let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
10569
10570 let client = reqwest::Client::new();
10571 let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
10572
10573 let (http_result, _) = tokio::join!(send_fut, async {
10574 if let Some(mut envelope) = rx.recv().await {
10575 envelope.exchange.input.body =
10576 camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
10577 if let Some(reply_tx) = envelope.reply_tx {
10578 let _ = reply_tx.send(Ok(envelope.exchange));
10579 }
10580 }
10581 });
10582
10583 let resp = http_result.unwrap();
10584 assert_eq!(resp.status().as_u16(), 200);
10585 let ct = resp
10586 .headers()
10587 .get("content-type")
10588 .expect("Content-Type header should be present");
10589 assert_eq!(ct, "application/xml");
10590 let body = resp.text().await.unwrap();
10591 assert_eq!(body, "<root><item>value</item></root>");
10592
10593 token.cancel();
10594 }
10595
10596 #[tokio::test]
10597 async fn test_no_content_type_for_empty_body() {
10598 let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
10599
10600 let client = reqwest::Client::new();
10601 let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
10602
10603 let (http_result, _) = tokio::join!(send_fut, async {
10604 if let Some(mut envelope) = rx.recv().await {
10605 envelope.exchange.input.body = camel_component_api::Body::Empty;
10606 if let Some(reply_tx) = envelope.reply_tx {
10607 let _ = reply_tx.send(Ok(envelope.exchange));
10608 }
10609 }
10610 });
10611
10612 let resp = http_result.unwrap();
10613 assert_eq!(resp.status().as_u16(), 200);
10614 assert!(
10615 resp.headers().get("content-type").is_none(),
10616 "Empty body should not set Content-Type"
10617 );
10618
10619 token.cancel();
10620 }
10621
10622 #[tokio::test]
10623 async fn test_no_content_type_for_raw_bytes_body() {
10624 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
10625
10626 let client = reqwest::Client::new();
10627 let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
10628
10629 let (http_result, _) = tokio::join!(send_fut, async {
10630 if let Some(mut envelope) = rx.recv().await {
10631 envelope.exchange.input.body =
10632 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
10633 if let Some(reply_tx) = envelope.reply_tx {
10634 let _ = reply_tx.send(Ok(envelope.exchange));
10635 }
10636 }
10637 });
10638
10639 let resp = http_result.unwrap();
10640 assert_eq!(resp.status().as_u16(), 200);
10641 assert!(
10642 resp.headers().get("content-type").is_none(),
10643 "Raw Bytes body should not set Content-Type"
10644 );
10645
10646 token.cancel();
10647 }
10648
10649 #[tokio::test]
10650 async fn test_content_type_from_stream_metadata() {
10651 use camel_component_api::{StreamBody, StreamMetadata};
10652 use futures::stream;
10653
10654 let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
10655
10656 let client = reqwest::Client::new();
10657 let send_fut = client
10658 .get(format!("http://127.0.0.1:{port}/stream-ct"))
10659 .send();
10660
10661 let (http_result, _) = tokio::join!(send_fut, async {
10662 if let Some(mut envelope) = rx.recv().await {
10663 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
10664 vec![Ok(bytes::Bytes::from("audio data"))];
10665 let stream = Box::pin(stream::iter(chunks));
10666 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
10667 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
10668 metadata: StreamMetadata {
10669 size_hint: None,
10670 content_type: Some("audio/mpeg".to_string()),
10671 origin: None,
10672 },
10673 });
10674 if let Some(reply_tx) = envelope.reply_tx {
10675 let _ = reply_tx.send(Ok(envelope.exchange));
10676 }
10677 }
10678 });
10679
10680 let resp = http_result.unwrap();
10681 assert_eq!(resp.status().as_u16(), 200);
10682 let ct = resp
10683 .headers()
10684 .get("content-type")
10685 .expect("Content-Type header should be present");
10686 assert_eq!(ct, "audio/mpeg");
10687 let body = resp.text().await.unwrap();
10688 assert_eq!(body, "audio data");
10689
10690 token.cancel();
10691 }
10692
10693 #[tokio::test]
10694 async fn test_user_content_type_overrides_inferred() {
10695 let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
10696
10697 let client = reqwest::Client::new();
10698 let send_fut = client
10699 .get(format!("http://127.0.0.1:{port}/override-ct"))
10700 .send();
10701
10702 let (http_result, _) = tokio::join!(send_fut, async {
10703 if let Some(mut envelope) = rx.recv().await {
10704 envelope.exchange.input.body =
10705 camel_component_api::Body::Json(serde_json::json!({"ok": true}));
10706 envelope.exchange.input.set_header(
10707 "Content-Type",
10708 serde_json::Value::String("text/html".to_string()),
10709 );
10710 if let Some(reply_tx) = envelope.reply_tx {
10711 let _ = reply_tx.send(Ok(envelope.exchange));
10712 }
10713 }
10714 });
10715
10716 let resp = http_result.unwrap();
10717 assert_eq!(resp.status().as_u16(), 200);
10718 let ct = resp
10719 .headers()
10720 .get("content-type")
10721 .expect("Content-Type header should be present");
10722 assert_eq!(
10723 ct, "text/html",
10724 "User-set Content-Type should take precedence over inferred type"
10725 );
10726
10727 token.cancel();
10728 }
10729
10730 #[tokio::test]
10731 async fn test_user_content_type_with_bytes_body() {
10732 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
10733
10734 let client = reqwest::Client::new();
10735 let send_fut = client
10736 .get(format!("http://127.0.0.1:{port}/bytes-ct"))
10737 .send();
10738
10739 let (http_result, _) = tokio::join!(send_fut, async {
10740 if let Some(mut envelope) = rx.recv().await {
10741 envelope.exchange.input.body =
10742 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
10743 envelope.exchange.input.set_header(
10744 "Content-Type",
10745 serde_json::Value::String("application/json".to_string()),
10746 );
10747 if let Some(reply_tx) = envelope.reply_tx {
10748 let _ = reply_tx.send(Ok(envelope.exchange));
10749 }
10750 }
10751 });
10752
10753 let resp = http_result.unwrap();
10754 assert_eq!(resp.status().as_u16(), 200);
10755 let ct = resp
10756 .headers()
10757 .get("content-type")
10758 .expect("Content-Type header should be present for Bytes body with user header");
10759 assert_eq!(
10760 ct, "application/json",
10761 "User Content-Type should be sent for Bytes body"
10762 );
10763
10764 token.cancel();
10765 }
10766
10767 #[tokio::test]
10772 async fn monitor_task_silent_on_clean_exit() {
10773 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
10774 let server_exited = tokio_util::sync::CancellationToken::new();
10775 monitor_axum_task(
10777 handle,
10778 "127.0.0.1:0".to_string(),
10779 noop_rt(),
10780 "test-monitor".into(),
10781 server_exited.clone(),
10782 )
10783 .await;
10784 assert!(
10788 !server_exited.is_cancelled(),
10789 "clean server exit must not cancel server_exited"
10790 );
10791 }
10792
10793 #[tokio::test]
10794 async fn monitor_task_handles_panicked_task() {
10795 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
10796 panic!("simulated server crash");
10797 });
10798 let server_exited = tokio_util::sync::CancellationToken::new();
10799 monitor_axum_task(
10801 handle,
10802 "127.0.0.1:9999".to_string(),
10803 noop_rt(),
10804 "test-monitor".into(),
10805 server_exited.clone(),
10806 )
10807 .await;
10808 assert!(
10811 server_exited.is_cancelled(),
10812 "crashed server must cancel server_exited"
10813 );
10814 }
10815
10816 #[test]
10821 fn http_auth_basic_debug_redacts_password() {
10822 let auth = HttpAuth::Basic {
10823 username: "admin".to_string(),
10824 password: "hunter2".to_string(),
10825 };
10826 let debug = format!("{:?}", auth);
10827 assert!(
10828 !debug.contains("hunter2"),
10829 "password must be redacted: {debug}"
10830 );
10831 assert!(debug.contains("admin"), "username should appear: {debug}");
10832 }
10833
10834 #[test]
10835 fn http_auth_bearer_debug_redacts_token() {
10836 let auth = HttpAuth::Bearer {
10837 token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
10838 };
10839 let debug = format!("{:?}", auth);
10840 assert!(
10841 !debug.contains("eyJhbGci"),
10842 "token must be redacted: {debug}"
10843 );
10844 }
10845
10846 #[test]
10847 fn http_auth_none_debug_shows_variant() {
10848 let debug = format!("{:?}", HttpAuth::None);
10849 assert!(
10850 debug.contains("None"),
10851 "None variant should appear: {debug}"
10852 );
10853 }
10854
10855 #[test]
10856 fn http_endpoint_config_debug_redacts_auth_credentials() {
10857 let config = HttpEndpointConfig::from_uri(
10858 "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
10859 )
10860 .unwrap();
10861 let debug = format!("{:?}", config);
10862 assert!(
10863 !debug.contains("secret123"),
10864 "password must be redacted in HttpEndpointConfig debug: {debug}"
10865 );
10866 }
10867
10868 #[test]
10869 fn debug_lists_all_public_fields() {
10870 let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10871 let debug = format!("{:?}", config);
10872 for field in [
10873 "base_url",
10874 "http_method",
10875 "throw_exception_on_failure",
10876 "ok_status_code_range",
10877 "response_timeout",
10878 "query_params",
10879 "raw_query",
10880 "allow_internal",
10881 "allow_cleartext",
10882 "blocked_hosts",
10883 "max_body_size",
10884 "read_timeout_ms",
10885 "max_response_bytes",
10886 "auth",
10887 "token_provider",
10888 "user_agent",
10889 "bridge_endpoint",
10890 "connection_close",
10891 "skip_request_headers",
10892 "skip_response_headers",
10893 "follow_redirects",
10894 "max_redirects",
10895 ] {
10896 assert!(
10897 debug.contains(field),
10898 "Debug output missing field '{field}': {debug}"
10899 );
10900 }
10901 }
10902
10903 use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
10908 use tower_http::services::ServeDir;
10909
10910 fn make_test_registry() -> HttpRouteRegistry {
10911 HttpRouteRegistry::new()
10912 }
10913
10914 fn make_test_state(registry: HttpRouteRegistry) -> AppState {
10915 AppState {
10916 registry,
10917 max_request_body: 2 * 1024 * 1024,
10918 max_response_body: 10 * 1024 * 1024,
10919 inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
10920 }
10921 }
10922
10923 #[allow(clippy::await_holding_lock)]
10924 #[tokio::test]
10925 async fn test_static_file_serving_serves_file_contents() {
10926 let _guard = lock_registry_test_mutex();
10927 ServerRegistry::reset();
10928
10929 let temp_dir =
10931 std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
10932 std::fs::create_dir_all(&temp_dir).unwrap();
10933 std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
10934 std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
10935
10936 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
10937
10938 let registry = make_test_registry();
10939 let serve_dir = ServeDir::new(&canonical_dir)
10940 .precompressed_gzip()
10941 .precompressed_br()
10942 .append_index_html_on_directories(true);
10943
10944 let mount = StaticMount {
10945 mount_path: "/".to_string(),
10946 mode: MountMode::Static,
10947 dir: canonical_dir.clone(),
10948 cache_control: "public, max-age=3600".to_string(),
10949 error_pages: std::collections::HashMap::new(),
10950 serve_dir,
10951 };
10952 registry.register_static_mount(mount).await.unwrap();
10953
10954 let state = make_test_state(registry);
10955
10956 let req = Request::builder()
10958 .uri("/hello.txt")
10959 .body(AxumBody::empty())
10960 .unwrap();
10961 let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
10962 assert_eq!(resp.status(), StatusCode::OK);
10963 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10964 .await
10965 .unwrap();
10966 assert_eq!(&body[..], b"Hello, static world!");
10967
10968 let req = Request::builder()
10970 .uri("/style.css")
10971 .body(AxumBody::empty())
10972 .unwrap();
10973 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
10974 assert_eq!(resp.status(), StatusCode::OK);
10975 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10976 .await
10977 .unwrap();
10978 assert_eq!(&body[..], b"body { color: red; }");
10979
10980 let req = Request::builder()
10982 .uri("/missing.txt")
10983 .body(AxumBody::empty())
10984 .unwrap();
10985 let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
10986 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
10987
10988 std::fs::remove_dir_all(&temp_dir).ok();
10990 }
10991
10992 #[allow(clippy::await_holding_lock)]
10993 #[tokio::test]
10994 async fn test_spa_fallback_serves_index_for_unknown_paths() {
10995 let _guard = lock_registry_test_mutex();
10996 ServerRegistry::reset();
10997
10998 let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
10999 std::fs::create_dir_all(&temp_dir).unwrap();
11000 std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
11001 std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
11002
11003 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11004
11005 let registry = make_test_registry();
11006 let serve_dir = ServeDir::new(&canonical_dir)
11007 .precompressed_gzip()
11008 .precompressed_br()
11009 .append_index_html_on_directories(true);
11010
11011 let mount = StaticMount {
11012 mount_path: "/".to_string(),
11013 mode: MountMode::Spa,
11014 dir: canonical_dir.clone(),
11015 cache_control: "public, max-age=0".to_string(),
11016 error_pages: std::collections::HashMap::new(),
11017 serve_dir,
11018 };
11019 registry.register_static_mount(mount).await.unwrap();
11021
11022 let state = make_test_state(registry);
11023
11024 let req = Request::builder()
11026 .method("GET")
11027 .uri("/dashboard")
11028 .header("Accept", "text/html")
11029 .body(AxumBody::empty())
11030 .unwrap();
11031 let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
11032 assert_eq!(resp.status(), StatusCode::OK);
11033 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11034 .await
11035 .unwrap();
11036 assert_eq!(&body[..], b"<h1>SPA App</h1>");
11037
11038 let req = Request::builder()
11040 .method("GET")
11041 .uri("/app.js")
11042 .body(AxumBody::empty())
11043 .unwrap();
11044 let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
11045 assert_eq!(resp.status(), StatusCode::OK);
11046 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11047 .await
11048 .unwrap();
11049 assert_eq!(&body[..], b"console.log('app')");
11050
11051 let req = Request::builder()
11053 .method("GET")
11054 .uri("/api/data")
11055 .header("Accept", "application/json")
11056 .body(AxumBody::empty())
11057 .unwrap();
11058 let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
11059 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11060
11061 let req = Request::builder()
11063 .method("GET")
11064 .uri("/style.css")
11065 .header("Accept", "text/html")
11066 .body(AxumBody::empty())
11067 .unwrap();
11068 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
11069 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11070
11071 std::fs::remove_dir_all(&temp_dir).ok();
11073 }
11074
11075 #[allow(clippy::await_holding_lock)]
11082 async fn run_conditional_get_returns_304(mode: MountMode) {
11083 let _guard = lock_registry_test_mutex();
11084 ServerRegistry::reset();
11085
11086 let temp_dir = std::env::temp_dir().join(format!(
11087 "http_cond_get_{}_{}",
11088 if mode == MountMode::Spa {
11089 "spa"
11090 } else {
11091 "static"
11092 },
11093 std::process::id()
11094 ));
11095 std::fs::create_dir_all(&temp_dir).unwrap();
11096 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
11097
11098 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11099
11100 let registry = make_test_registry();
11101 let serve_dir = ServeDir::new(&canonical_dir)
11102 .precompressed_gzip()
11103 .precompressed_br()
11104 .append_index_html_on_directories(true);
11105
11106 let mount = StaticMount {
11107 mount_path: "/".to_string(),
11108 mode,
11109 dir: canonical_dir.clone(),
11110 cache_control: "public, max-age=3600".to_string(),
11111 error_pages: std::collections::HashMap::new(),
11112 serve_dir,
11113 };
11114 registry.register_static_mount(mount).await.unwrap();
11115
11116 let state = make_test_state(registry);
11117
11118 let req = Request::builder()
11120 .method("GET")
11121 .uri("/index.html")
11122 .body(AxumBody::empty())
11123 .unwrap();
11124 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11125 assert_eq!(
11126 resp.status(),
11127 StatusCode::OK,
11128 "first GET should return 200, got {}",
11129 resp.status()
11130 );
11131 assert!(
11133 resp.headers().contains_key(http::header::CACHE_CONTROL),
11134 "200 response missing Cache-Control"
11135 );
11136 let etag = resp
11137 .headers()
11138 .get(http::header::ETAG)
11139 .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
11140 .clone();
11141 let last_modified = resp
11142 .headers()
11143 .get(http::header::LAST_MODIFIED)
11144 .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
11145 .clone();
11146 let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
11148 .await
11149 .unwrap();
11150
11151 let req = Request::builder()
11155 .method("GET")
11156 .uri("/index.html")
11157 .header(http::header::IF_NONE_MATCH, etag.clone())
11158 .body(AxumBody::empty())
11159 .unwrap();
11160 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11161 assert_eq!(
11162 resp.status(),
11163 StatusCode::NOT_MODIFIED,
11164 "If-None-Match with matching ETag should return 304, got {}",
11165 resp.status()
11166 );
11167 assert!(
11169 resp.headers().contains_key(http::header::CACHE_CONTROL),
11170 "304 (If-None-Match) missing Cache-Control"
11171 );
11172 assert_eq!(
11175 resp.headers().get(http::header::ETAG),
11176 Some(&etag),
11177 "304 (If-None-Match) must echo the ETag validator"
11178 );
11179 assert_eq!(
11180 resp.headers().get(http::header::LAST_MODIFIED),
11181 Some(&last_modified),
11182 "304 (If-None-Match) must carry Last-Modified"
11183 );
11184
11185 let req = Request::builder()
11187 .method("GET")
11188 .uri("/index.html")
11189 .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
11190 .body(AxumBody::empty())
11191 .unwrap();
11192 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11193 assert_eq!(
11194 resp.status(),
11195 StatusCode::NOT_MODIFIED,
11196 "If-Modified-Since with matching timestamp should return 304, got {}",
11197 resp.status()
11198 );
11199 assert!(
11200 resp.headers().contains_key(http::header::CACHE_CONTROL),
11201 "304 (If-Modified-Since) missing Cache-Control"
11202 );
11203 assert_eq!(
11204 resp.headers().get(http::header::ETAG),
11205 Some(&etag),
11206 "304 (If-Modified-Since) must carry the ETag validator"
11207 );
11208 assert_eq!(
11209 resp.headers().get(http::header::LAST_MODIFIED),
11210 Some(&last_modified),
11211 "304 (If-Modified-Since) must echo Last-Modified"
11212 );
11213
11214 let req = Request::builder()
11220 .method("GET")
11221 .uri("/index.html")
11222 .header(
11223 http::header::IF_MODIFIED_SINCE,
11224 "Wed, 21 Oct 2000 07:28:00 GMT",
11225 )
11226 .body(AxumBody::empty())
11227 .unwrap();
11228 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11229 assert_eq!(
11230 resp.status(),
11231 StatusCode::OK,
11232 "past If-Modified-Since should return 200 (file modified after it), got {}",
11233 resp.status()
11234 );
11235
11236 std::fs::remove_dir_all(&temp_dir).ok();
11238 }
11239
11240 #[tokio::test]
11241 async fn test_conditional_get_returns_304_static_mode() {
11242 run_conditional_get_returns_304(MountMode::Static).await;
11243 }
11244
11245 #[tokio::test]
11246 async fn test_conditional_get_returns_304_spa_mode() {
11247 run_conditional_get_returns_304(MountMode::Spa).await;
11248 }
11249
11250 #[allow(clippy::await_holding_lock)]
11251 #[tokio::test]
11252 async fn test_error_page_mapping_serves_custom_404() {
11253 let _guard = lock_registry_test_mutex();
11254 ServerRegistry::reset();
11255
11256 let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
11257 let errors_dir = temp_dir.join("errors");
11258 std::fs::create_dir_all(&errors_dir).unwrap();
11259 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
11260 std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
11261
11262 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11263 let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
11264
11265 let registry = make_test_registry();
11266 let serve_dir = ServeDir::new(&canonical_dir)
11267 .precompressed_gzip()
11268 .precompressed_br()
11269 .append_index_html_on_directories(true);
11270
11271 let mut error_pages = std::collections::HashMap::new();
11272 error_pages.insert(404, canonical_404);
11273
11274 let mount = StaticMount {
11275 mount_path: "/".to_string(),
11276 mode: MountMode::Static,
11277 dir: canonical_dir.clone(),
11278 cache_control: "public, max-age=0".to_string(),
11279 error_pages,
11280 serve_dir,
11281 };
11282 registry.register_static_mount(mount).await.unwrap();
11283
11284 let state = make_test_state(registry);
11285
11286 let req = Request::builder()
11288 .method("GET")
11289 .uri("/missing.html")
11290 .body(AxumBody::empty())
11291 .unwrap();
11292 let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
11293 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11294 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11295 .await
11296 .unwrap();
11297 assert_eq!(&body[..], b"<h1>Custom 404</h1>");
11298
11299 let req = Request::builder()
11301 .method("GET")
11302 .uri("/index.html")
11303 .body(AxumBody::empty())
11304 .unwrap();
11305 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11306 assert_eq!(resp.status(), StatusCode::OK);
11307 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11308 .await
11309 .unwrap();
11310 assert_eq!(&body[..], b"<h1>Home</h1>");
11311
11312 std::fs::remove_dir_all(&temp_dir).ok();
11314 }
11315
11316 #[tokio::test]
11317 async fn http_consumer_returns_body_and_code_on_stop() {
11318 use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
11319 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
11320 use tower::ServiceExt;
11321
11322 let set_body_step = CompiledStep::Process {
11324 kind_hint: camel_api::SpanKindHint::Internal,
11325 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
11326 ex.input.body = Body::Text("nope".into());
11327 Box::pin(async move { Ok(ex) })
11328 }),
11329 body_contract: None,
11330 lifecycle: None,
11331 label: None,
11332 to_uri: None,
11333 };
11334 let set_status_step = CompiledStep::Process {
11335 kind_hint: camel_api::SpanKindHint::Internal,
11336 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
11337 ex.input.set_header(
11338 "CamelHttpResponseCode",
11339 serde_json::Value::Number(409.into()),
11340 );
11341 Box::pin(async move { Ok(ex) })
11342 }),
11343 body_contract: None,
11344 lifecycle: None,
11345 label: None,
11346 to_uri: None,
11347 };
11348 let pipeline = compose_pipeline_with_handler(
11349 vec![set_body_step, set_status_step, CompiledStep::Stop],
11350 None,
11351 PipelineRuntimeCtx::compile_time(),
11352 );
11353
11354 let ex = Exchange::new(Message::default());
11355 let result = pipeline.oneshot(ex).await;
11356 assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
11357 let returned = result.unwrap();
11358 assert_eq!(returned.input.body.as_text(), Some("nope"));
11359 assert_eq!(
11360 returned
11361 .input
11362 .header("CamelHttpResponseCode")
11363 .and_then(|v| v.as_u64()),
11364 Some(409)
11365 );
11366 }
11367
11368 #[tokio::test]
11369 async fn http_consumer_returns_200_when_body_empty_on_stop() {
11370 use camel_api::{Exchange, Message};
11378 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
11379 use tower::ServiceExt;
11380
11381 let pipeline = compose_pipeline_with_handler(
11382 vec![CompiledStep::Stop],
11383 None,
11384 PipelineRuntimeCtx::compile_time(),
11385 );
11386 let ex = Exchange::new(Message::default());
11387 let result = pipeline.oneshot(ex).await;
11388 assert!(result.is_ok(), "Stop with empty body arrives as Ok");
11389 }
11392
11393 async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
11401 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11402 let port = listener.local_addr().unwrap().port();
11403 let registry = HttpRouteRegistry::new();
11404 tokio::spawn(run_axum_server(
11405 listener,
11406 registry.clone(),
11407 2 * 1024 * 1024,
11408 10 * 1024 * 1024,
11409 Arc::new(tokio::sync::Semaphore::new(1024)),
11410 test_rt(),
11411 "test-route".into(),
11412 ));
11413 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
11415 (port, registry)
11416 }
11417
11418 fn spawn_responder(
11423 mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
11424 status: u16,
11425 body: String,
11426 ) -> tokio::task::JoinHandle<()> {
11427 tokio::spawn(async move {
11428 if let Some(envelope) = rx.recv().await {
11429 let _ = envelope.reply_tx.send(HttpReply {
11430 status,
11431 headers: vec![],
11432 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
11433 });
11434 }
11435 })
11436 }
11437
11438 #[tokio::test]
11439 async fn method_aware_dispatch_same_path_different_verbs() {
11440 let (port, registry) = spawn_test_server().await;
11441
11442 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11447 registry
11448 .register_rest_endpoint(
11449 "GET".into(),
11450 vec![PathSegment::Literal("users".into())],
11451 get_tx,
11452 )
11453 .await;
11454
11455 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11456 registry
11457 .register_rest_endpoint(
11458 "POST".into(),
11459 vec![PathSegment::Literal("users".into())],
11460 post_tx,
11461 )
11462 .await;
11463
11464 let get_handle = spawn_responder(get_rx, 200, "list".into());
11465 let post_handle = spawn_responder(post_rx, 201, "create".into());
11466
11467 let client = reqwest::Client::new();
11468
11469 let resp = client
11471 .get(format!("http://127.0.0.1:{port}/users"))
11472 .send()
11473 .await
11474 .unwrap();
11475 assert_eq!(resp.status().as_u16(), 200);
11476 let body = resp.text().await.unwrap();
11477 assert_eq!(body, "list");
11478
11479 let resp = client
11481 .post(format!("http://127.0.0.1:{port}/users"))
11482 .send()
11483 .await
11484 .unwrap();
11485 assert_eq!(resp.status().as_u16(), 201);
11486 let body = resp.text().await.unwrap();
11487 assert_eq!(body, "create");
11488
11489 let _ = tokio::join!(get_handle, post_handle);
11490 }
11491
11492 #[tokio::test]
11493 async fn method_aware_dispatch_templated_path_extracts_params() {
11494 let (port, registry) = spawn_test_server().await;
11495
11496 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11500 registry
11501 .register_rest_endpoint(
11502 "GET".into(),
11503 vec![
11504 PathSegment::Literal("users".into()),
11505 PathSegment::Param("id".into()),
11506 ],
11507 tx,
11508 )
11509 .await;
11510
11511 let handle = tokio::spawn(async move {
11514 if let Some(envelope) = rx.recv().await {
11515 let id = envelope.path_params.get("id").cloned().unwrap_or_default();
11516 let _ = envelope.reply_tx.send(HttpReply {
11517 status: 200,
11518 headers: vec![],
11519 body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
11520 });
11521 }
11522 });
11523
11524 let client = reqwest::Client::new();
11525 let resp = client
11526 .get(format!("http://127.0.0.1:{port}/users/42"))
11527 .send()
11528 .await
11529 .unwrap();
11530 assert_eq!(resp.status().as_u16(), 200);
11531 let body = resp.text().await.unwrap();
11532 assert_eq!(body, "id=42");
11533
11534 let _ = handle.await;
11535 }
11536
11537 #[tokio::test]
11538 async fn method_aware_dispatch_unmatched_method_falls_through() {
11539 let (port, _registry) = spawn_test_server().await;
11544
11545 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11547 _registry
11548 .register_rest_endpoint(
11549 "GET".into(),
11550 vec![PathSegment::Literal("users".into())],
11551 get_tx,
11552 )
11553 .await;
11554
11555 let drain = tokio::spawn(async move {
11558 let mut get_rx = get_rx;
11559 while get_rx.recv().await.is_some() {}
11560 });
11561
11562 let client = reqwest::Client::new();
11563 let resp = client
11564 .delete(format!("http://127.0.0.1:{port}/users"))
11565 .send()
11566 .await
11567 .unwrap();
11568 assert_eq!(resp.status().as_u16(), 404);
11569
11570 drop(drain);
11571 }
11572
11573 #[tokio::test]
11574 async fn regression_legacy_exact_api_route_still_works() {
11575 let (port, registry) = spawn_test_server().await;
11580
11581 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11582 registry.register_api_route("/legacy/path".into(), tx).await;
11583
11584 let handle = tokio::spawn(async move {
11585 if let Some(envelope) = rx.recv().await {
11586 let _ = envelope.reply_tx.send(HttpReply {
11587 status: 200,
11588 headers: vec![],
11589 body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
11590 });
11591 }
11592 });
11593
11594 let client = reqwest::Client::new();
11595 let resp = client
11596 .get(format!("http://127.0.0.1:{port}/legacy/path"))
11597 .send()
11598 .await
11599 .unwrap();
11600 assert_eq!(resp.status().as_u16(), 200);
11601 let body = resp.text().await.unwrap();
11602 assert_eq!(body, "legacy ok");
11603
11604 let _ = handle.await;
11605 }
11606
11607 #[allow(clippy::await_holding_lock)]
11608 #[tokio::test]
11609 async fn regression_static_mount_still_works() {
11610 let _guard = lock_registry_test_mutex();
11614 ServerRegistry::reset();
11615
11616 let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
11617 std::fs::create_dir_all(&temp_dir).unwrap();
11618 std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
11619 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11620
11621 let registry = make_test_registry();
11622 let serve_dir = ServeDir::new(&canonical_dir)
11623 .precompressed_gzip()
11624 .precompressed_br()
11625 .append_index_html_on_directories(true);
11626 let mount = StaticMount {
11627 mount_path: "/".to_string(),
11628 mode: MountMode::Static,
11629 dir: canonical_dir.clone(),
11630 cache_control: "public, max-age=3600".to_string(),
11631 error_pages: std::collections::HashMap::new(),
11632 serve_dir,
11633 };
11634 registry.register_static_mount(mount).await.unwrap();
11635
11636 let state = make_test_state(registry);
11637 let req = Request::builder()
11638 .uri("/regress.txt")
11639 .body(AxumBody::empty())
11640 .unwrap();
11641 let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
11642 assert_eq!(resp.status(), StatusCode::OK);
11643 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11644 .await
11645 .unwrap();
11646 assert_eq!(&body[..], b"static works");
11647
11648 std::fs::remove_dir_all(&temp_dir).ok();
11649 }
11650
11651 #[tokio::test]
11660 async fn deregister_one_method_keeps_sibling_verbs() {
11661 let (port, registry) = spawn_test_server().await;
11665
11666 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11667 registry
11668 .register_rest_endpoint(
11669 "GET".into(),
11670 vec![PathSegment::Literal("users".into())],
11671 get_tx,
11672 )
11673 .await;
11674
11675 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11676 registry
11677 .register_rest_endpoint(
11678 "POST".into(),
11679 vec![PathSegment::Literal("users".into())],
11680 post_tx,
11681 )
11682 .await;
11683
11684 let drain = tokio::spawn(async move {
11686 let mut get_rx = get_rx;
11687 while get_rx.recv().await.is_some() {}
11688 });
11689
11690 registry.unregister_rest_endpoint("GET", "/users").await;
11692 drop(drain);
11693
11694 let post_handle = spawn_responder(post_rx, 201, "create".into());
11695
11696 let client = reqwest::Client::new();
11697 let resp = client
11699 .post(format!("http://127.0.0.1:{port}/users"))
11700 .send()
11701 .await
11702 .unwrap();
11703 assert_eq!(resp.status().as_u16(), 201);
11704 assert_eq!(resp.text().await.unwrap(), "create");
11705
11706 let _ = post_handle.await;
11707 }
11708
11709 #[tokio::test]
11710 async fn dispatch_exact_legacy_beats_rest_template() {
11711 let (port, registry) = spawn_test_server().await;
11716
11717 let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11719 registry
11720 .register_api_route("/api/users".into(), exact_tx)
11721 .await;
11722 let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
11723
11724 let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11726 registry
11727 .register_rest_endpoint(
11728 "GET".into(),
11729 vec![
11730 PathSegment::Literal("api".into()),
11731 PathSegment::Param("resource".into()),
11732 ],
11733 tpl_tx,
11734 )
11735 .await;
11736 let _tpl_drain = tokio::spawn(async move {
11742 let mut tpl_rx = tpl_rx;
11743 if let Some(env) = tpl_rx.recv().await {
11744 let _ = env.reply_tx.send(HttpReply {
11745 status: 200,
11746 headers: vec![],
11747 body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
11748 });
11749 }
11750 });
11751
11752 let client = reqwest::Client::new();
11753 let resp = client
11754 .get(format!("http://127.0.0.1:{port}/api/users"))
11755 .send()
11756 .await
11757 .unwrap();
11758 assert_eq!(resp.status().as_u16(), 200);
11759 assert_eq!(resp.text().await.unwrap(), "exact");
11761
11762 let _ = exact_handle.await;
11763 }
11764
11765 #[tokio::test]
11766 async fn ambiguous_rest_templates_return_500_not_silent_404() {
11767 let (port, registry) = spawn_test_server().await;
11772
11773 let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11774 registry
11775 .register_rest_endpoint(
11776 "GET".into(),
11777 vec![
11778 PathSegment::Literal("users".into()),
11779 PathSegment::Param("id".into()),
11780 ],
11781 a_tx,
11782 )
11783 .await;
11784
11785 let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11786 registry
11787 .register_rest_endpoint(
11788 "GET".into(),
11789 vec![
11790 PathSegment::Literal("users".into()),
11791 PathSegment::Param("name".into()),
11792 ],
11793 b_tx,
11794 )
11795 .await;
11796
11797 let client = reqwest::Client::new();
11798 let resp = client
11799 .get(format!("http://127.0.0.1:{port}/users/42"))
11800 .send()
11801 .await
11802 .unwrap();
11803 assert_eq!(resp.status().as_u16(), 500);
11805 }
11806
11807 #[test]
11808 fn from_uri_round_trips_templated_path_with_http_method() {
11809 use crate::UriConfig;
11815 let cfg =
11816 HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
11817 assert_eq!(cfg.host, "0.0.0.0");
11818 assert_eq!(cfg.port, 8080);
11819 assert_eq!(cfg.path, "/users/{id}");
11820 assert_eq!(cfg.method.as_deref(), Some("GET"));
11821
11822 let cfg_lc =
11824 HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
11825 assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
11826 assert_eq!(cfg_lc.path, "/orders");
11827 }
11828
11829 #[test]
11834 fn type_conversion_failed_maps_to_400() {
11835 let reply = pipeline_error_to_reply(
11836 CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
11837 "/api/users",
11838 );
11839 assert_eq!(reply.status, 400);
11840 let json_ct = reply
11842 .headers
11843 .iter()
11844 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11845 .count();
11846 assert_eq!(json_ct, 1);
11847 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"], "invalid JSON at line 1");
11856 }
11857
11858 #[test]
11859 fn other_error_still_maps_to_500() {
11860 let reply =
11861 pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
11862 assert_eq!(reply.status, 500);
11863 }
11864
11865 #[test]
11866 fn unauthenticated_maps_to_401() {
11867 let reply = pipeline_error_to_reply(
11868 CamelError::Unauthenticated("no token".to_string()),
11869 "/api/users",
11870 );
11871 assert_eq!(reply.status, 401);
11872 }
11873
11874 #[test]
11875 fn unauthorized_maps_to_403() {
11876 let reply = pipeline_error_to_reply(
11877 CamelError::Unauthorized("forbidden".to_string()),
11878 "/api/users",
11879 );
11880 assert_eq!(reply.status, 403);
11881 }
11882
11883 #[test]
11884 fn validation_error_maps_to_400() {
11885 let reply = pipeline_error_to_reply(
11886 CamelError::ValidationError("body does not match schema".to_string()),
11887 "/api/users",
11888 );
11889 assert_eq!(reply.status, 400);
11890 let json_ct = reply
11891 .headers
11892 .iter()
11893 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11894 .count();
11895 assert_eq!(json_ct, 1);
11896 let body = match &reply.body {
11897 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11898 _ => panic!("expected bytes body"),
11899 };
11900 let parsed: serde_json::Value =
11901 serde_json::from_str(&body).expect("body must be valid JSON");
11902 assert_eq!(parsed["error"], "validation_error");
11903 assert_eq!(parsed["message"], "body does not match schema");
11904 }
11905
11906 #[test]
11911 fn finalizer_maps_unsupported_media_type() {
11912 let reply = pipeline_error_to_reply(
11913 CamelError::UnsupportedMediaType {
11914 consumed: "text/plain".to_string(),
11915 declared: "application/json".to_string(),
11916 },
11917 "/x",
11918 );
11919 assert_eq!(reply.status, 415);
11920 let json_ct = reply
11921 .headers
11922 .iter()
11923 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11924 .count();
11925 assert_eq!(json_ct, 1);
11926 let body = match &reply.body {
11927 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11928 _ => panic!("expected bytes body"),
11929 };
11930 let parsed: serde_json::Value =
11931 serde_json::from_str(&body).expect("body must be valid JSON");
11932 assert_eq!(parsed["error"], "unsupported_media_type");
11933 assert_eq!(
11934 parsed["message"],
11935 "consumed text/plain, declared application/json"
11936 );
11937 }
11938
11939 #[test]
11940 fn finalizer_maps_not_acceptable() {
11941 let reply = pipeline_error_to_reply(
11942 CamelError::NotAcceptable {
11943 accept: "application/xml".to_string(),
11944 produced: "application/json".to_string(),
11945 },
11946 "/x",
11947 );
11948 assert_eq!(reply.status, 406);
11949 let json_ct = reply
11950 .headers
11951 .iter()
11952 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11953 .count();
11954 assert_eq!(json_ct, 1);
11955 let body = match &reply.body {
11956 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11957 _ => panic!("expected bytes body"),
11958 };
11959 let parsed: serde_json::Value =
11960 serde_json::from_str(&body).expect("body must be valid JSON");
11961 assert_eq!(parsed["error"], "not_acceptable");
11962 assert_eq!(
11963 parsed["message"],
11964 "accept application/xml, produced application/json"
11965 );
11966 }
11967
11968 #[test]
11969 fn json_error_reply_preserves_empty_message() {
11970 let reply = json_error_reply(400, "bad_request", "".to_string());
11971 assert_eq!(reply.status, 400);
11972 let json_ct = reply
11973 .headers
11974 .iter()
11975 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11976 .count();
11977 assert_eq!(json_ct, 1);
11978 let body = match &reply.body {
11979 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11980 _ => panic!("expected bytes body"),
11981 };
11982 let parsed: serde_json::Value =
11983 serde_json::from_str(&body).expect("body must be valid JSON");
11984 assert_eq!(parsed["error"], "bad_request");
11985 assert_eq!(parsed["message"], "");
11986 }
11987
11988 #[test]
11989 fn https_consumer_without_tls_cert_errors() {
11990 let endpoint = HttpEndpoint {
11991 uri: "https://0.0.0.0:8443/api".to_string(),
11992 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
11993 server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
11994 client: reqwest::Client::new(),
11995 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
11996 PINNED_CLIENT_TTL,
11997 PINNED_CLIENT_MAX_ENTRIES,
11998 )),
11999 http_config: HttpConfig::default(),
12000 };
12001 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
12002 let result = endpoint.create_consumer(rt);
12003 assert!(result.is_err(), "expected error for https without tls cert");
12004 if let Err(e) = result {
12005 let msg = e.to_string();
12006 assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
12007 }
12008 }
12009
12010 #[test]
12011 fn http_consumer_with_tls_config_errors() {
12012 let endpoint = HttpEndpoint {
12013 uri: "http://0.0.0.0:8080/api".to_string(),
12014 config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
12015 server_config: HttpServerConfig::from_uri(
12016 "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
12017 )
12018 .unwrap(),
12019 client: reqwest::Client::new(),
12020 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
12021 PINNED_CLIENT_TTL,
12022 PINNED_CLIENT_MAX_ENTRIES,
12023 )),
12024 http_config: HttpConfig::default(),
12025 };
12026 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
12027 let result = endpoint.create_consumer(rt);
12028 assert!(result.is_err(), "expected error for http with tls config");
12029 if let Err(e) = result {
12030 let msg = e.to_string();
12031 assert!(msg.contains("https"), "error must mention https: {msg}");
12032 }
12033 }
12034
12035 #[test]
12036 fn https_consumer_with_partial_tls_cert_only_errors() {
12037 let server_config =
12040 HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
12041 assert!(
12042 server_config.tls_config.is_none(),
12043 "partial tlsCert must not create ServerTlsConfig"
12044 );
12045 let endpoint = HttpEndpoint {
12046 uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
12047 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
12048 .unwrap(),
12049 server_config,
12050 client: reqwest::Client::new(),
12051 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
12052 PINNED_CLIENT_TTL,
12053 PINNED_CLIENT_MAX_ENTRIES,
12054 )),
12055 http_config: HttpConfig::default(),
12056 };
12057 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
12058 let result = endpoint.create_consumer(rt);
12059 assert!(
12060 result.is_err(),
12061 "must error: https:// requires both tlsCert and tlsKey"
12062 );
12063 }
12064
12065 #[test]
12066 fn load_tls_config_parses_valid_pem() {
12067 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
12069 use camel_component_api::test_support::tls;
12070 let (_, cert_pem, key_pem) = tls::gen_server_cert();
12071 let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
12072 let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
12073
12074 let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
12075 assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
12076 }
12077
12078 #[tokio::test(flavor = "multi_thread")]
12079 #[allow(clippy::await_holding_lock)]
12080 async fn consumer_tls_handshake_roundtrip() {
12081 use camel_component_api::test_support::tls;
12082 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
12083
12084 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
12086
12087 let _guard = lock_registry_test_mutex();
12089
12090 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
12092 let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
12093 let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
12094 let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
12095
12096 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12098 let port = probe.local_addr().unwrap().port();
12099 drop(probe);
12100
12101 ServerRegistry::reset();
12102
12103 let component = HttpComponent::new();
12105 let endpoint_ctx = NoOpComponentContext;
12106 let uri = format!(
12107 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
12108 cert_path.to_string_lossy(),
12109 key_path.to_string_lossy(),
12110 );
12111 let endpoint = component
12112 .create_endpoint(&uri, &endpoint_ctx)
12113 .expect("create TLS endpoint");
12114 let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
12115
12116 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
12118 let token = tokio_util::sync::CancellationToken::new();
12119 let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
12120 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
12121
12122 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
12124
12125 let ca_bytes = std::fs::read(&ca_path).unwrap();
12127 let client = reqwest::Client::builder()
12128 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
12129 .build()
12130 .unwrap();
12131
12132 let send_fut = client
12133 .post(format!("https://localhost:{port}/test"))
12134 .body("ping")
12135 .send();
12136
12137 let (http_result, _) = tokio::join!(send_fut, async {
12139 if let Some(mut envelope) = rx.recv().await {
12140 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
12141 if let Some(reply_tx) = envelope.reply_tx {
12142 let _ = reply_tx.send(Ok(envelope.exchange));
12143 }
12144 }
12145 });
12146
12147 let resp = http_result.expect("TLS handshake + request must succeed");
12148
12149 assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
12150 let body = resp.text().await.unwrap();
12151 assert_eq!(body, "pong");
12152
12153 token.cancel();
12154 }
12155
12156 #[tokio::test(flavor = "multi_thread")]
12157 #[allow(clippy::await_holding_lock)]
12158 async fn consumer_tls_rejects_client_without_ca() {
12159 use camel_component_api::test_support::tls;
12160 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
12161
12162 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
12163
12164 let _guard = lock_registry_test_mutex();
12166
12167 let (_, cert_pem, key_pem) = tls::gen_server_cert();
12168 let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
12169 let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
12170
12171 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12172 let port = probe.local_addr().unwrap().port();
12173 drop(probe);
12174
12175 ServerRegistry::reset();
12176
12177 let component = HttpComponent::new();
12179 let endpoint_ctx = NoOpComponentContext;
12180 let uri = format!(
12181 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
12182 cert_path.to_string_lossy(),
12183 key_path.to_string_lossy(),
12184 );
12185 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
12186 let mut consumer = endpoint.create_consumer(rt()).unwrap();
12187 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
12188 let token = tokio_util::sync::CancellationToken::new();
12189 let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
12190 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
12191
12192 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
12193
12194 let client = reqwest::Client::builder().build().unwrap();
12196
12197 let result = client
12198 .get(format!("https://localhost:{port}/test"))
12199 .send()
12200 .await;
12201
12202 assert!(
12203 result.is_err(),
12204 "must reject without CA — proves real verification"
12205 );
12206
12207 token.cancel();
12208 }
12209
12210 #[test]
12211 fn server_config_partial_tls_cert_without_key() {
12212 let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
12214 assert!(cfg.tls_config.is_none());
12216 }
12217
12218 #[test]
12219 fn endpoint_uri_options_count_parity() {
12220 assert_eq!(
12222 HttpEndpointConfig::uri_options().len(),
12223 23,
12224 "HttpEndpointUriConfig #[uri_param] count drifted from parser"
12225 );
12226 }
12227
12228 fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
12229 pairs
12230 .iter()
12231 .map(|(k, v)| {
12232 (
12233 (*k).to_string(),
12234 serde_json::Value::String((*v).to_string()),
12235 )
12236 })
12237 .collect()
12238 }
12239
12240 #[test]
12241 fn response_emits_cache_control_via_pragma_warning() {
12242 let headers = make_headers(&[
12243 ("Cache-Control", "public, max-age=3600"),
12244 ("Via", "1.1 myproxy"),
12245 ("Pragma", "no-cache"),
12246 ("Warning", "199 misc"),
12247 ]);
12248 let selected = select_response_headers(&headers, None, None);
12249 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12250 for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
12251 assert!(
12252 names.contains(&expected),
12253 "{expected} should pass through to the response"
12254 );
12255 }
12256 }
12257
12258 #[test]
12259 fn response_excludes_request_only_and_server_owned() {
12260 let headers = make_headers(&[
12261 ("User-Agent", "x"),
12262 ("Accept", "*/*"),
12263 ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
12264 ]);
12265 let selected = select_response_headers(&headers, None, None);
12266 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12267 for excluded in ["User-Agent", "Accept", "Date"] {
12268 assert!(
12269 !names.contains(&excluded),
12270 "{excluded} should NOT appear in the response"
12271 );
12272 }
12273 }
12274
12275 #[test]
12276 fn response_re_derives_content_type() {
12277 let headers = make_headers(&[("Content-Type", "text/plain")]);
12278 let selected = select_response_headers(&headers, Some("application/json".into()), None);
12279 let ct_entries: Vec<&str> = selected
12280 .iter()
12281 .filter(|(k, _)| k == "Content-Type")
12282 .map(|(_, v)| v.as_str())
12283 .collect();
12284 assert_eq!(
12285 ct_entries,
12286 ["application/json"],
12287 "exactly one Content-Type entry, re-derived from user_content_type"
12288 );
12289 }
12290
12291 #[test]
12292 fn response_excludes_camel_headers() {
12293 let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
12294 let selected = select_response_headers(&headers, None, None);
12295 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12296 assert!(
12297 !names.contains(&"CamelHttpPath"),
12298 "Camel-namespace headers must be excluded"
12299 );
12300 assert!(
12301 names.contains(&"Cache-Control"),
12302 "Cache-Control must pass through"
12303 );
12304 }
12305
12306 #[test]
12307 fn response_stringifies_scalar_header_values() {
12308 let mut headers = make_headers(&[("X-Label", "keep")]);
12309 headers.insert("X-Retries".to_string(), serde_json::json!(3));
12310 headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
12311 headers.insert("X-Enabled".to_string(), serde_json::json!(true));
12312 let selected = select_response_headers(&headers, None, None);
12313 let get = |name: &str| -> Option<&str> {
12314 selected
12315 .iter()
12316 .find(|(k, _)| k == name)
12317 .map(|(_, v)| v.as_str())
12318 };
12319 assert_eq!(
12320 get("X-Retries"),
12321 Some("3"),
12322 "integer header must be stringified"
12323 );
12324 assert_eq!(
12325 get("X-Ratio"),
12326 Some("3.5"),
12327 "float header must be stringified"
12328 );
12329 assert_eq!(
12330 get("X-Enabled"),
12331 Some("true"),
12332 "bool header must be stringified"
12333 );
12334 assert_eq!(
12335 get("X-Label"),
12336 Some("keep"),
12337 "string header must pass through"
12338 );
12339 }
12340
12341 #[test]
12342 fn response_drops_null_and_structured_header_values() {
12343 let mut headers = make_headers(&[("X-Keep", "yes")]);
12344 headers.insert("X-Null".to_string(), serde_json::Value::Null);
12345 headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
12346 headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
12347 let selected = select_response_headers(&headers, None, None);
12348 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12349 for dropped in ["X-Null", "X-Obj", "X-Arr"] {
12350 assert!(
12351 !names.contains(&dropped),
12352 "{dropped} must not be emitted: no single-value form"
12353 );
12354 }
12355 assert!(names.contains(&"X-Keep"), "scalar headers must survive");
12356 }
12357
12358 #[test]
12359 fn response_stringifies_scalars_despite_excluded_names() {
12360 let mut headers = HashMap::new();
12364 headers.insert("Content-Length".to_string(), serde_json::json!(999));
12365 headers.insert("Date".to_string(), serde_json::json!(12345));
12366 let selected = select_response_headers(&headers, None, None);
12367 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12368 assert!(
12369 !names.contains(&"Content-Length"),
12370 "content-length is re-derived by the server"
12371 );
12372 assert!(!names.contains(&"Date"), "date is server-owned");
12373 }
12374
12375 #[test]
12376 fn outbound_stringifies_scalar_header_values() {
12377 let mut headers = make_headers(&[("X-Label", "keep")]);
12378 headers.insert("X-Retries".to_string(), serde_json::json!(3));
12379 headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
12380 headers.insert("X-Enabled".to_string(), serde_json::json!(true));
12381 let outbound = select_outbound_headers(&headers, &[], &[]);
12382 let get = |name: &str| -> Option<String> {
12384 outbound
12385 .accepted
12386 .iter()
12387 .find(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12388 .map(|(_, v)| v.to_str().unwrap().to_string())
12389 };
12390 assert_eq!(
12391 get("X-Retries").as_deref(),
12392 Some("3"),
12393 "integer header must be stringified"
12394 );
12395 assert_eq!(
12396 get("X-Ratio").as_deref(),
12397 Some("3.5"),
12398 "float header must be stringified"
12399 );
12400 assert_eq!(
12401 get("X-Enabled").as_deref(),
12402 Some("true"),
12403 "bool header must be stringified"
12404 );
12405 assert_eq!(
12406 get("X-Label").as_deref(),
12407 Some("keep"),
12408 "string header must pass through"
12409 );
12410 assert!(outbound.drops.is_empty(), "scalar headers must not drop");
12411 }
12412
12413 #[test]
12414 fn outbound_drops_null_and_structured_header_values() {
12415 let mut headers = make_headers(&[("X-Keep", "yes")]);
12416 headers.insert("X-Null".to_string(), serde_json::Value::Null);
12417 headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
12418 headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
12419 let outbound = select_outbound_headers(&headers, &[], &[]);
12420 let has = |name: &str| {
12421 outbound
12422 .accepted
12423 .iter()
12424 .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12425 };
12426 assert!(has("X-Keep"), "scalar headers must survive");
12427 for (name, kind) in [("X-Null", "null"), ("X-Obj", "object"), ("X-Arr", "array")] {
12428 let dropped = outbound
12429 .drops
12430 .iter()
12431 .find(|d| d.name == name)
12432 .unwrap_or_else(|| panic!("{name} must have a drop record: {:?}", outbound.drops));
12433 assert_eq!(
12434 dropped.reason, "no scalar string form",
12435 "{name} drop reason must name the value kind absence"
12436 );
12437 assert_eq!(dropped.value_kind, Some(kind), "{name} kind recorded");
12438 }
12439 }
12440
12441 #[test]
12442 fn outbound_stringifies_scalars_despite_excluded_names() {
12443 let mut headers = HashMap::new();
12447 headers.insert("Transfer-Encoding".to_string(), serde_json::json!(7));
12448 headers.insert("Host".to_string(), serde_json::json!(12345));
12449 headers.insert("X-Ok".to_string(), serde_json::json!(7));
12450 let outbound = select_outbound_headers(&headers, &[], &[]);
12451 let has = |name: &str| {
12452 outbound
12453 .accepted
12454 .iter()
12455 .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12456 };
12457 assert!(
12458 !has("Transfer-Encoding"),
12459 "hop-by-hop header must stay excluded"
12460 );
12461 assert!(!has("Host"), "host is destination-derived");
12462 assert!(has("X-Ok"), "non-excluded scalar must be stringified");
12463 assert!(
12464 outbound
12465 .drops
12466 .iter()
12467 .any(|d| d.name == "Transfer-Encoding" && d.reason == "outbound emission policy"),
12468 "policy drop must be recorded before coercion"
12469 );
12470 }
12471
12472 #[test]
12473 fn outbound_drops_invalid_names_values_and_skip_config() {
12474 let mut headers = make_headers(&[("X-Good", "fine")]);
12475 headers.insert("X Bad Name".to_string(), serde_json::json!("v"));
12476 headers.insert(
12477 "X-Control-Value".to_string(),
12478 serde_json::json!("line1\nline2"),
12479 );
12480 headers.insert("X-Secret".to_string(), serde_json::json!("s3cr3t"));
12481 headers.insert("CamelHttpQuery".to_string(), serde_json::json!("q=1"));
12482 let skip = vec!["x-secret".to_string()];
12483 let outbound = select_outbound_headers(&headers, &skip, &[]);
12484 let has = |name: &str| {
12485 outbound
12486 .accepted
12487 .iter()
12488 .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12489 };
12490 assert!(has("X-Good"), "valid header must survive");
12491 assert!(!has("X Bad Name"), "invalid header name must drop");
12492 assert!(!has("X-Control-Value"), "control-char value must drop");
12493 assert!(!has("X-Secret"), "skipped header must drop");
12494 assert!(!has("CamelHttpQuery"), "Camel-namespace header must drop");
12495 let reason = |n: &str| {
12496 outbound
12497 .drops
12498 .iter()
12499 .find(|d| d.name == n)
12500 .map(|d| d.reason)
12501 };
12502 assert_eq!(reason("X Bad Name"), Some("invalid header name"));
12503 assert_eq!(reason("X-Control-Value"), Some("invalid header value"));
12504 assert_eq!(reason("X-Secret"), Some("skip_request_headers"));
12505 assert_eq!(reason("CamelHttpQuery"), Some("Camel namespace"));
12506 }
12507
12508 #[test]
12509 fn constructed_header_invalid_value_returns_drop_record() {
12510 let result = constructed_header("user-agent", "bad\r\ns3nt1nel");
12511 let Err(record) = result else {
12512 panic!("invalid value must produce a drop record");
12513 };
12514 assert_eq!(record.reason, "invalid header value");
12515 assert_eq!(record.name, "user-agent");
12516 assert!(record.value_kind.is_none());
12517 let debug = format!("{record:?}");
12518 assert!(
12519 !debug.contains("bad\r\n") && !debug.contains("s3nt1nel"),
12520 "drop record debug must not leak the value"
12521 );
12522 }
12523
12524 #[test]
12525 fn constructed_header_invalid_name_returns_drop_record() {
12526 let result = constructed_header("bad name", "ok");
12527 let Err(record) = result else {
12528 panic!("invalid name must produce a drop record");
12529 };
12530 assert_eq!(record.reason, "invalid header name");
12531 assert_eq!(record.name, "bad name");
12532 let debug = format!("{record:?}");
12533 assert!(
12534 !debug.contains("ok"),
12535 "drop record debug must not leak the value"
12536 );
12537 }
12538
12539 #[test]
12540 fn constructed_header_valid_pair_roundtrip() {
12541 let result = constructed_header("authorization", "Bearer abc123");
12542 let Ok((name, val)) = result else {
12543 panic!("valid pair must construct");
12544 };
12545 assert_eq!(name.as_str(), "authorization");
12546 let Ok(roundtrip) = val.to_str() else {
12547 panic!("valid value must roundtrip to str");
12548 };
12549 assert_eq!(roundtrip, "Bearer abc123");
12550 }
12551
12552 async fn start_host_capturing_destination() -> (
12564 String,
12565 Arc<std::sync::Mutex<Option<(String, String)>>>,
12566 tokio::task::JoinHandle<()>,
12567 ) {
12568 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12569 let port = listener.local_addr().unwrap().port();
12570 let url = format!("http://127.0.0.1:{port}");
12571 let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
12572 Arc::new(std::sync::Mutex::new(None));
12573 let captured_clone = Arc::clone(&captured);
12574 let handle = tokio::spawn(async move {
12575 use tokio::io::{AsyncReadExt, AsyncWriteExt};
12576 if let Ok((mut stream, _)) = listener.accept().await {
12577 let mut buf = vec![0u8; 16384];
12578 let n = stream.read(&mut buf).await.unwrap_or(0);
12579 let request = String::from_utf8_lossy(&buf[..n]).to_string();
12580 if request.contains("\r\n\r\n") {
12581 let request_line = request.lines().next().unwrap_or("").to_string();
12582 let host_value = request
12583 .lines()
12584 .find(|l| l.to_lowercase().starts_with("host:"))
12585 .and_then(|l| l.split_once(':'))
12586 .map(|(_, v)| v.trim().to_string())
12587 .unwrap_or_default();
12588 *captured_clone.lock().unwrap() = Some((host_value, request_line));
12589 }
12590 let body = r#"{"echo":"ok"}"#;
12591 let resp = format!(
12592 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
12593 body.len(),
12594 body
12595 );
12596 let _ = stream.write_all(resp.as_bytes()).await;
12597 }
12598 });
12599 (url, captured, handle)
12600 }
12601
12602 #[tokio::test]
12607 async fn bridge_proxy_outbound_host_matches_destination() {
12608 use tower::ServiceExt;
12609
12610 let (url, captured, _handle) = start_host_capturing_destination().await;
12611 let expected_host = url.strip_prefix("http://").unwrap();
12614
12615 let ctx = test_producer_ctx();
12616 let component = HttpComponent::new();
12617 let endpoint_ctx = NoOpComponentContext;
12618 let endpoint = component
12619 .create_endpoint(
12620 &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
12621 &endpoint_ctx,
12622 )
12623 .unwrap();
12624 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
12625
12626 let mut exchange = Exchange::new(Message::default());
12629 exchange.input.set_header("Host", "localhost");
12630 exchange.input.set_header("CamelHttpPath", "/foo");
12631
12632 let result = producer.oneshot(exchange).await;
12633 assert!(result.is_ok(), "producer call failed: {:?}", result);
12634
12635 tokio::time::sleep(Duration::from_millis(100)).await;
12636 let (host_value, request_line) = captured
12637 .lock()
12638 .unwrap()
12639 .take()
12640 .expect("destination capture mutex empty — producer did not reach the destination");
12641
12642 assert_ne!(
12643 host_value, "localhost",
12644 "bridge producer must not forward the exchange Host: localhost"
12645 );
12646 assert_eq!(
12647 host_value, expected_host,
12648 "Host must be derived from the destination authority (no scheme)"
12649 );
12650 assert!(
12651 !request_line.contains("/foo"),
12652 "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
12653 );
12654 }
12655
12656 #[tokio::test]
12661 async fn bridge_proxy_route_set_response_header_survives() {
12662 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
12663
12664 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12665 let port = listener.local_addr().unwrap().port();
12666 drop(listener);
12667
12668 let component = HttpComponent::new();
12669 let endpoint_ctx = NoOpComponentContext;
12670 let endpoint = component
12671 .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
12672 .unwrap();
12673 let mut consumer = endpoint.create_consumer(rt()).unwrap();
12674
12675 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
12676 let token = tokio_util::sync::CancellationToken::new();
12677 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
12678
12679 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
12680 tokio::time::sleep(Duration::from_millis(50)).await;
12681
12682 let client = reqwest::Client::new();
12683 let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
12684
12685 let (http_result, _) = tokio::join!(send_fut, async {
12689 if let Some(mut envelope) = rx.recv().await {
12690 envelope
12691 .exchange
12692 .input
12693 .set_header("Cache-Control", "public, max-age=3600");
12694 if let Some(reply_tx) = envelope.reply_tx {
12695 let _ = reply_tx.send(Ok(envelope.exchange));
12696 }
12697 }
12698 });
12699
12700 let resp = http_result.unwrap();
12701 assert_eq!(resp.status().as_u16(), 200);
12702
12703 let cache_control = resp.headers().get("cache-control");
12704 assert!(
12705 cache_control.is_some(),
12706 "Cache-Control header must survive to the wire response"
12707 );
12708 assert_eq!(
12709 cache_control.unwrap().to_str().unwrap(),
12710 "public, max-age=3600"
12711 );
12712
12713 token.cancel();
12714 }
12715
12716 use camel_api::security_policy::CredentialSource;
12735 use camel_auth::credential_source::extract_token_from_exchange;
12736 use camel_auth::native_auth::NativeCredentialStore;
12737 use camel_auth::{StaticTokenAuthenticator, TokenAuthenticator};
12738
12739 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 {
12747 let mut msg = Message::default();
12748 msg.set_header(
12749 "CamelHttpMethod",
12750 serde_json::Value::String(envelope.method.clone()),
12751 );
12752 msg.set_header(
12753 "CamelHttpPath",
12754 serde_json::Value::String(envelope.path.clone()),
12755 );
12756 msg.set_header(
12757 "CamelHttpQuery",
12758 serde_json::Value::String(envelope.query.clone()),
12759 );
12760 for (k, v) in &envelope.headers {
12761 if let Ok(val_str) = v.to_str() {
12762 msg.set_header(
12763 title_case_header(k.as_str()),
12764 serde_json::Value::String(val_str.to_string()),
12765 );
12766 }
12767 }
12768 Exchange::new(msg)
12769 }
12770
12771 async fn spawn_failing_auth_route(
12778 registry: &HttpRouteRegistry,
12779 path: &str,
12780 sources: Vec<CredentialSource>,
12781 ) {
12782 let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
12783 NativeCredentialStore::try_new(vec![]).unwrap(),
12784 ));
12785 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12786 registry.register_api_route(path.to_string(), tx).await;
12787 let path_owned = path.to_string();
12788 tokio::spawn(async move {
12789 while let Some(envelope) = rx.recv().await {
12790 let exchange = envelope_to_exchange(&envelope);
12791 let reply_tx = envelope.reply_tx;
12792 let result: Result<(), CamelError> = async {
12793 let token = extract_token_from_exchange(&exchange, &sources)
12794 .map(|extracted| extracted.token)
12795 .ok_or_else(|| {
12796 CamelError::Unauthenticated("no credential in any source".into())
12797 })?;
12798 authenticator.authenticate_bearer(&token).await?;
12799 Ok(())
12800 }
12801 .await;
12802 let reply = match result {
12803 Ok(()) => HttpReply {
12804 status: 200,
12805 headers: vec![],
12806 body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
12807 },
12808 Err(e) => pipeline_error_to_reply(e, &path_owned),
12809 };
12810 let _ = reply_tx.send(reply);
12811 }
12812 });
12813 }
12814
12815 fn captured_logs_contain(needle: &str) -> bool {
12819 let buf = tracing_test::internal::global_buf().lock().unwrap();
12820 String::from_utf8_lossy(&buf).contains(needle)
12821 }
12822
12823 #[tracing_test::traced_test]
12824 #[tokio::test]
12825 async fn error_context_redacts_query_sentinel() {
12826 let (port, registry) = spawn_test_server().await;
12827 spawn_failing_auth_route(
12828 ®istry,
12829 "/secure-query",
12830 vec![CredentialSource::QueryParam {
12831 param: "token".to_string(),
12832 }],
12833 )
12834 .await;
12835
12836 let client = reqwest::Client::new();
12837 let resp = client
12838 .get(format!(
12840 "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
12841 ))
12842 .send()
12843 .await
12844 .unwrap();
12845
12846 assert_eq!(resp.status().as_u16(), 401);
12847 let body = resp.text().await.unwrap();
12848 assert_eq!(body, "Unauthorized");
12849 assert!(
12850 !body.contains(SENTINEL_QRY_42),
12851 "reply body must not contain the query credential"
12852 );
12853 assert!(
12854 !captured_logs_contain(SENTINEL_QRY_42),
12855 "no tracing record during request handling may render the query credential"
12856 );
12857 assert!(
12861 captured_logs_contain("Authentication failed"),
12862 "positive control: the failed-auth warn! must be captured by the test subscriber"
12863 );
12864 }
12865
12866 #[tracing_test::traced_test]
12867 #[tokio::test]
12868 async fn error_context_redacts_cookie_sentinel() {
12869 let (port, registry) = spawn_test_server().await;
12870 spawn_failing_auth_route(
12871 ®istry,
12872 "/secure-cookie",
12873 vec![CredentialSource::Cookie {
12874 name: "session".to_string(),
12875 }],
12876 )
12877 .await;
12878
12879 let client = reqwest::Client::new();
12880 let resp = client
12881 .get(format!("http://127.0.0.1:{port}/secure-cookie"))
12882 .header("Cookie", format!("session={SENTINEL_CKY_7}"))
12883 .send()
12884 .await
12885 .unwrap();
12886
12887 assert_eq!(resp.status().as_u16(), 401);
12888 let body = resp.text().await.unwrap();
12889 assert_eq!(body, "Unauthorized");
12890 assert!(
12891 !body.contains(SENTINEL_CKY_7),
12892 "reply body must not contain the cookie credential"
12893 );
12894 assert!(
12895 !captured_logs_contain(SENTINEL_CKY_7),
12896 "no tracing record during request handling may render the cookie credential"
12897 );
12898 }
12899
12900 #[tracing_test::traced_test]
12901 #[tokio::test]
12902 async fn error_reply_no_credential_value() {
12903 let (port, registry) = spawn_test_server().await;
12904 spawn_failing_auth_route(
12905 ®istry,
12906 "/secure-bad",
12907 vec![CredentialSource::Cookie {
12908 name: "session".to_string(),
12909 }],
12910 )
12911 .await;
12912
12913 let client = reqwest::Client::new();
12914 let resp = client
12915 .get(format!("http://127.0.0.1:{port}/secure-bad"))
12916 .header("Cookie", format!("session={SENTINEL_BAD_1}"))
12917 .send()
12918 .await
12919 .unwrap();
12920
12921 assert_eq!(resp.status().as_u16(), 401);
12922 let body = resp.text().await.unwrap();
12923 assert_eq!(body, "Unauthorized");
12924 assert!(
12925 !body.contains(SENTINEL_BAD_1),
12926 "reply body must not contain the credential value"
12927 );
12928 assert!(
12929 !captured_logs_contain(SENTINEL_BAD_1),
12930 "error logs must not render the credential value"
12931 );
12932 }
12933
12934 async fn spawn_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
12948 use tokio::io::AsyncWriteExt;
12949
12950 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
12951 .await
12952 .expect("bind ephemeral 127.0.0.1 listener");
12953 let port = listener.local_addr().expect("local addr").port();
12954 let base_url = format!("http://localhost:{port}");
12955 let handle = tokio::spawn(async move {
12956 while let Ok((mut conn, _)) = listener.accept().await {
12957 let _ = conn
12958 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
12959 .await;
12960 let _ = conn.shutdown().await;
12961 }
12962 });
12963 (base_url, handle)
12964 }
12965
12966 async fn spawn_tls_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
12973 use tokio::io::AsyncWriteExt;
12974
12975 let (_ca_pem, cert_pem, key_pem) =
12976 camel_component_api::test_support::tls::gen_server_cert();
12977 let certs: Vec<_> = rustls_pemfile::certs(&mut cert_pem.as_bytes())
12978 .collect::<Result<_, _>>()
12979 .expect("parse server cert pem");
12980 let key = rustls_pemfile::private_key(&mut key_pem.as_bytes())
12981 .expect("parse server key pem")
12982 .expect("server key present");
12983 let provider =
12986 std::sync::Arc::new(tokio_rustls::rustls::crypto::aws_lc_rs::default_provider());
12987 let tls_cfg = tokio_rustls::rustls::ServerConfig::builder_with_provider(provider)
12988 .with_safe_default_protocol_versions()
12989 .expect("safe default protocol versions")
12990 .with_no_client_auth()
12991 .with_single_cert(certs, key)
12992 .expect("build rustls server config");
12993 let acceptor = tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(tls_cfg));
12994
12995 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
12996 .await
12997 .expect("bind ephemeral 127.0.0.1 listener");
12998 let port = listener.local_addr().expect("local addr").port();
12999 let base_url = format!("https://localhost:{port}");
13000 let handle = tokio::spawn(async move {
13001 while let Ok((conn, _)) = listener.accept().await {
13002 let acceptor = acceptor.clone();
13003 tokio::spawn(async move {
13004 if let Ok(mut tls) = acceptor.accept(conn).await {
13005 let _ = tls
13006 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
13007 .await;
13008 let _ = tls.shutdown().await;
13009 }
13010 });
13011 }
13012 });
13013 (base_url, handle)
13014 }
13015
13016 fn responder_port(base_url: &str) -> u16 {
13020 url::Url::parse(base_url)
13021 .expect("responder base URL parses")
13022 .port()
13023 .expect("responder base URL carries an explicit port")
13024 }
13025
13026 fn endpoint_with_shared_cache(
13031 base_url: &str,
13032 pinned_cache: &Arc<PinnedClientCache>,
13033 ) -> HttpEndpoint {
13034 let uri = format!("{base_url}?allowInternal=true");
13035 HttpEndpoint {
13036 uri: uri.clone(),
13037 config: HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses"),
13038 server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
13039 client: reqwest::Client::new(),
13040 pinned_cache: Arc::clone(pinned_cache),
13041 http_config: HttpConfig::default(),
13042 }
13043 }
13044
13045 #[tokio::test]
13046 async fn producers_share_endpoint_cache() {
13047 use tower::ServiceExt;
13048
13049 let (base_url, _handle) = spawn_multi_accept_200().await;
13050 let pinned_cache = Arc::new(PinnedClientCache::new(
13051 PINNED_CLIENT_TTL,
13052 PINNED_CLIENT_MAX_ENTRIES,
13053 ));
13054
13055 let ctx = test_producer_ctx();
13056 let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
13057 let producer_a = endpoint.create_producer(rt(), &ctx);
13058 let producer_b = endpoint.create_producer(rt(), &ctx);
13059
13060 for producer in [producer_a, producer_b] {
13063 let producer = producer.expect("create producer");
13064 let exchange = Exchange::new(Message::default());
13065 let reply = producer.oneshot(exchange).await;
13066 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
13067 }
13068
13069 assert_eq!(
13070 pinned_cache.build_count(),
13071 1,
13072 "both producers must hit the same shared cache entry; a second \
13073 build means sharing is broken"
13074 );
13075 }
13076
13077 #[tokio::test]
13078 async fn producer_repeated_hostname_requests_build_one_client() {
13079 use tower::ServiceExt;
13080
13081 let (base_url, _handle) = spawn_multi_accept_200().await;
13082 let pinned_cache = Arc::new(PinnedClientCache::new(
13083 PINNED_CLIENT_TTL,
13084 PINNED_CLIENT_MAX_ENTRIES,
13085 ));
13086 let ctx = test_producer_ctx();
13087 let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
13088 let producer = endpoint
13089 .create_producer(rt(), &ctx)
13090 .expect("create producer");
13091
13092 for i in 0..2 {
13095 let exchange = Exchange::new(Message::default());
13096 let reply = producer.clone().oneshot(exchange).await;
13097 assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
13098 }
13099
13100 assert_eq!(
13101 pinned_cache.build_count(),
13102 1,
13103 "repeated hostname requests must reuse the one pinned client; \
13104 0 builds means the producer bypassed the cache, more than 1 \
13105 means the entry was dropped"
13106 );
13107 }
13108
13109 #[tokio::test]
13110 async fn ip_literal_request_never_enters_cache() {
13111 use tower::ServiceExt;
13112
13113 let (base_url, _handle) = spawn_multi_accept_200().await;
13114 let pinned_cache = Arc::new(PinnedClientCache::new(
13115 PINNED_CLIENT_TTL,
13116 PINNED_CLIENT_MAX_ENTRIES,
13117 ));
13118
13119 let ctx = test_producer_ctx();
13120 let destination = format!("http://127.0.0.1:{}/ping", responder_port(&base_url));
13121 let endpoint = endpoint_with_shared_cache(&destination, &pinned_cache);
13122 let producer = endpoint
13123 .create_producer(rt(), &ctx)
13124 .expect("create producer");
13125
13126 let exchange = Exchange::new(Message::default());
13127 let reply = producer.oneshot(exchange).await;
13128 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
13129
13130 assert_eq!(
13131 pinned_cache.build_count(),
13132 0,
13133 "an IP-literal URL must use the shared unpinned client and \
13134 never enter the pinned cache"
13135 );
13136 }
13137
13138 #[tokio::test]
13139 async fn test_component_endpoints_share_pinned_cache() {
13140 use tower::ServiceExt;
13141
13142 let component = HttpComponent::new();
13143 let (base_url, _handle) = spawn_multi_accept_200().await;
13144 let baseline = component.pinned_cache.build_count();
13145
13146 let ctx = test_producer_ctx();
13147 let endpoint_ctx = NoOpComponentContext;
13148 for uri in [
13149 format!("{base_url}/a?allowInternal=true&k=a"),
13150 format!("{base_url}/b?allowInternal=true&k=b"),
13151 ] {
13152 let endpoint = component
13153 .create_endpoint(&uri, &endpoint_ctx)
13154 .expect("create endpoint");
13155 let producer = endpoint
13156 .create_producer(rt(), &ctx)
13157 .expect("create producer");
13158 let exchange = Exchange::new(Message::default());
13159 let reply = producer.oneshot(exchange).await;
13160 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
13161 }
13162
13163 assert_eq!(
13164 component.pinned_cache.build_count() - baseline,
13165 1,
13166 "endpoints created by one component must share its pinned cache; \
13167 0 builds means the endpoints bypassed it, more than 1 means \
13168 per-endpoint caches came back"
13169 );
13170 }
13171
13172 #[tokio::test]
13173 async fn test_dynamic_resolution_sequence_hits_shared_cache() {
13174 use tower::ServiceExt;
13175
13176 let component = HttpComponent::new();
13177 let (base_url, _handle) = spawn_multi_accept_200().await;
13178 let baseline = component.pinned_cache.build_count();
13179
13180 let ctx = test_producer_ctx();
13181 let endpoint_ctx = NoOpComponentContext;
13182 for i in 0..3 {
13183 let endpoint = component
13184 .create_endpoint(
13185 &format!("{base_url}/r{i}?allowInternal=true&k={i}"),
13186 &endpoint_ctx,
13187 )
13188 .expect("create endpoint");
13189 let producer = endpoint
13190 .create_producer(rt(), &ctx)
13191 .expect("create producer");
13192 let exchange = Exchange::new(Message::default());
13193 let reply = producer.oneshot(exchange).await;
13194 assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
13195 }
13196
13197 assert_eq!(
13198 component.pinned_cache.build_count() - baseline,
13199 1,
13200 "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
13201 must reuse the component's one pinned cache entry; 0 builds \
13202 means the endpoints bypassed it, more than 1 means \
13203 per-endpoint caches came back"
13204 );
13205 }
13206
13207 #[tokio::test]
13215 async fn test_https_component_endpoints_share_pinned_cache_behaviorally() {
13216 use tower::ServiceExt;
13217
13218 let http_config = HttpConfig {
13219 tls: Some(crate::config::TlsConfig {
13220 enabled: true,
13221 insecure: true,
13222 ..Default::default()
13223 }),
13224 ..Default::default()
13225 };
13226 let component = HttpsComponent::with_config(http_config);
13227 let (base_url, _handle) = spawn_tls_multi_accept_200().await;
13228 let baseline = component.pinned_cache.build_count();
13229
13230 let ctx = test_producer_ctx();
13231 let endpoint_ctx = NoOpComponentContext;
13232 for uri in [
13233 format!("{base_url}/a?allowInternal=true&k=a"),
13234 format!("{base_url}/b?allowInternal=true&k=b"),
13235 ] {
13236 let endpoint = component
13237 .create_endpoint(&uri, &endpoint_ctx)
13238 .expect("create https endpoint");
13239 let producer = endpoint
13240 .create_producer(rt(), &ctx)
13241 .expect("create producer");
13242 let exchange = Exchange::new(Message::default());
13243 let reply = producer.oneshot(exchange).await;
13244 assert!(reply.is_ok(), "https request failed: {reply:?}");
13245 }
13246
13247 assert_eq!(
13248 component.pinned_cache.build_count() - baseline,
13249 1,
13250 "endpoints of one HttpsComponent must share its pinned cache over \
13251 real https requests; 0 builds means the endpoints bypassed it \
13252 (per-endpoint cache regression), more than 1 means \
13253 per-endpoint caches came back"
13254 );
13255 }
13256
13257 #[test]
13258 fn test_https_component_owns_distinct_cache() {
13259 let http = HttpComponent::new();
13260 let https = HttpsComponent::new();
13261
13262 assert!(
13263 !Arc::ptr_eq(&http.pinned_cache, &https.pinned_cache),
13264 "http and https components must each own their own pinned cache"
13265 );
13266
13267 let endpoint_ctx = NoOpComponentContext;
13268 let _ = http
13269 .create_endpoint("http://localhost:1/?allowInternal=true", &endpoint_ctx)
13270 .expect("http endpoint");
13271 let _ = https
13272 .create_endpoint("https://localhost:1/?allowInternal=true", &endpoint_ctx)
13273 .expect("https endpoint");
13274
13275 assert_eq!(
13276 http.pinned_cache.build_count(),
13277 0,
13278 "endpoint creation must not build a pinned client"
13279 );
13280 assert_eq!(
13281 https.pinned_cache.build_count(),
13282 0,
13283 "endpoint creation must not build a pinned client"
13284 );
13285 }
13286
13287 #[test]
13288 fn test_component_constructor_builds_one_unpinned_client() {
13289 let baseline = build_client_call_count();
13290
13291 let _http = HttpComponent::new();
13292 assert_eq!(
13293 build_client_call_count() - baseline,
13294 1,
13295 "HttpComponent::new() must build exactly one shared unpinned client"
13296 );
13297
13298 let _https = HttpsComponent::new();
13299 assert_eq!(
13300 build_client_call_count() - baseline,
13301 2,
13302 "HttpsComponent::new() must build exactly one more shared unpinned client"
13303 );
13304 }
13305
13306 #[test]
13307 fn test_component_endpoints_share_unpinned_client() {
13308 let component = HttpComponent::new();
13309 let baseline = build_client_call_count();
13310
13311 let endpoint_ctx = NoOpComponentContext;
13312 for uri in [
13313 "http://localhost:1/a?allowInternal=true",
13314 "http://localhost:1/b?allowInternal=true",
13315 ] {
13316 let _endpoint = component
13317 .create_endpoint(uri, &endpoint_ctx)
13318 .expect("create endpoint");
13319 }
13320
13321 assert_eq!(
13322 build_client_call_count() - baseline,
13323 0,
13324 "create_endpoint must clone the component's shared unpinned client, \
13325 never build a fresh one"
13326 );
13327 }
13328
13329 #[test]
13330 fn test_dynamic_resolution_adds_no_unpinned_client_builds() {
13331 let component = HttpComponent::new();
13332 let baseline = build_client_call_count();
13333
13334 let ctx = test_producer_ctx();
13335 let endpoint_ctx = NoOpComponentContext;
13336 for i in 0..3 {
13337 let endpoint = component
13338 .create_endpoint(
13339 &format!("http://localhost:1/r{i}?allowInternal=true&k={i}"),
13340 &endpoint_ctx,
13341 )
13342 .expect("create endpoint");
13343 let _producer = endpoint
13344 .create_producer(rt(), &ctx)
13345 .expect("create producer");
13346 }
13347
13348 assert_eq!(
13349 build_client_call_count() - baseline,
13350 0,
13351 "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
13352 must reuse the component's shared unpinned client and build \
13353 no additional clients"
13354 );
13355 }
13356}