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 blocked_hosts: Vec<String>,
128 pub max_body_size: usize,
129 pub read_timeout_ms: u64,
130 pub max_response_bytes: usize,
131 pub auth: HttpAuth,
132 pub token_provider: Option<Arc<dyn TokenProvider>>,
133 pub user_agent: Option<String>,
134 pub bridge_endpoint: bool,
135 pub connection_close: bool,
136 pub skip_request_headers: Vec<String>,
137 pub skip_response_headers: Vec<String>,
138 pub follow_redirects: bool,
139 pub max_redirects: usize,
140 pub allowed_uri_hosts: Option<Vec<AllowedUriHost>>,
145}
146
147impl std::fmt::Debug for HttpEndpointConfig {
153 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154 f.debug_struct("HttpEndpointConfig")
155 .field("base_url", &mask_base_url_userinfo(&self.base_url))
156 .field("http_method", &self.http_method)
157 .field(
158 "throw_exception_on_failure",
159 &self.throw_exception_on_failure,
160 )
161 .field("ok_status_code_range", &self.ok_status_code_range)
162 .field("response_timeout", &self.response_timeout)
163 .field(
164 "query_params",
165 &self
166 .query_params
167 .iter()
168 .map(|(key, _)| (key, "***"))
169 .collect::<Vec<_>>(),
170 )
171 .field("raw_query", &self.raw_query.as_ref().map(|_| "?[redacted]"))
172 .field("allow_internal", &self.allow_internal)
173 .field("blocked_hosts", &self.blocked_hosts)
174 .field("max_body_size", &self.max_body_size)
175 .field("read_timeout_ms", &self.read_timeout_ms)
176 .field("max_response_bytes", &self.max_response_bytes)
177 .field("auth", &self.auth)
178 .field("token_provider", &self.token_provider)
179 .field("user_agent", &self.user_agent)
180 .field("bridge_endpoint", &self.bridge_endpoint)
181 .field("connection_close", &self.connection_close)
182 .field("skip_request_headers", &self.skip_request_headers)
183 .field("skip_response_headers", &self.skip_response_headers)
184 .field("follow_redirects", &self.follow_redirects)
185 .field("max_redirects", &self.max_redirects)
186 .field("allowed_uri_hosts", &self.allowed_uri_hosts)
187 .finish()
188 }
189}
190
191#[derive(Clone, PartialEq)]
192pub enum HttpAuth {
193 None,
194 Basic { username: String, password: String },
195 Bearer { token: String },
196}
197
198impl std::fmt::Debug for HttpAuth {
199 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200 match self {
201 HttpAuth::None => f.write_str("None"),
202 HttpAuth::Basic { username, .. } => f
203 .debug_struct("Basic")
204 .field("username", username)
205 .field("password", &"***")
206 .finish(),
207 HttpAuth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
208 }
209 }
210}
211
212fn is_consumed_option(key: &str) -> bool {
221 HttpEndpointConfig::uri_options()
222 .iter()
223 .any(|option| option.name == key || option.aliases.iter().any(|alias| alias == key))
224}
225
226impl UriConfig for HttpEndpointConfig {
227 fn scheme() -> &'static str {
229 "http"
230 }
231
232 fn from_uri(uri: &str) -> Result<Self, CamelError> {
233 let parts = parse_uri(uri)?;
234 Self::from_components(parts)
235 }
236
237 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
238 if parts.scheme != "http" && parts.scheme != "https" {
240 return Err(CamelError::InvalidUri(format!(
241 "expected scheme 'http' or 'https', got '{}'",
242 parts.scheme
243 )));
244 }
245
246 let base_url = format!("{}:{}", parts.scheme, parts.path);
249
250 let http_method = parts.params.get("httpMethod").cloned();
251
252 let throw_exception_on_failure = match parts.params.get("throwExceptionOnFailure") {
253 Some(v) => parse_bool_param_http(v).map_err(|e| {
254 CamelError::InvalidUri(format!("invalid value for throwExceptionOnFailure: {e}"))
255 })?,
256 None => true,
257 };
258
259 let ok_status_code_range = match parts.params.get("okStatusCodeRange") {
261 Some(v) => parse_ok_status_code_range(v)?,
262 None => (200, 299),
263 };
264
265 let response_timeout = match parts.params.get("responseTimeout") {
266 Some(v) => Some(v.parse::<u64>().map(Duration::from_millis).map_err(|e| {
267 CamelError::InvalidUri(format!("invalid value for responseTimeout: {e}"))
268 })?),
269 None => None,
270 };
271
272 let allow_internal = match parts.params.get("allowInternal") {
274 Some(v) => parse_bool_param_http(v).map_err(|e| {
275 CamelError::InvalidUri(format!("invalid value for allowInternal: {e}"))
276 })?,
277 None => false, };
279
280 let blocked_hosts = parts
282 .params
283 .get("blockedHosts")
284 .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
285 .unwrap_or_default();
286
287 let max_body_size = match parts.params.get("maxBodySize") {
288 Some(v) => v.parse::<usize>().map_err(|e| {
289 CamelError::InvalidUri(format!("invalid value for maxBodySize: {e}"))
290 })?,
291 None => 10 * 1024 * 1024, };
293
294 let read_timeout_ms = match parts.params.get("readTimeout") {
295 Some(v) => v.parse::<u64>().map_err(|e| {
296 CamelError::InvalidUri(format!("invalid value for readTimeout: {e}"))
297 })?,
298 None => 30_000, };
300
301 let max_response_bytes = match parts.params.get("maxResponseBytes") {
302 Some(v) => v.parse::<usize>().map_err(|e| {
303 CamelError::InvalidUri(format!("invalid value for maxResponseBytes: {e}"))
304 })?,
305 None => 10 * 1024 * 1024, };
307
308 let auth = parse_auth_from_params(&parts.params)?;
309
310 let user_agent = parts.params.get("userAgent").cloned();
311
312 if parts.params.contains_key("cookieHandling") {
313 return Err(CamelError::InvalidUri(
314 "cookieHandling is not supported".into(),
315 ));
316 }
317
318 let bridge_endpoint = match parts.params.get("bridgeEndpoint") {
319 Some(v) => parse_bool_param_http(v).map_err(|e| {
320 CamelError::InvalidUri(format!("invalid value for bridgeEndpoint: {e}"))
321 })?,
322 None => false,
323 };
324
325 let connection_close = match parts.params.get("connectionClose") {
326 Some(v) => parse_bool_param_http(v).map_err(|e| {
327 CamelError::InvalidUri(format!("invalid value for connectionClose: {e}"))
328 })?,
329 None => false,
330 };
331
332 let skip_request_headers = parts
333 .params
334 .get("skipRequestHeaders")
335 .map(|v| {
336 v.split(',')
337 .map(str::trim)
338 .filter(|s| !s.is_empty())
339 .map(|s| s.to_ascii_lowercase())
340 .collect::<Vec<_>>()
341 })
342 .unwrap_or_default();
343
344 let skip_response_headers = parts
345 .params
346 .get("skipResponseHeaders")
347 .map(|v| {
348 v.split(',')
349 .map(str::trim)
350 .filter(|s| !s.is_empty())
351 .map(|s| s.to_ascii_lowercase())
352 .collect::<Vec<_>>()
353 })
354 .unwrap_or_default();
355
356 let follow_redirects = match parts.params.get("followRedirects") {
357 Some(v) => parse_bool_param_http(v).map_err(|e| {
358 CamelError::InvalidUri(format!("invalid value for followRedirects: {e}"))
359 })?,
360 None => false,
361 };
362
363 let max_redirects = match parts.params.get("maxRedirects") {
364 Some(v) => v.parse::<usize>().map_err(|e| {
365 CamelError::InvalidUri(format!("invalid value for maxRedirects: {e}"))
366 })?,
367 None => 10,
368 };
369
370 let allowed_uri_hosts = match parts.params.get("allowedUriHosts") {
373 Some(v) => Some(parse_allowed_uri_hosts(v)?),
374 None => None,
375 };
376
377 let raw_query = parts.raw_query.clone();
382
383 Ok(Self {
384 base_url,
385 http_method,
386 throw_exception_on_failure,
387 ok_status_code_range,
388 response_timeout,
389 query_params: Vec::new(),
390 raw_query,
391 allow_internal,
392 blocked_hosts,
393 max_body_size,
394 read_timeout_ms,
395 max_response_bytes,
396 auth,
397 token_provider: None,
398 user_agent,
399 bridge_endpoint,
400 connection_close,
401 skip_request_headers,
402 skip_response_headers,
403 follow_redirects,
404 max_redirects,
405 allowed_uri_hosts,
406 })
407 }
408}
409
410#[derive(Debug, Clone, UriConfig)]
417#[allow(dead_code)]
418#[uri_scheme = "http"]
419#[uri_config(
420 skip_impl,
421 metadata(
422 scheme = "http",
423 description = "HTTP client and server component",
424 producer,
425 consumer,
426 streaming
427 ),
428 crate = "camel_component_api"
429)]
430struct HttpEndpointUriConfig {
431 #[allow(dead_code)]
432 _base_url: String,
433
434 #[uri_param(
435 name = "httpMethod",
436 desc = "HTTP method. Defaults to CamelHttpMethod header or POST/GET"
437 )]
438 http_method: Option<String>,
439
440 #[uri_param(
441 name = "throwExceptionOnFailure",
442 default = "true",
443 desc = "Throw on non-2xx status"
444 )]
445 throw_exception_on_failure: bool,
446
447 #[uri_param(
448 name = "okStatusCodeRange",
449 default = "200-299",
450 desc = "Success status code range"
451 )]
452 ok_status_code_range: String,
453
454 #[uri_param(name = "responseTimeout", desc = "Response timeout in milliseconds")]
455 response_timeout: Option<u64>,
456
457 #[uri_param(
458 name = "connectTimeout",
459 desc = "Connection timeout in milliseconds (consumed option; effective timeout comes from the global http config)"
460 )]
461 connect_timeout: Option<u64>,
462
463 #[uri_param(
464 name = "allowInternal",
465 default = "false",
466 desc = "Allow private/internal network destinations (SSRF)"
467 )]
468 allow_internal: bool,
469
470 #[uri_param(name = "blockedHosts", desc = "Comma-separated blocked host list")]
471 blocked_hosts: Option<String>,
472
473 #[uri_param(
474 name = "maxBodySize",
475 default = "10485760",
476 desc = "Max request/response body bytes"
477 )]
478 max_body_size: u64,
479
480 #[uri_param(name = "readTimeout", desc = "Socket read timeout in milliseconds")]
481 read_timeout: Option<u64>,
482
483 #[uri_param(name = "maxResponseBytes", desc = "Max response body bytes")]
484 max_response_bytes: Option<u64>,
485
486 #[uri_param(
487 name = "authMethod",
488 kind = "enum:Basic,Bearer",
489 desc = "Authentication method"
490 )]
491 auth_method: Option<String>,
492
493 #[uri_param(name = "authUsername", secret, desc = "Basic auth username")]
494 auth_username: Option<String>,
495
496 #[uri_param(name = "authPassword", secret, desc = "Basic auth password")]
497 auth_password: Option<String>,
498
499 #[uri_param(name = "authBearerToken", secret, desc = "Bearer auth token")]
500 auth_bearer_token: Option<String>,
501
502 #[uri_param(name = "userAgent", desc = "User-Agent header")]
503 user_agent: Option<String>,
504
505 #[uri_param(
506 name = "bridgeEndpoint",
507 default = "false",
508 desc = "Bridge endpoint mode"
509 )]
510 bridge_endpoint: bool,
511
512 #[uri_param(
513 name = "connectionClose",
514 default = "false",
515 desc = "Send Connection: close"
516 )]
517 connection_close: bool,
518
519 #[uri_param(
520 name = "skipRequestHeaders",
521 desc = "Comma-separated request headers to skip"
522 )]
523 skip_request_headers: Option<String>,
524
525 #[uri_param(
526 name = "skipResponseHeaders",
527 desc = "Comma-separated response headers to skip"
528 )]
529 skip_response_headers: Option<String>,
530
531 #[uri_param(
532 name = "followRedirects",
533 default = "false",
534 desc = "Follow HTTP redirects"
535 )]
536 follow_redirects: bool,
537
538 #[uri_param(name = "maxRedirects", default = "10", desc = "Max redirect hops")]
539 max_redirects: u64,
540
541 #[uri_param(
542 name = "allowedUriHosts",
543 desc = "Comma-separated allowlist of CamelHttpUri override hosts (host or host:port)"
544 )]
545 allowed_uri_hosts: Option<String>,
546}
547
548impl HttpEndpointConfig {
549 pub fn metadata() -> ComponentMetadata {
552 HttpEndpointUriConfig::metadata()
553 }
554
555 pub fn uri_options() -> Vec<camel_api::component_metadata::UriOption> {
557 HttpEndpointUriConfig::uri_options()
558 }
559}
560
561fn parse_auth_from_params(params: &HashMap<String, String>) -> Result<HttpAuth, CamelError> {
562 let Some(method) = params.get("authMethod") else {
563 return Ok(HttpAuth::None);
564 };
565
566 if method.eq_ignore_ascii_case("none") {
567 return Ok(HttpAuth::None);
568 }
569
570 if method.eq_ignore_ascii_case("basic") {
571 let username = params.get("authUsername").cloned().ok_or_else(|| {
572 CamelError::InvalidUri("authUsername is required for authMethod=Basic".to_string())
573 })?;
574 let password = params.get("authPassword").cloned().ok_or_else(|| {
575 CamelError::InvalidUri("authPassword is required for authMethod=Basic".to_string())
576 })?;
577 return Ok(HttpAuth::Basic { username, password });
578 }
579
580 if method.eq_ignore_ascii_case("bearer") {
581 let token = params.get("authBearerToken").cloned().ok_or_else(|| {
582 CamelError::InvalidUri("authBearerToken is required for authMethod=Bearer".to_string())
583 })?;
584 return Ok(HttpAuth::Bearer { token });
585 }
586
587 Err(CamelError::InvalidUri(format!(
588 "invalid value for authMethod: {method} (expected None, Basic, or Bearer)"
589 )))
590}
591
592fn parse_bool_param_http(value: &str) -> Result<bool, CamelError> {
593 match value.to_ascii_lowercase().as_str() {
594 "true" | "1" | "yes" => Ok(true),
595 "false" | "0" | "no" => Ok(false),
596 _ => Err(CamelError::InvalidUri(format!(
597 "invalid boolean value: '{value}'"
598 ))),
599 }
600}
601
602impl HttpEndpointConfig {
603 pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
604 let parts = parse_uri(uri)?;
605 let mut endpoint = Self::from_components(parts.clone())?;
606 if endpoint.response_timeout.is_none() {
607 endpoint.response_timeout = Some(Duration::from_millis(config.response_timeout_ms));
608 }
609 if !parts.params.contains_key("allowInternal") {
610 endpoint.allow_internal = config.allow_internal;
611 }
612 if !parts.params.contains_key("blockedHosts") {
613 endpoint.blocked_hosts = config.blocked_hosts.clone();
614 }
615 if !parts.params.contains_key("maxBodySize") {
616 endpoint.max_body_size = config.max_body_size;
617 }
618 if !parts.params.contains_key("readTimeout") {
619 endpoint.read_timeout_ms = config.read_timeout_ms;
620 }
621 if !parts.params.contains_key("maxResponseBytes") {
622 endpoint.max_response_bytes = config.max_response_bytes;
623 }
624 if !parts.params.contains_key("okStatusCodeRange")
625 && let Some(range) = &config.ok_status_code_range
626 {
627 endpoint.ok_status_code_range = parse_ok_status_code_range(range)?;
628 }
629 if !parts.params.contains_key("followRedirects") {
630 endpoint.follow_redirects = config.follow_redirects;
631 }
632 if !parts.params.contains_key("maxRedirects") {
633 endpoint.max_redirects = config.max_redirects.unwrap_or(10);
634 }
635
636 Ok(endpoint)
637 }
638}
639
640#[derive(Debug, Clone)]
646pub struct HttpServerConfig {
647 pub scheme: String,
649 pub host: String,
651 pub port: u16,
653 pub path: String,
655 pub max_request_body: usize,
657 pub max_response_body: usize,
659 pub max_inflight_requests: usize,
661 pub method: Option<String>,
668 pub tls_config: Option<crate::config::ServerTlsConfig>,
671}
672
673impl UriConfig for HttpServerConfig {
674 fn scheme() -> &'static str {
676 "http"
677 }
678
679 fn from_uri(uri: &str) -> Result<Self, CamelError> {
680 let parts = parse_uri(uri)?;
681 Self::from_components(parts)
682 }
683
684 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
685 if parts.scheme != "http" && parts.scheme != "https" {
687 return Err(CamelError::InvalidUri(format!(
688 "expected scheme 'http' or 'https', got '{}'",
689 parts.scheme
690 )));
691 }
692
693 let authority_and_path = parts.path.trim_start_matches('/');
696
697 let (authority, path_suffix) = if let Some(idx) = authority_and_path.find('/') {
699 (&authority_and_path[..idx], &authority_and_path[idx..])
700 } else {
701 (authority_and_path, "/")
702 };
703
704 let path = if path_suffix.is_empty() {
705 "/"
706 } else {
707 path_suffix
708 }
709 .to_string();
710
711 let (host, port) = if let Some(colon) = authority.rfind(':') {
713 let port_str = &authority[colon + 1..];
714 match port_str.parse::<u16>() {
715 Ok(p) => (authority[..colon].to_string(), p),
716 Err(_) => {
717 return Err(CamelError::InvalidUri(format!(
718 "invalid port '{}' in authority",
719 port_str
720 )));
721 }
722 }
723 } else {
724 let default_port = if parts.scheme == "https" { 443 } else { 80 };
726 (authority.to_string(), default_port)
727 };
728
729 let max_request_body = parts
730 .params
731 .get("maxRequestBody")
732 .and_then(|v| v.parse::<usize>().ok())
733 .unwrap_or(2 * 1024 * 1024); let max_response_body = parts
736 .params
737 .get("maxResponseBody")
738 .and_then(|v| v.parse::<usize>().ok())
739 .unwrap_or(10 * 1024 * 1024); let max_inflight_requests = parts
742 .params
743 .get("maxInflightRequests")
744 .and_then(|v| v.parse::<usize>().ok())
745 .unwrap_or(1024);
746
747 let method = parts.params.get("httpMethod").map(|m| m.to_uppercase());
753
754 Ok(Self {
755 scheme: parts.scheme,
756 host,
757 port,
758 path,
759 max_request_body,
760 max_response_body,
761 max_inflight_requests,
762 method,
763 tls_config: {
764 let cert = parts.params.get("tlsCert").cloned();
765 let key = parts.params.get("tlsKey").cloned();
766 match (cert, key) {
767 (Some(c), Some(k)) => Some(crate::config::ServerTlsConfig {
768 cert_path: c,
769 key_path: k,
770 }),
771 (None, None) => None,
772 _ => None, }
774 },
775 })
776 }
777}
778
779impl HttpServerConfig {
780 pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
781 let parts = parse_uri(uri)?;
782 let mut server = Self::from_components(parts.clone())?;
783 if !parts.params.contains_key("maxRequestBody") {
784 server.max_request_body = config.max_request_body;
785 }
786 if !parts.params.contains_key("maxResponseBody") {
787 server.max_response_body = config.max_body_size;
789 }
790 Ok(server)
791 }
792}
793
794pub enum HttpReplyBody {
802 Bytes(bytes::Bytes),
803 Stream(BoxStream<'static, Result<bytes::Bytes, CamelError>>),
804}
805
806pub struct RequestEnvelope {
811 pub method: String,
812 pub path: String,
813 pub query: String,
814 pub headers: http::HeaderMap,
815 pub body: StreamBody,
816 pub path_params: std::collections::HashMap<String, String>,
822 pub reply_tx: tokio::sync::oneshot::Sender<HttpReply>,
823}
824
825pub struct HttpReply {
829 pub status: u16,
830 pub headers: Vec<(String, String)>,
831 pub body: HttpReplyBody,
832}
833
834type ServerKey = (String, u16);
839
840struct ServerHandle {
842 registry: HttpRouteRegistry,
843 bound_addr: std::net::SocketAddr,
846 max_request_body: usize,
847 max_response_body: usize,
848 max_inflight_requests: usize,
849 is_tls: bool,
850 tls_cert_path: Option<String>,
851 tls_key_path: Option<String>,
852 monitor_task: tokio::task::JoinHandle<()>,
855 tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
858 tls_source: Option<ServerTlsSource>,
859}
860
861#[derive(Default)]
864struct RegistryState {
865 entries: HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>,
866 staged: HashMap<ServerKey, tokio::net::TcpListener>,
867}
868
869pub struct ServerRegistry {
871 inner: Mutex<RegistryState>,
872}
873
874impl ServerRegistry {
875 pub fn global() -> &'static Self {
877 static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
878 INSTANCE.get_or_init(|| ServerRegistry {
879 inner: Mutex::new(RegistryState::default()),
880 })
881 }
882
883 #[allow(clippy::too_many_arguments)]
886 pub async fn get_or_spawn(
887 &'static self,
888 host: &str,
889 port: u16,
890 max_request_body: usize,
891 max_response_body: usize,
892 max_inflight_requests: usize,
893 runtime: Arc<dyn RuntimeObservability>,
894 route_id: String,
895 tls_config: Option<crate::config::ServerTlsConfig>,
896 ) -> Result<HttpRouteRegistry, CamelError> {
897 self.get_or_spawn_internal(
898 host,
899 port,
900 max_request_body,
901 max_response_body,
902 max_inflight_requests,
903 runtime,
904 route_id,
905 tls_config,
906 None,
907 )
908 .await
909 }
910
911 #[allow(clippy::too_many_arguments)]
918 pub async fn get_or_spawn_with_listener(
919 &'static self,
920 listener: tokio::net::TcpListener,
921 max_request_body: usize,
922 max_response_body: usize,
923 max_inflight_requests: usize,
924 runtime: Arc<dyn RuntimeObservability>,
925 route_id: String,
926 tls_config: Option<crate::config::ServerTlsConfig>,
927 ) -> Result<HttpRouteRegistry, CamelError> {
928 let addr = listener
929 .local_addr()
930 .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
931 self.get_or_spawn_internal(
932 &addr.ip().to_string(),
933 addr.port(),
934 max_request_body,
935 max_response_body,
936 max_inflight_requests,
937 runtime,
938 route_id,
939 tls_config,
940 Some(listener),
941 )
942 .await
943 }
944
945 pub async fn stage_listener(
952 &'static self,
953 listener: tokio::net::TcpListener,
954 ) -> Result<(), CamelError> {
955 let addr = listener
956 .local_addr()
957 .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
958 let host = addr.ip().to_string();
959 use std::collections::hash_map::Entry;
960 let mut guard = self.inner.lock().map_err(|_| {
961 CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
962 })?;
963 match guard.staged.entry((host.clone(), addr.port())) {
964 Entry::Occupied(_) => Err(CamelError::EndpointCreationFailed(format!(
965 "listener already staged for {host}:{}",
966 addr.port()
967 ))),
968 Entry::Vacant(slot) => {
969 slot.insert(listener);
970 Ok(())
971 }
972 }
973 }
974
975 pub fn bound_addr(&'static self, host: &str, port: u16) -> Option<std::net::SocketAddr> {
978 let guard = self.inner.lock().ok()?;
979 guard
980 .entries
981 .get(&(host.to_string(), port))
982 .and_then(|cell| cell.get())
983 .map(|handle| handle.bound_addr)
984 }
985
986 #[allow(clippy::too_many_arguments)]
987 async fn get_or_spawn_internal(
988 &'static self,
989 host: &str,
990 port: u16,
991 max_request_body: usize,
992 max_response_body: usize,
993 max_inflight_requests: usize,
994 runtime: Arc<dyn RuntimeObservability>,
995 route_id: String,
996 tls_config: Option<crate::config::ServerTlsConfig>,
997 provided: Option<tokio::net::TcpListener>,
998 ) -> Result<HttpRouteRegistry, CamelError> {
999 let host_owned = host.to_string();
1000 let key = (host.to_string(), port);
1001
1002 let cell = {
1003 let mut guard = self.inner.lock().map_err(|_| {
1004 CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
1005 })?;
1006 if let Some(existing) = guard.entries.get(&key)
1010 && let Some(handle) = existing.get()
1011 && handle.monitor_task.is_finished()
1012 {
1013 if handle.is_tls {
1016 let scheme = if handle.is_tls { "https" } else { "http" };
1017 camel_component_api::tls_source::TlsReloadRegistry::global()
1018 .unregister(scheme, host, port);
1019 }
1020 guard.entries.remove(&key);
1021 }
1022 guard
1023 .entries
1024 .entry(key)
1025 .or_insert_with(|| Arc::new(OnceCell::new()))
1026 .clone()
1027 };
1028
1029 if let Some(existing) = cell.get()
1030 && existing.max_request_body != max_request_body
1031 {
1032 return Err(CamelError::EndpointCreationFailed(format!(
1033 "incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
1034 existing.max_request_body, max_request_body
1035 )));
1036 }
1037
1038 if let Some(existing) = cell.get()
1039 && existing.max_response_body != max_response_body
1040 {
1041 return Err(CamelError::EndpointCreationFailed(format!(
1042 "incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
1043 existing.max_response_body, max_response_body
1044 )));
1045 }
1046
1047 if let Some(existing) = cell.get()
1048 && existing.max_inflight_requests != max_inflight_requests
1049 {
1050 return Err(CamelError::EndpointCreationFailed(format!(
1051 "incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
1052 existing.max_inflight_requests, max_inflight_requests
1053 )));
1054 }
1055
1056 if let Some(existing) = cell.get()
1058 && existing.is_tls != tls_config.is_some()
1059 {
1060 return Err(CamelError::EndpointCreationFailed(format!(
1061 "incompatible TLS mode for shared server (host={host}, port={port}): existing is_tls={}, new has_tls={}",
1062 existing.is_tls,
1063 tls_config.is_some()
1064 )));
1065 }
1066
1067 if let (Some(existing), Some(new_tls)) = (cell.get(), &tls_config)
1069 && (existing.tls_cert_path.as_deref() != Some(&new_tls.cert_path)
1070 || existing.tls_key_path.as_deref() != Some(&new_tls.key_path))
1071 {
1072 return Err(CamelError::EndpointCreationFailed(format!(
1073 "incompatible TLS cert/key for shared server (host={host}, port={port}): routes on the same TLS port must use the same cert and key"
1074 )));
1075 }
1076
1077 let handle = cell
1078 .get_or_try_init(|| {
1079 let rt = Arc::clone(&runtime);
1080 let rid = route_id.clone();
1081 let key = (host_owned.clone(), port);
1082 async move {
1083 let source = match provided {
1092 Some(listener) => ListenerSource::Staged(listener),
1093 None => {
1094 let mut guard = self.inner.lock().map_err(|_| {
1095 CamelError::EndpointCreationFailed(
1096 "ServerRegistry lock poisoned".into(),
1097 )
1098 })?;
1099 match guard.staged.remove(&key) {
1100 Some(listener) => ListenerSource::Staged(listener),
1101 None => {
1105 if let Some((staged_host, _)) = guard
1106 .staged
1107 .keys()
1108 .find(|(_, staged_port)| *staged_port == port)
1109 {
1110 let staged_host = staged_host.clone();
1111 return Err(CamelError::EndpointCreationFailed(
1112 format!(
1113 "staged listener conflict on port {port}: staged under host {staged_host}, requested {host_owned}"
1114 ),
1115 ));
1116 }
1117 ListenerSource::Bind
1118 }
1119 }
1120 }
1121 };
1122 spawn_entry(
1123 key,
1124 source,
1125 max_request_body,
1126 max_response_body,
1127 max_inflight_requests,
1128 rt,
1129 rid,
1130 tls_config,
1131 )
1132 .await
1133 .and_then(|handle| {
1134 Arc::try_unwrap(handle).map_err(|_| {
1138 CamelError::EndpointCreationFailed(
1139 "spawned server handle has dangling clones".into(),
1140 )
1141 })
1142 })
1143 }
1144 })
1145 .await?;
1146
1147 Ok(handle.registry.clone())
1148 }
1149
1150 pub async fn unregister(&self, host: &str, port: u16) {
1154 debug!(
1155 host = host,
1156 port = port,
1157 "consumer unregistered from HTTP server"
1158 );
1159 }
1160
1161 #[cfg(test)]
1168 pub fn reset() {
1169 let instance = Self::global();
1170 let mut guard = instance
1171 .inner
1172 .lock()
1173 .expect("ServerRegistry lock poisoned during test reset");
1174 guard.entries.clear();
1175 guard.staged.clear();
1176 }
1177}
1178
1179enum ListenerSource {
1182 Bind,
1183 Staged(tokio::net::TcpListener),
1184}
1185
1186#[allow(clippy::too_many_arguments)]
1191async fn spawn_entry(
1192 key: ServerKey,
1193 source: ListenerSource,
1194 max_request_body: usize,
1195 max_response_body: usize,
1196 max_inflight_requests: usize,
1197 runtime: Arc<dyn RuntimeObservability>,
1198 route_id: String,
1199 tls_config: Option<crate::config::ServerTlsConfig>,
1200) -> Result<Arc<ServerHandle>, CamelError> {
1201 let rt = Arc::clone(&runtime);
1202 let rid = route_id.clone();
1203 let (host_owned, port) = key;
1204 let listener = match source {
1205 ListenerSource::Bind => {
1206 let addr = format!("{host_owned}:{port}");
1207 tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
1208 CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
1209 })?
1210 }
1211 ListenerSource::Staged(listener) => listener,
1212 };
1213 let bound_addr = listener
1214 .local_addr()
1215 .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
1216 let registry = HttpRouteRegistry::new();
1217 let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
1218 let tls_rustls_cfg: Option<axum_server::tls_rustls::RustlsConfig>;
1221 let tls_source: Option<ServerTlsSource>;
1222 let server_task = if let Some(ref tls) = tls_config {
1223 let rustls_config = load_tls_config(&tls.cert_path, &tls.key_path)?;
1224 let source = ServerTlsSource {
1225 cert_path: std::path::PathBuf::from(&tls.cert_path),
1226 key_path: std::path::PathBuf::from(&tls.key_path),
1227 client_ca_path: None,
1228 };
1229 let rustls_cfg =
1233 axum_server::tls_rustls::RustlsConfig::from_config(std::sync::Arc::new(rustls_config));
1234 tls_rustls_cfg = Some(rustls_cfg.clone());
1235 tls_source = Some(source);
1236 let std_listener = listener.into_std().map_err(|e| {
1238 CamelError::EndpointCreationFailed(format!("TLS listener conversion: {e}"))
1239 })?;
1240 tokio::spawn(run_axum_server_tls(
1241 std_listener,
1242 rustls_cfg,
1243 registry.clone(),
1244 max_request_body,
1245 max_response_body,
1246 Arc::clone(&inflight),
1247 Arc::clone(&rt),
1248 rid.clone(),
1249 ))
1250 } else {
1251 tls_rustls_cfg = None;
1252 tls_source = None;
1253 tokio::spawn(run_axum_server(
1254 listener,
1255 registry.clone(),
1256 max_request_body,
1257 max_response_body,
1258 Arc::clone(&inflight),
1259 Arc::clone(&rt),
1260 rid.clone(),
1261 ))
1262 };
1263 let addr_for_monitor = format!("{host_owned}:{port}");
1264 let monitor_task = tokio::spawn(monitor_axum_task(
1265 server_task,
1266 addr_for_monitor,
1267 Arc::clone(&rt),
1268 rid,
1269 ));
1270 let handle = ServerHandle {
1271 registry,
1272 bound_addr,
1273 max_request_body,
1274 max_response_body,
1275 max_inflight_requests,
1276 is_tls: tls_config.is_some(),
1277 tls_cert_path: tls_config.as_ref().map(|t| t.cert_path.clone()),
1278 tls_key_path: tls_config.as_ref().map(|t| t.key_path.clone()),
1279 monitor_task,
1280 tls_config: tls_rustls_cfg,
1281 tls_source,
1282 };
1283 if let (Some(tls_cfg), Some(source)) = (handle.tls_config.as_ref(), handle.tls_source.as_ref())
1288 {
1289 let handler = Arc::new(crate::tls_reload::HttpReloadHandler::new(
1290 tls_cfg.clone(),
1291 source.clone(),
1292 host_owned.clone(),
1293 port,
1294 ));
1295 camel_component_api::tls_source::TlsReloadRegistry::global().register(handler);
1296 }
1297 Ok(Arc::new(handle))
1298}
1299
1300use axum::{
1305 Router,
1306 body::Body as AxumBody,
1307 extract::{Request, State},
1308 http::{Response, StatusCode},
1309 response::IntoResponse,
1310};
1311
1312#[derive(Clone)]
1313pub(crate) struct AppState {
1314 registry: HttpRouteRegistry,
1315 max_request_body: usize,
1316 max_response_body: usize,
1317 inflight: Arc<tokio::sync::Semaphore>,
1318}
1319
1320const CONSUMER_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
1327
1328async fn run_axum_server(
1329 listener: tokio::net::TcpListener,
1330 registry: HttpRouteRegistry,
1331 max_request_body: usize,
1332 max_response_body: usize,
1333 inflight: Arc<tokio::sync::Semaphore>,
1334 runtime: Arc<dyn RuntimeObservability>,
1335 route_id: String,
1336) {
1337 let state = AppState {
1338 registry,
1339 max_request_body,
1340 max_response_body,
1341 inflight,
1342 };
1343 let app = Router::new()
1344 .fallback(dispatch_handler)
1345 .with_state(state)
1346 .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1347 StatusCode::REQUEST_TIMEOUT,
1348 CONSUMER_REQUEST_TIMEOUT,
1349 ));
1350
1351 axum::serve(listener, app).await.unwrap_or_else(|e| {
1352 runtime
1353 .metrics()
1354 .increment_errors(&route_id, "e:http:accept");
1355 tracing::error!(error = %e, "Axum server error");
1357 });
1358}
1359
1360#[allow(clippy::too_many_arguments)]
1361async fn run_axum_server_tls(
1362 listener: std::net::TcpListener,
1363 tls_cfg: axum_server::tls_rustls::RustlsConfig,
1364 registry: HttpRouteRegistry,
1365 max_request_body: usize,
1366 max_response_body: usize,
1367 inflight: Arc<tokio::sync::Semaphore>,
1368 runtime: Arc<dyn RuntimeObservability>,
1369 route_id: String,
1370) {
1371 let state = AppState {
1372 registry,
1373 max_request_body,
1374 max_response_body,
1375 inflight,
1376 };
1377 let app = Router::new()
1378 .fallback(dispatch_handler)
1379 .with_state(state)
1380 .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1381 StatusCode::REQUEST_TIMEOUT,
1382 CONSUMER_REQUEST_TIMEOUT,
1383 ));
1384
1385 let server = match axum_server::from_tcp_rustls(listener, tls_cfg) {
1390 Ok(server) => server,
1391 Err(e) => {
1392 runtime
1393 .metrics()
1394 .increment_errors(&route_id, "e:http:accept-tls");
1395 tracing::error!(error = %e, "Axum TLS server setup error");
1397 return;
1398 }
1399 };
1400
1401 server
1402 .serve(app.into_make_service())
1403 .await
1404 .unwrap_or_else(|e| {
1405 runtime
1406 .metrics()
1407 .increment_errors(&route_id, "e:http:accept-tls");
1408 tracing::error!(error = %e, "Axum TLS server error");
1410 });
1411}
1412
1413async fn monitor_axum_task(
1421 handle: tokio::task::JoinHandle<()>,
1422 addr: String,
1423 runtime: Arc<dyn RuntimeObservability>,
1424 route_id: String,
1425) {
1426 match handle.await {
1427 Ok(()) => {
1428 }
1430 Err(join_err) => {
1431 runtime
1432 .metrics()
1433 .increment_errors(&route_id, "e:http:server-task-exited");
1434 tracing::error!(
1436 addr = %addr,
1437 error = %join_err,
1438 "Axum server task exited unexpectedly — all routes on this port are now dead"
1439 );
1440 }
1441 }
1442}
1443
1444fn load_tls_config(
1447 cert_path: &str,
1448 key_path: &str,
1449) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
1450 use std::fs::File;
1451 use std::io::BufReader;
1452
1453 let cert_file = File::open(cert_path)
1454 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
1455 let key_file = File::open(key_path)
1456 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
1457
1458 let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
1459 .collect::<Result<Vec<_>, _>>()
1460 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
1461
1462 let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
1463 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
1464 .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
1465
1466 tokio_rustls::rustls::ServerConfig::builder()
1467 .with_no_client_auth()
1468 .with_single_cert(certs, key)
1469 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
1470}
1471
1472async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
1473 let path = req.uri().path().to_owned();
1474 let method = req.method().to_string();
1475
1476 let api_sender = {
1493 let inner = state.registry.inner.read().await;
1494 inner.api_routes.get(&path).cloned()
1495 }; let (rest_sender, path_params) = if api_sender.is_some() {
1498 (None, Default::default())
1500 } else {
1501 let inner = state.registry.inner.read().await;
1502 match rest_match::match_endpoint(&method, &path, &inner.rest_endpoints) {
1503 rest_match::MatchOutcome::Found(m) => (Some(m.payload), m.path_params),
1504 rest_match::MatchOutcome::Ambiguous => {
1505 tracing::warn!(
1511 method = %method,
1512 path = %path,
1513 "ambiguous REST template match — returning 500"
1514 );
1515 return Response::builder()
1516 .status(StatusCode::INTERNAL_SERVER_ERROR)
1517 .body(AxumBody::from("Internal Server Error"))
1518 .expect("infallible"); }
1520 rest_match::MatchOutcome::NotFound => (None, Default::default()),
1521 }
1522 }; let sender = api_sender.or(rest_sender);
1525
1526 if let Some(sender) = sender {
1527 let query = req.uri().query().unwrap_or("").to_string();
1528 let headers = req.headers().clone();
1529
1530 let content_length: Option<u64> = headers
1532 .get(http::header::CONTENT_LENGTH)
1533 .and_then(|v| v.to_str().ok())
1534 .and_then(|s| s.parse().ok());
1535
1536 if let Some(len) = content_length
1537 && len > state.max_request_body as u64
1538 {
1539 return Response::builder()
1540 .status(StatusCode::PAYLOAD_TOO_LARGE)
1541 .body(AxumBody::from("Request body exceeds configured limit"))
1542 .expect("infallible"); }
1544
1545 let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
1546 Ok(permit) => permit,
1547 Err(_) => {
1548 return Response::builder()
1549 .status(StatusCode::SERVICE_UNAVAILABLE)
1550 .body(AxumBody::from("Service Unavailable"))
1551 .expect("infallible"); }
1553 };
1554
1555 let content_type = headers
1561 .get(http::header::CONTENT_TYPE)
1562 .and_then(|v| v.to_str().ok())
1563 .map(|s| s.to_string());
1564
1565 let data_stream: BodyDataStream = req.into_body().into_data_stream();
1566 let max_body = state.max_request_body;
1567 let mut seen: u64 = 0;
1568 let capped_stream =
1569 data_stream
1570 .map_err(|e| CamelError::Io(e.to_string()))
1571 .map(move |chunk| match chunk {
1572 Ok(bytes) => {
1573 seen = seen.saturating_add(bytes.len() as u64);
1574 if seen > max_body as u64 {
1575 Err(CamelError::ProcessorError(format!(
1576 "Request body exceeds configured limit of {max_body} bytes"
1577 )))
1578 } else {
1579 Ok(bytes)
1580 }
1581 }
1582 Err(e) => Err(e),
1583 });
1584 let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(capped_stream);
1585
1586 let stream_body = StreamBody {
1587 stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
1588 metadata: StreamMetadata {
1589 size_hint: content_length,
1590 content_type,
1591 origin: None,
1592 },
1593 };
1594
1595 let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
1596 let envelope = RequestEnvelope {
1597 method,
1598 path,
1599 query,
1600 headers,
1601 body: stream_body,
1602 path_params,
1603 reply_tx,
1604 };
1605
1606 if sender.send(envelope).await.is_err() {
1607 return Response::builder()
1608 .status(StatusCode::SERVICE_UNAVAILABLE)
1609 .body(AxumBody::from("Consumer unavailable"))
1610 .expect("infallible"); }
1612
1613 match reply_rx.await {
1614 Ok(reply) => {
1615 let reply = match reply.body {
1616 HttpReplyBody::Bytes(b)
1617 if exceeds_max_response_body(b.len(), state.max_response_body) =>
1618 {
1619 HttpReply {
1620 status: 500,
1621 headers: vec![],
1622 body: HttpReplyBody::Bytes(bytes::Bytes::from(
1623 "Response body exceeds configured limit",
1624 )),
1625 }
1626 }
1627 _ => reply,
1628 };
1629
1630 let status =
1631 StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1632 let mut builder = Response::builder().status(status);
1633 for (k, v) in &reply.headers {
1634 builder = builder.header(k.as_str(), v.as_str());
1635 }
1636 match reply.body {
1637 HttpReplyBody::Bytes(b) => {
1638 builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
1639 Response::builder()
1640 .status(StatusCode::INTERNAL_SERVER_ERROR)
1641 .body(AxumBody::from("Invalid response headers from consumer"))
1642 .expect("infallible") })
1644 }
1645 HttpReplyBody::Stream(stream) => builder
1646 .body(AxumBody::from_stream(stream))
1647 .unwrap_or_else(|_| {
1648 Response::builder()
1649 .status(StatusCode::INTERNAL_SERVER_ERROR)
1650 .body(AxumBody::from("Invalid response headers from consumer"))
1651 .expect("infallible") }),
1653 }
1654 }
1655 Err(_) => Response::builder()
1656 .status(StatusCode::INTERNAL_SERVER_ERROR)
1657 .body(AxumBody::from("Pipeline error"))
1658 .expect("infallible"), }
1660 } else {
1661 static_dispatch::dispatch_static(&state, req, &path).await
1663 }
1664}
1665
1666fn exceeds_max_response_body(len: usize, max: usize) -> bool {
1667 len > max
1668}
1669
1670fn title_case_header(name: &str) -> String {
1671 name.split('-')
1672 .map(|part| {
1673 let mut chars = part.chars();
1674 match chars.next() {
1675 None => String::new(),
1676 Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
1677 }
1678 })
1679 .collect::<Vec<_>>()
1680 .join("-")
1681}
1682
1683pub(crate) struct HttpKernelAuth {
1698 pub(crate) plan: camel_api::security_policy::RouteSecurityPlan,
1699 pub(crate) providers: Arc<camel_auth::ProviderRegistry>,
1700}
1701
1702impl HttpKernelAuth {
1703 pub(crate) fn from_security_context(
1708 ctx: &camel_component_api::SecurityContext,
1709 ) -> Option<Self> {
1710 Some(Self {
1711 plan: ctx.plan.clone()?,
1712 providers: ctx.providers.clone()?,
1713 })
1714 }
1715}
1716
1717fn envelope_channel_capacity(max_inflight_requests: usize) -> usize {
1731 max_inflight_requests.max(1)
1732}
1733
1734pub struct HttpConsumer {
1735 config: HttpServerConfig,
1736 runtime: Arc<dyn RuntimeObservability>,
1738 kernel: Option<Arc<HttpKernelAuth>>,
1742}
1743
1744impl HttpConsumer {
1745 pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
1746 Self {
1747 config,
1748 runtime,
1749 kernel: None,
1750 }
1751 }
1752}
1753
1754#[async_trait::async_trait]
1755impl Consumer for HttpConsumer {
1756 async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
1757 use camel_component_api::{Body, Exchange, Message};
1758
1759 let registry = ServerRegistry::global()
1760 .get_or_spawn(
1761 &self.config.host,
1762 self.config.port,
1763 self.config.max_request_body,
1764 self.config.max_response_body,
1765 self.config.max_inflight_requests,
1766 self.runtime.clone(),
1767 ctx.route_id().to_string(),
1768 self.config.tls_config.clone(),
1769 )
1770 .await?;
1771
1772 let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(
1776 envelope_channel_capacity(self.config.max_inflight_requests),
1777 );
1778 if let Some(method) = self.config.method.clone() {
1785 let segments = rest_match::parse_path_template(&self.config.path);
1786 registry
1787 .register_rest_endpoint(method, segments, env_tx)
1788 .await;
1789 } else {
1790 registry
1791 .register_api_route(self.config.path.clone(), env_tx)
1792 .await;
1793 }
1794
1795 ctx.mark_ready();
1804
1805 let path = self.config.path.clone();
1806 let registry_for_cleanup = registry.clone();
1807 let cancel_token = ctx.cancel_token();
1808 let kernel = self.kernel.clone();
1809 loop {
1810 tokio::select! {
1811 _ = ctx.cancelled() => {
1812 break;
1813 }
1814 envelope = env_rx.recv() => {
1815 let Some(envelope) = envelope else { break; };
1816
1817 let mut msg = Message::default();
1819
1820 msg.set_header("CamelHttpMethod",
1822 serde_json::Value::String(envelope.method.clone()));
1823 msg.set_header("CamelHttpPath",
1824 serde_json::Value::String(envelope.path.clone()));
1825 msg.set_header("CamelHttpQuery",
1826 serde_json::Value::String(envelope.query.clone()));
1827
1828 for (param_name, param_value) in &envelope.path_params {
1835 msg.set_header(
1836 format!("CamelHttpPath_{param_name}"),
1837 serde_json::Value::String(param_value.clone()),
1838 );
1839 }
1840
1841 for (k, v) in &envelope.headers {
1843 if let Ok(val_str) = v.to_str() {
1844 msg.set_header(
1845 title_case_header(k.as_str()),
1846 serde_json::Value::String(val_str.to_string()),
1847 );
1848 }
1849 }
1850
1851 msg.body = Body::Stream(envelope.body);
1854
1855 #[allow(unused_mut)]
1856 let mut exchange = Exchange::new(msg);
1857
1858 #[cfg(feature = "otel")]
1860 {
1861 let headers: HashMap<String, String> = envelope
1862 .headers
1863 .iter()
1864 .filter_map(|(k, v)| {
1865 Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1866 })
1867 .collect();
1868 camel_otel::extract_into_exchange(&mut exchange, &headers);
1869 }
1870
1871 let reply_tx = envelope.reply_tx;
1872 let sender = ctx.sender().clone();
1873 let path_clone = path.clone();
1874 let cancel = cancel_token.clone();
1875 let auth_headers = envelope.headers.clone();
1879 let auth_uri: http::Uri = {
1880 let full = if envelope.query.is_empty() {
1881 envelope.path.clone()
1882 } else {
1883 format!("{}?{}", envelope.path, envelope.query)
1884 };
1885 full.parse().unwrap_or_default()
1889 };
1890 let kernel = kernel.clone();
1891
1892 tokio::spawn(async move {
1912 if cancel.is_cancelled() {
1920 let _ = reply_tx.send(HttpReply {
1921 status: 503,
1922 headers: vec![],
1923 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
1924 });
1925 return;
1926 }
1927
1928 if let Some(kernel) = kernel.as_ref()
1937 && !matches!(
1938 kernel.plan.access_mode,
1939 camel_api::security_policy::AccessMode::Public
1940 )
1941 {
1942 let principal = match camel_auth::extract_token_multi(
1943 &auth_headers,
1944 &auth_uri,
1945 &kernel.plan.credential_sources,
1946 ) {
1947 Some(extracted) => {
1948 match camel_auth::kernel_authenticate(
1949 &kernel.plan,
1950 &kernel.providers,
1951 &extracted,
1952 )
1953 .await
1954 {
1955 Ok(principal) => principal,
1956 Err(e) => {
1957 tracing::warn!(
1959 path = %path_clone,
1960 error = %e,
1961 "HTTP request authentication failed"
1962 );
1963 let _ = reply_tx.send(pipeline_error_to_reply(
1964 e,
1965 &path_clone,
1966 ));
1967 return;
1968 }
1969 }
1970 }
1971 None => {
1972 tracing::warn!(
1974 path = %path_clone,
1975 "HTTP request rejected: no credential found in any source"
1976 );
1977 let _ = reply_tx.send(pipeline_error_to_reply(
1978 CamelError::Unauthenticated(
1979 "no credential found in any source".to_string(),
1980 ),
1981 &path_clone,
1982 ));
1983 return;
1984 }
1985 };
1986 camel_auth::install_carrier(&mut exchange, &principal);
1987 }
1988
1989 let (tx, rx) = tokio::sync::oneshot::channel();
1991 let envelope = camel_component_api::consumer::ExchangeEnvelope {
1992 exchange,
1993 reply_tx: Some(tx),
1994 };
1995
1996 let result = match sender.send(envelope).await {
1997 Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
1998 Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
1999 }
2000 .and_then(|r| r);
2001
2002 let reply = match result {
2003 Ok(out) => {
2004 let status = out
2005 .input
2006 .header("CamelHttpResponseCode")
2007 .and_then(|v| {
2008 let raw = v.as_u64()
2009 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
2010 let code = raw as u16;
2011 (100..1000).contains(&code).then_some(code)
2012 })
2013 .unwrap_or(200);
2014
2015 let user_content_type = out
2016 .input
2017 .header("Content-Type")
2018 .and_then(|v| v.as_str().map(|s| s.to_string()));
2019
2020 let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
2021 Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
2022 Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
2023 Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
2024 Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
2025 v.to_string().into_bytes(),
2026 )), Some("application/json".to_string())),
2027 Body::Stream(s) => {
2028 let ct = s.metadata.content_type.clone();
2029 match s.stream.lock().await.take() {
2030 Some(stream) => (
2031 HttpReplyBody::Stream(stream),
2032 ct,
2033 ),
2034 None => {
2035 tracing::error!(
2037 "Body::Stream already consumed before HTTP reply — returning 500"
2038 );
2039 let error_reply = HttpReply {
2040 status: 500,
2041 headers: vec![],
2042 body: HttpReplyBody::Bytes(bytes::Bytes::new()),
2043 };
2044 if reply_tx.send(error_reply).is_err() {
2045 debug!("reply_tx dropped before error reply could be sent");
2046 }
2047 return;
2048 }
2049 }
2050 }
2051 _ => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
2053 };
2054
2055 let resp_headers = select_response_headers(
2056 &out.input.headers,
2057 user_content_type,
2058 inferred_content_type,
2059 );
2060
2061 HttpReply {
2062 status,
2063 headers: resp_headers,
2064 body: reply_body,
2065 }
2066 }
2067 Err(e) => {
2068 pipeline_error_to_reply(e, &path_clone)
2069 }
2070 };
2071
2072 let _ = reply_tx.send(reply);
2074 });
2075 }
2076 }
2077 }
2078
2079 if let Some(method) = &self.config.method {
2084 registry_for_cleanup
2085 .unregister_rest_endpoint(method, &path)
2086 .await;
2087 } else {
2088 registry_for_cleanup.unregister_api_route(&path).await;
2089 }
2090
2091 ServerRegistry::global()
2095 .unregister(&self.config.host, self.config.port)
2096 .await;
2097
2098 Ok(())
2099 }
2100
2101 async fn stop(&mut self) -> Result<(), CamelError> {
2102 Ok(())
2103 }
2104
2105 fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
2106 camel_component_api::ConcurrencyModel::Concurrent { max: None }
2107 }
2108
2109 fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
2116 camel_component_api::ConsumerStartupMode::Explicit
2117 }
2118
2119 fn set_security_context(&mut self, ctx: camel_component_api::SecurityContext) {
2122 self.kernel = HttpKernelAuth::from_security_context(&ctx).map(Arc::new);
2123 }
2124}
2125
2126pub struct HttpComponent {
2131 config: HttpConfig,
2132 pinned_cache: std::sync::Arc<PinnedClientCache>,
2133 client: reqwest::Client,
2134}
2135
2136#[cfg(test)]
2137thread_local! {
2138 static BUILD_CLIENT_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
2139}
2140
2141pub(crate) fn build_client(
2142 config: &HttpConfig,
2143 resolve_override: Option<(&str, &[std::net::SocketAddr])>,
2144) -> reqwest::Client {
2145 #[cfg(test)]
2146 BUILD_CLIENT_CALLS.with(|c| c.set(c.get() + 1));
2147
2148 let mut builder = reqwest::Client::builder()
2149 .no_proxy() .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
2151 .pool_max_idle_per_host(config.pool_max_idle_per_host)
2152 .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
2153
2154 builder = builder.redirect(reqwest::redirect::Policy::none());
2158
2159 if let Some((host, addrs)) = resolve_override {
2160 builder = builder.resolve_to_addrs(host, addrs);
2161 }
2162
2163 if let Some(tls) = &config.tls
2164 && tls.enabled
2165 {
2166 if tls.insecure || !tls.verify_peer {
2167 tracing::warn!("HTTP TLS verification disabled — connections are vulnerable to MitM");
2169 builder = builder.danger_accept_invalid_certs(true);
2170 }
2171
2172 if let Some(ca_path) = &tls.ca_cert_path {
2173 match std::fs::read(ca_path) {
2178 Ok(ca_bytes) => {
2179 match reqwest::Certificate::from_pem(&ca_bytes)
2180 .or_else(|_| reqwest::Certificate::from_der(&ca_bytes))
2181 {
2182 Ok(ca_cert) => {
2183 builder = builder.add_root_certificate(ca_cert);
2184 }
2185 Err(e) => {
2186 tracing::warn!(
2188 error = %e,
2189 "configured CA certificate failed to parse — falling back to system roots"
2190 );
2191 }
2192 }
2193 }
2194 Err(e) => {
2195 tracing::warn!(
2197 error = %e,
2198 "configured CA certificate file unreadable — falling back to system roots"
2199 );
2200 }
2201 }
2202 }
2203
2204 if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path) {
2208 match (std::fs::read(cert_path), std::fs::read(key_path)) {
2209 (Ok(cert_bytes), Ok(key_bytes)) => {
2210 let mut identity_pem = cert_bytes;
2211 identity_pem.extend_from_slice(&key_bytes);
2212 match reqwest::Identity::from_pem(&identity_pem) {
2213 Ok(identity) => {
2214 builder = builder.identity(identity);
2215 }
2216 Err(e) => {
2217 tracing::warn!(
2219 error = %e,
2220 "configured mTLS identity failed to parse — client certificate NOT used"
2221 );
2222 }
2223 }
2224 }
2225 (cert_r, key_r) => {
2226 tracing::warn!(
2228 cert_ok = cert_r.is_ok(),
2229 key_ok = key_r.is_ok(),
2230 "configured mTLS cert/key file unreadable — client certificate NOT used"
2231 );
2232 }
2233 }
2234 }
2235 }
2236
2237 builder
2238 .build()
2239 .expect("reqwest::Client::build() with valid config should not fail") }
2241
2242#[cfg(test)]
2243pub(crate) fn build_client_call_count() -> u64 {
2244 BUILD_CLIENT_CALLS.with(|c| c.get())
2245}
2246
2247impl HttpComponent {
2248 pub fn new() -> Self {
2249 let config = HttpConfig::default();
2250 Self {
2251 client: build_client(&config, None),
2252 config,
2253 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2254 PINNED_CLIENT_TTL,
2255 PINNED_CLIENT_MAX_ENTRIES,
2256 )),
2257 }
2258 }
2259
2260 pub fn with_config(config: HttpConfig) -> Self {
2261 Self {
2262 client: build_client(&config, None),
2263 config,
2264 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2265 PINNED_CLIENT_TTL,
2266 PINNED_CLIENT_MAX_ENTRIES,
2267 )),
2268 }
2269 }
2270
2271 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2272 match config {
2273 Some(cfg) => Self::with_config(cfg),
2274 None => Self::new(),
2275 }
2276 }
2277}
2278
2279impl Default for HttpComponent {
2280 fn default() -> Self {
2281 Self::new()
2282 }
2283}
2284
2285impl Component for HttpComponent {
2286 fn scheme(&self) -> &str {
2287 "http"
2288 }
2289
2290 fn metadata(&self) -> ComponentMetadata {
2291 HttpEndpointConfig::metadata()
2292 }
2293
2294 fn create_endpoint(
2295 &self,
2296 uri: &str,
2297 ctx: &dyn camel_component_api::ComponentContext,
2298 ) -> Result<Box<dyn Endpoint>, CamelError> {
2299 self.config.validate()?;
2300 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2301 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2302 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2303 server_config.host.clone(),
2304 server_config.port,
2305 )));
2306 self.pinned_cache
2307 .wire(HttpComponentKind::Http, ctx.metrics());
2308 Ok(Box::new(HttpEndpoint {
2309 uri: uri.to_string(),
2310 config,
2311 server_config,
2312 client: self.client.clone(),
2313 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2314 http_config: self.config.clone(),
2315 }))
2316 }
2317}
2318
2319pub struct HttpsComponent {
2320 config: HttpConfig,
2321 pinned_cache: std::sync::Arc<PinnedClientCache>,
2322 client: reqwest::Client,
2323}
2324
2325impl HttpsComponent {
2326 pub fn new() -> Self {
2327 let config = HttpConfig::default();
2328 Self {
2329 client: build_client(&config, None),
2330 config,
2331 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2332 PINNED_CLIENT_TTL,
2333 PINNED_CLIENT_MAX_ENTRIES,
2334 )),
2335 }
2336 }
2337
2338 pub fn with_config(config: HttpConfig) -> Self {
2339 Self {
2340 client: build_client(&config, None),
2341 config,
2342 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2343 PINNED_CLIENT_TTL,
2344 PINNED_CLIENT_MAX_ENTRIES,
2345 )),
2346 }
2347 }
2348
2349 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2350 match config {
2351 Some(cfg) => Self::with_config(cfg),
2352 None => Self::new(),
2353 }
2354 }
2355}
2356
2357impl Default for HttpsComponent {
2358 fn default() -> Self {
2359 Self::new()
2360 }
2361}
2362
2363impl Component for HttpsComponent {
2364 fn scheme(&self) -> &str {
2365 "https"
2366 }
2367
2368 fn metadata(&self) -> ComponentMetadata {
2369 let mut meta = HttpEndpointConfig::metadata();
2372 meta.scheme = "https".to_string();
2373 meta.description = "HTTPS client and server component (TLS over HTTP)".to_string();
2374 meta
2375 }
2376
2377 fn create_endpoint(
2378 &self,
2379 uri: &str,
2380 ctx: &dyn camel_component_api::ComponentContext,
2381 ) -> Result<Box<dyn Endpoint>, CamelError> {
2382 self.config.validate()?;
2383 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2384 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2385 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2386 server_config.host.clone(),
2387 server_config.port,
2388 )));
2389 self.pinned_cache
2390 .wire(HttpComponentKind::Https, ctx.metrics());
2391 Ok(Box::new(HttpEndpoint {
2392 uri: uri.to_string(),
2393 config,
2394 server_config,
2395 client: self.client.clone(),
2396 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2397 http_config: self.config.clone(),
2398 }))
2399 }
2400}
2401
2402struct HttpEndpoint {
2407 uri: String,
2408 config: HttpEndpointConfig,
2409 server_config: HttpServerConfig,
2410 client: reqwest::Client,
2411 pinned_cache: std::sync::Arc<PinnedClientCache>,
2412 http_config: HttpConfig,
2413}
2414
2415impl Endpoint for HttpEndpoint {
2416 fn uri(&self) -> &str {
2417 &self.uri
2418 }
2419
2420 fn create_consumer(
2421 &self,
2422 rt: Arc<dyn camel_component_api::RuntimeObservability>,
2423 ) -> Result<Box<dyn Consumer>, CamelError> {
2424 let scheme_is_https = self.server_config.scheme == "https";
2427 let has_tls = self.server_config.tls_config.is_some();
2428
2429 if scheme_is_https && !has_tls {
2430 return Err(CamelError::EndpointCreationFailed(
2431 "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
2432 ));
2433 }
2434 if !scheme_is_https && has_tls {
2435 return Err(CamelError::EndpointCreationFailed(
2436 "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
2437 ));
2438 }
2439 Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
2440 }
2441
2442 fn create_producer(
2443 &self,
2444 rt: Arc<dyn camel_component_api::RuntimeObservability>,
2445 _ctx: &ProducerContext,
2446 ) -> Result<BoxProcessor, CamelError> {
2447 let producer = HttpProducer {
2448 config: Arc::new(self.config.clone()),
2449 client: self.client.clone(),
2450 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2451 http_config: Arc::new(self.http_config.clone()),
2452 runtime: rt,
2453 };
2454 if let Some(ref provider) = self.config.token_provider {
2455 let layer = BearerTokenLayer::new(Arc::clone(provider));
2456 Ok(BoxProcessor::new(layer.layer(producer)))
2457 } else {
2458 Ok(BoxProcessor::new(producer))
2459 }
2460 }
2461}
2462
2463#[derive(Clone)]
2468struct HttpProducer {
2469 config: Arc<HttpEndpointConfig>,
2470 client: reqwest::Client,
2471 pinned_cache: std::sync::Arc<PinnedClientCache>,
2472 http_config: Arc<HttpConfig>,
2473 runtime: Arc<dyn RuntimeObservability>,
2479}
2480
2481impl HttpProducer {
2482 fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
2483 if let Some(ref method) = config.http_method {
2484 return method.to_uppercase();
2485 }
2486 if let Some(method) = exchange
2487 .input
2488 .header("CamelHttpMethod")
2489 .and_then(|v| v.as_str())
2490 {
2491 return method.to_uppercase();
2492 }
2493 if !exchange.input.body.is_empty() {
2494 return "POST".to_string();
2495 }
2496 "GET".to_string()
2497 }
2498
2499 fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> Result<String, CamelError> {
2500 if config.bridge_endpoint {
2509 let Some(query) = resolve_endpoint_query(config)? else {
2510 return Ok(config.base_url.clone());
2511 };
2512 let _: url::Url = url::Url::parse(&config.base_url).map_err(|e| {
2520 CamelError::ProcessorError(format!(
2521 "invalid base URL '{}': {e}",
2522 redact_url_for_diagnostics(&config.base_url)
2523 ))
2524 })?;
2525 let mut url = config.base_url.clone();
2526 url.push('?');
2527 url.push_str(&query);
2528 return Ok(url);
2529 }
2530
2531 if let Some(uri) = exchange
2532 .input
2533 .header("CamelHttpUri")
2534 .and_then(|v| v.as_str())
2535 {
2536 if let Some(fence) = &config.allowed_uri_hosts
2541 && !uri_host_allowed(uri, fence)?
2542 {
2543 return Err(CamelError::ProcessorError(format!(
2544 "CamelHttpUri host not allowed by allowedUriHosts fence: {}",
2545 redact_url_for_diagnostics(uri)
2546 )));
2547 }
2548 let (base, override_query) = match uri.split_once('?') {
2555 Some((base, query)) => (base, Some(query)),
2556 None => (uri, None),
2557 };
2558 if let Some(query) = override_query {
2564 for (_key, span) in raw_query_pairs(query)? {
2565 validate_raw_query_span(span)?;
2566 }
2567 }
2568 let mut url = base.to_string();
2569 if let Some(path) = exchange
2570 .input
2571 .header("CamelHttpPath")
2572 .and_then(|v| v.as_str())
2573 {
2574 if !url.ends_with('/') && !path.starts_with('/') {
2575 url.push('/');
2576 }
2577 url.push_str(path);
2578 }
2579 if let Some(query) = exchange
2580 .input
2581 .header("CamelHttpQuery")
2582 .and_then(|v| v.as_str())
2583 {
2584 if let Some(merged) = merge_header_query(override_query, query)? {
2585 url.push('?');
2586 url.push_str(&merged);
2587 }
2588 return Ok(url);
2589 }
2590 if let Some(query) = override_query {
2591 url.push('?');
2592 url.push_str(query);
2593 }
2594 return Ok(url);
2595 }
2596
2597 let mut url = config.base_url.clone();
2598
2599 if let Some(path) = exchange
2600 .input
2601 .header("CamelHttpPath")
2602 .and_then(|v| v.as_str())
2603 {
2604 if !url.ends_with('/') && !path.starts_with('/') {
2605 url.push('/');
2606 }
2607 url.push_str(path);
2608 }
2609
2610 if let Some(query) = exchange
2611 .input
2612 .header("CamelHttpQuery")
2613 .and_then(|v| v.as_str())
2614 {
2615 if let Some(merged) =
2620 merge_header_query(resolve_endpoint_query(config)?.as_deref(), query)?
2621 {
2622 url.push('?');
2623 url.push_str(&merged);
2624 }
2625 return Ok(url);
2626 }
2627
2628 if let Some(query) = resolve_endpoint_query(config)? {
2629 url.push('?');
2630 url.push_str(&query);
2631 }
2632
2633 Ok(url)
2634 }
2635
2636 fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
2637 status >= range.0 && status <= range.1
2638 }
2639}
2640
2641#[derive(Clone, Debug, PartialEq, Eq)]
2646pub struct AllowedUriHost {
2647 pub host: String,
2649 pub port: Option<u16>,
2651}
2652
2653fn parse_allowed_uri_hosts(raw: &str) -> Result<Vec<AllowedUriHost>, CamelError> {
2661 let mut entries = Vec::new();
2662 for segment in raw.split(',') {
2663 let segment = segment.trim();
2664 if segment.is_empty() {
2665 continue;
2666 }
2667 let parsed = url::Url::parse(&format!("http://{segment}"))
2668 .map_err(|_| invalid_allowed_uri_host_entry(segment))?;
2669 if parsed.path() != "/" || !parsed.username().is_empty() || parsed.password().is_some() {
2673 return Err(invalid_allowed_uri_host_entry(segment));
2674 }
2675 let Some(host) = parsed.host_str() else {
2676 return Err(invalid_allowed_uri_host_entry(segment));
2677 };
2678 entries.push(AllowedUriHost {
2679 host: host.to_string(),
2680 port: parsed.port(),
2681 });
2682 }
2683 if entries.is_empty() {
2684 return Err(CamelError::InvalidUri(
2685 "allowedUriHosts declares no valid host entries".to_string(),
2686 ));
2687 }
2688 Ok(entries)
2689}
2690
2691fn invalid_allowed_uri_host_entry(segment: &str) -> CamelError {
2692 CamelError::InvalidUri(format!("invalid allowedUriHosts entry '{segment}'"))
2693}
2694
2695pub(crate) fn uri_host_allowed(
2702 url_str: &str,
2703 fence: &[AllowedUriHost],
2704) -> Result<bool, CamelError> {
2705 let Ok(parsed) = url::Url::parse(url_str) else {
2706 return Ok(false);
2707 };
2708 let Some(host) = parsed.host_str() else {
2709 return Ok(false);
2710 };
2711 let effective_port = parsed.port().or(match parsed.scheme() {
2712 "https" => Some(443_u16),
2713 "http" => Some(80),
2714 _ => None,
2715 });
2716 Ok(fence.iter().any(|entry| {
2717 entry.host == host
2718 && match entry.port {
2719 None => true,
2720 Some(port) => effective_port == Some(port),
2721 }
2722 }))
2723}
2724
2725fn resolve_endpoint_query(config: &HttpEndpointConfig) -> Result<Option<String>, CamelError> {
2738 let mut parts: Vec<String> = Vec::new();
2739 let mut authored_keys = std::collections::HashSet::new();
2740
2741 if let Some(raw) = config.raw_query.as_deref() {
2742 for (key, span) in raw_query_pairs(raw)? {
2743 authored_keys.insert(key.clone());
2744 if is_consumed_option(&key) {
2745 continue;
2746 }
2747 validate_raw_query_span(span)?;
2748 parts.push(span.to_string());
2749 }
2750 }
2751
2752 for (key, value) in &config.query_params {
2753 if !authored_keys.contains(key.as_str()) {
2754 parts.push(format!(
2755 "{}={}",
2756 encode_query_component(key),
2757 encode_query_component(value)
2758 ));
2759 }
2760 }
2761
2762 if parts.is_empty() && config.raw_query.as_deref() != Some("") {
2763 return Ok(None);
2764 }
2765 Ok(Some(parts.join("&")))
2766}
2767
2768fn merge_header_query(
2777 higher_precedence: Option<&str>,
2778 header_query: &str,
2779) -> Result<Option<String>, CamelError> {
2780 if header_query.is_empty() {
2781 return Ok(higher_precedence.map(str::to_string));
2782 }
2783 let mut parts: Vec<String> = Vec::new();
2784 let mut higher_keys = std::collections::HashSet::new();
2785 for (key, span) in raw_query_pairs(higher_precedence.unwrap_or(""))? {
2786 higher_keys.insert(key);
2787 parts.push(span.to_string());
2788 }
2789 for (key, span) in raw_query_pairs(header_query)? {
2790 validate_raw_query_span(span)?;
2791 if !higher_keys.contains(key.as_str()) {
2792 parts.push(span.to_string());
2793 }
2794 }
2795 if parts.is_empty() {
2796 return Ok(None);
2797 }
2798 Ok(Some(parts.join("&")))
2799}
2800
2801fn is_legal_query_byte(byte: u8) -> bool {
2812 matches!(byte,
2813 b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z'
2814 | b'-' | b'.' | b'_' | b'~'
2815 | b'!' | b'$' | b'&' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'='
2816 | b':' | b'@' | b'/' | b'?'
2817 | b'%')
2818}
2819
2820fn validate_raw_query_span(span: &str) -> Result<(), CamelError> {
2826 for &byte in span.as_bytes() {
2827 if !is_legal_query_byte(byte) {
2828 return Err(CamelError::ProcessorError(format!(
2829 "raw query pair '{span}' contains byte 0x{byte:02X}, which is not legal in a URL query component"
2830 )));
2831 }
2832 }
2833 Ok(())
2834}
2835
2836fn encode_query_component(component: &str) -> String {
2840 const HEX: &[u8; 16] = b"0123456789ABCDEF";
2841 let mut out = String::with_capacity(component.len());
2842 for &byte in component.as_bytes() {
2843 match byte {
2844 b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z' | b'-' | b'.' | b'_' | b'~' => {
2845 out.push(byte as char);
2846 }
2847 _ => {
2848 out.push('%');
2849 out.push(HEX[(byte >> 4) as usize] as char);
2850 out.push(HEX[(byte & 0x0f) as usize] as char);
2851 }
2852 }
2853 }
2854 out
2855}
2856
2857fn mask_base_url_userinfo(raw: &str) -> String {
2865 let Some(scheme_end) = raw.find("://") else {
2866 return raw.to_string();
2867 };
2868 let after_scheme = &raw[scheme_end + 3..];
2869 let authority_end = after_scheme
2871 .find(['/', '?', '#'])
2872 .unwrap_or(after_scheme.len());
2873 let authority = &after_scheme[..authority_end];
2874 let Some(at) = authority.rfind('@') else {
2877 return raw.to_string();
2878 };
2879 let mut out = String::with_capacity(raw.len());
2880 out.push_str(&raw[..scheme_end + 3]);
2881 out.push_str("***@");
2882 out.push_str(&authority[at + 1..]);
2883 out.push_str(&after_scheme[authority_end..]);
2884 out
2885}
2886
2887pub(crate) fn redact_url_for_diagnostics(raw: &str) -> String {
2893 const MAX_URL_LOG_LEN: usize = 256;
2894 match url::Url::parse(raw) {
2895 Ok(mut u) => {
2896 if !u.username().is_empty() || u.password().is_some() {
2897 let _ = u.set_username("***");
2898 let _ = u.set_password(None);
2899 }
2900 if u.query().is_some() {
2901 u.set_query(None);
2902 let mut s = u.to_string();
2904 if let Some(stripped) = s.strip_suffix('?') {
2905 s = stripped.to_string();
2906 }
2907 s.push_str("?[redacted]");
2908 if s.len() > MAX_URL_LOG_LEN {
2909 s.truncate(MAX_URL_LOG_LEN);
2910 }
2911 return s;
2912 }
2913 let mut s = u.to_string();
2914 if s.len() > MAX_URL_LOG_LEN {
2915 s.truncate(MAX_URL_LOG_LEN);
2916 }
2917 s
2918 }
2919 Err(_) => {
2920 let mut s = raw.to_string();
2921 s.truncate(MAX_URL_LOG_LEN);
2922 s
2923 }
2924 }
2925}
2926
2927const MAX_ERROR_RESPONSE_BODY_BYTES: usize = 4096;
2932
2933fn truncate_error_body(body: &[u8]) -> String {
2934 if body.len() <= MAX_ERROR_RESPONSE_BODY_BYTES {
2935 String::from_utf8_lossy(body).into_owned()
2936 } else {
2937 let mut s = String::from_utf8_lossy(&body[..MAX_ERROR_RESPONSE_BODY_BYTES]).into_owned();
2938 s.push_str("...[truncated]");
2939 s
2940 }
2941}
2942
2943impl HttpProducer {
2944 fn is_entity_enclosing(method: &str) -> bool {
2949 matches!(method, "POST" | "PUT" | "PATCH")
2950 }
2951}
2952
2953impl Service<Exchange> for HttpProducer {
2954 type Response = Exchange;
2955 type Error = CamelError;
2956 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
2957
2958 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
2959 Poll::Ready(Ok(()))
2960 }
2961
2962 fn call(&mut self, exchange: Exchange) -> Self::Future {
2963 let config = self.config.clone();
2964 let shared_client = self.client.clone();
2965 let pinned_cache = std::sync::Arc::clone(&self.pinned_cache);
2966 let http_config = self.http_config.clone();
2967 let component_metrics = self.runtime.component_metrics();
2968
2969 Box::pin(async move {
2970 let mut exchange = exchange;
2971 let outcome = async {
2972 let method_str = HttpProducer::resolve_method(&exchange, &config);
2973 let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
2978 let url = HttpProducer::resolve_url(&exchange, &config)?;
2979
2980 ssrf::validate_url_for_ssrf(&url, &config)?;
2982
2983 let resolved =
2991 ssrf::resolve_initial_url_for_ssrf(&url, config.allow_internal).await?;
2992 let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
2993 pinned_cache
2994 .get_or_build(host.as_str(), addrs, || {
2995 build_client(&http_config, Some((host.as_str(), addrs)))
2996 })
2997 .await
2998 } else {
2999 shared_client.clone()
3000 };
3001
3002 debug!(
3003 correlation_id = %exchange.correlation_id(),
3004 method = %method_str,
3005 url = %redact_url_for_diagnostics(&url),
3006 "HTTP request"
3007 );
3008
3009 let method = method_str.parse::<reqwest::Method>().map_err(|e| {
3010 CamelError::ProcessorError(format!(
3011 "Invalid HTTP method '{}': {}",
3012 method_str, e
3013 ))
3014 })?;
3015
3016 let mut collected_headers: Vec<(
3018 reqwest::header::HeaderName,
3019 reqwest::header::HeaderValue,
3020 )> = Vec::new();
3021
3022 if let Some(user_agent) = &config.user_agent
3023 && !config.bridge_endpoint
3024 {
3025 match constructed_header("user-agent", user_agent) {
3026 Ok((_, val)) => {
3027 collected_headers.push((reqwest::header::USER_AGENT, val));
3028 }
3029 Err(drop) => debug!(
3030 correlation_id = %exchange.correlation_id(),
3031 header = %drop.name,
3032 "outbound header dropped: {}",
3033 drop.reason
3034 ),
3035 }
3036 }
3037
3038 #[cfg(feature = "otel")]
3040 let should_inject_otel = !config.bridge_endpoint;
3041 #[cfg(feature = "otel")]
3042 if should_inject_otel {
3043 let mut otel_headers = HashMap::new();
3044 camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
3045 for (k, v) in otel_headers {
3046 match constructed_header(&k, &v) {
3047 Ok((name, val)) => collected_headers.push((name, val)),
3048 Err(drop) => debug!(
3049 correlation_id = %exchange.correlation_id(),
3050 header = %drop.name,
3051 "outbound header dropped: {}",
3052 drop.reason
3053 ),
3054 }
3055 }
3056 }
3057
3058 let conn_tokens = header_policy::connection_tokens(
3059 exchange
3060 .input
3061 .headers
3062 .iter()
3063 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3064 .filter_map(|(_, v)| v.as_str()),
3065 );
3066
3067 let outbound = select_outbound_headers(
3068 &exchange.input.headers,
3069 &config.skip_request_headers,
3070 &conn_tokens,
3071 );
3072 for drop in &outbound.drops {
3073 if let Some(value_kind) = drop.value_kind {
3074 debug!(
3075 correlation_id = %exchange.correlation_id(),
3076 header = %drop.name,
3077 value_kind = value_kind,
3078 "outbound header dropped: {}",
3079 drop.reason
3080 );
3081 } else {
3082 debug!(
3083 correlation_id = %exchange.correlation_id(),
3084 header = %drop.name,
3085 "outbound header dropped: {}",
3086 drop.reason
3087 );
3088 }
3089 }
3090 collected_headers.extend(outbound.accepted);
3091
3092 if !config.bridge_endpoint {
3094 match &config.auth {
3095 HttpAuth::None => {}
3096 HttpAuth::Basic { username, password } => {
3097 use base64::Engine;
3098 let credentials = format!("{username}:{password}");
3100 let encoded =
3101 base64::engine::general_purpose::STANDARD.encode(credentials);
3102 match constructed_header("authorization", &format!("Basic {encoded}")) {
3105 Ok((_, val)) => {
3106 collected_headers.push((reqwest::header::AUTHORIZATION, val));
3107 }
3108 Err(drop) => debug!(
3109 correlation_id = %exchange.correlation_id(),
3110 header = %drop.name,
3111 "outbound header dropped: {}",
3112 drop.reason
3113 ),
3114 }
3115 }
3116 HttpAuth::Bearer { token } => {
3117 let bearer = format!("Bearer {token}");
3119 match constructed_header("authorization", &bearer) {
3120 Ok((_, val)) => {
3121 collected_headers.push((reqwest::header::AUTHORIZATION, val));
3122 }
3123 Err(drop) => debug!(
3124 correlation_id = %exchange.correlation_id(),
3125 header = %drop.name,
3126 "outbound header dropped: {}",
3127 drop.reason
3128 ),
3129 }
3130 }
3131 }
3132
3133 if config.connection_close {
3134 collected_headers.push((
3135 reqwest::header::CONNECTION,
3136 reqwest::header::HeaderValue::from_static("close"),
3137 ));
3138 }
3139 }
3140
3141 let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
3143 let materialized_body: Option<Vec<u8>> = if is_stream_body {
3144 if suppress_body {
3145 std::mem::take(&mut exchange.input.body);
3153 tracing::warn!(
3155 correlation_id = %exchange.correlation_id(),
3156 method = %method_str,
3157 "dropping request body for non-entity-enclosing HTTP method"
3158 );
3159 }
3160 None } else {
3162 let body = std::mem::take(&mut exchange.input.body);
3163 let bytes = body.into_bytes(config.max_body_size).await?;
3164 if bytes.is_empty() {
3165 None
3167 } else if suppress_body {
3168 tracing::warn!(
3170 correlation_id = %exchange.correlation_id(),
3171 method = %method_str,
3172 "dropping request body for non-entity-enclosing HTTP method"
3173 );
3174 None
3175 } else {
3176 Some(bytes.to_vec())
3177 }
3178 };
3179
3180 let response = if config.follow_redirects && !is_stream_body {
3181 ssrf::send_with_ssrf_safe_redirects(
3187 &client,
3188 &shared_client,
3189 &pinned_cache,
3190 &http_config,
3191 &config,
3192 method,
3193 &url,
3194 collected_headers,
3195 materialized_body,
3196 config.max_redirects,
3197 config.response_timeout,
3198 )
3199 .await?
3200 } else {
3201 let mut request = client.request(method, &url);
3203
3204 if let Some(timeout) = config.response_timeout {
3205 request = request.timeout(timeout);
3206 }
3207
3208 for (name, value) in &collected_headers {
3209 request = request.header(name, value);
3210 }
3211
3212 if is_stream_body {
3213 if let Body::Stream(ref s) = exchange.input.body {
3214 let mut stream_lock = s.stream.lock().await;
3215 if let Some(stream) = stream_lock.take() {
3216 request = request.body(reqwest::Body::wrap_stream(stream));
3217 } else {
3218 return Err(CamelError::AlreadyConsumed);
3219 }
3220 }
3221 } else if let Some(ref body_bytes) = materialized_body {
3222 request = request.body(body_bytes.clone());
3223 }
3224
3225 request.send().await.map_err(|e| {
3226 CamelError::ProcessorError(format!("HTTP request failed: {e}"))
3227 })?
3228 };
3229
3230 let status_code = response.status().as_u16();
3231 let status_text = response
3232 .status()
3233 .canonical_reason()
3234 .unwrap_or("Unknown")
3235 .to_string();
3236
3237 for (key, value) in response.headers() {
3238 if config
3239 .skip_response_headers
3240 .iter()
3241 .any(|h| h.eq_ignore_ascii_case(key.as_str()))
3242 {
3243 continue;
3244 }
3245 if let Ok(val_str) = value.to_str() {
3246 exchange.input.set_header(
3247 title_case_header(key.as_str()),
3248 serde_json::Value::String(val_str.to_string()),
3249 );
3250 }
3251 }
3252
3253 exchange.input.set_header(
3254 "CamelHttpResponseCode",
3255 serde_json::Value::Number(status_code.into()),
3256 );
3257 exchange.input.set_header(
3258 "CamelHttpResponseText",
3259 serde_json::Value::String(status_text.clone()),
3260 );
3261
3262 let read_timeout = Duration::from_millis(config.read_timeout_ms);
3264 let response_body = tokio::time::timeout(read_timeout, async {
3265 if let Some(content_len) = response.content_length()
3267 && content_len > config.max_response_bytes as u64
3268 {
3269 return Err(CamelError::ProcessorError(format!(
3270 "Response body too large: {} bytes exceeds limit of {} bytes",
3271 content_len, config.max_response_bytes
3272 )));
3273 }
3274 use futures::TryStreamExt;
3276 let mut stream = response.bytes_stream();
3277 let mut total: usize = 0;
3278 let mut collected = Vec::new();
3279 while let Some(chunk) = stream.try_next().await.map_err(|e| {
3280 CamelError::ProcessorError(format!("Failed to read response body: {e}"))
3281 })? {
3282 total += chunk.len();
3283 if total > config.max_response_bytes {
3284 return Err(CamelError::ProcessorError(format!(
3285 "Response body too large: {} bytes exceeds limit of {} bytes",
3286 total, config.max_response_bytes
3287 )));
3288 }
3289 collected.push(chunk);
3290 }
3291 let mut result = bytes::BytesMut::with_capacity(total);
3292 for chunk in collected {
3293 result.extend_from_slice(&chunk);
3294 }
3295 Ok::<bytes::Bytes, CamelError>(result.freeze())
3296 })
3297 .await
3298 .map_err(|_| {
3299 CamelError::ProcessorError(format!(
3300 "Read timeout after {}ms",
3301 config.read_timeout_ms
3302 ))
3303 })??;
3304
3305 if config.throw_exception_on_failure
3306 && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
3307 {
3308 return Err(CamelError::HttpOperationFailed {
3309 method: method_str,
3310 url: redact_url_for_diagnostics(&url),
3313 status_code,
3314 status_text,
3315 response_body: Some(truncate_error_body(&response_body)),
3316 });
3317 }
3318
3319 if !response_body.is_empty() {
3320 exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
3321 }
3322
3323 debug!(
3324 correlation_id = %exchange.correlation_id(),
3325 status = status_code,
3326 url = %redact_url_for_diagnostics(&url),
3327 "HTTP response"
3328 );
3329 Ok(exchange)
3330 }
3331 .await;
3332 component_metrics.observe("http", "request", outcome.is_err());
3339 outcome
3340 })
3341 }
3342}
3343
3344#[cfg(test)]
3354pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
3355
3356fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
3366 match e {
3367 CamelError::Unauthenticated(msg) => {
3368 tracing::warn!(error = %msg, path = %path, "Authentication failed");
3369 HttpReply {
3370 status: 401,
3371 headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
3372 body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
3373 }
3374 }
3375 CamelError::Unauthorized(msg) => {
3376 tracing::warn!(error = %msg, path = %path, "Authorization failed");
3377 HttpReply {
3378 status: 403,
3379 headers: vec![],
3380 body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
3381 }
3382 }
3383 CamelError::TypeConversionFailed(msg) => {
3384 tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
3385 json_error_reply(400, "bad_request", msg)
3386 }
3387 CamelError::ValidationError(msg) => {
3388 tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
3389 json_error_reply(400, "validation_error", msg)
3390 }
3391 CamelError::ConsumerStopping => {
3392 tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
3393 HttpReply {
3394 status: 503,
3395 headers: vec![],
3396 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
3397 }
3398 }
3399 CamelError::UnsupportedMediaType { consumed, declared } => {
3400 tracing::warn!(error = %consumed, declared = %declared, path = %path, "Unsupported media type (bad request)");
3401 json_error_reply(
3402 415,
3403 "unsupported_media_type",
3404 format!("consumed {consumed}, declared {declared}"),
3405 )
3406 }
3407 CamelError::NotAcceptable { accept, produced } => {
3408 tracing::warn!(error = %accept, produced = %produced, path = %path, "Not acceptable (bad request)");
3409 json_error_reply(
3410 406,
3411 "not_acceptable",
3412 format!("accept {accept}, produced {produced}"),
3413 )
3414 }
3415 e => {
3416 tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
3418 HttpReply {
3419 status: 500,
3420 headers: vec![],
3421 body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
3422 }
3423 }
3424 }
3425}
3426
3427fn json_error_reply(status: u16, code: &str, message: String) -> HttpReply {
3435 let body = serde_json::to_string(&serde_json::json!({
3436 "error": code,
3437 "message": message,
3438 }))
3439 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
3441 status,
3442 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
3443 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
3444 }
3445}
3446
3447const fn json_value_kind(v: &serde_json::Value) -> &'static str {
3451 match v {
3452 serde_json::Value::Null => "null",
3453 serde_json::Value::Bool(_) => "bool",
3454 serde_json::Value::Number(_) => "number",
3455 serde_json::Value::String(_) => "string",
3456 serde_json::Value::Array(_) => "array",
3457 serde_json::Value::Object(_) => "object",
3458 }
3459}
3460
3461fn scalar_string_form(v: &serde_json::Value) -> Option<String> {
3466 match v {
3467 serde_json::Value::String(s) => Some(s.clone()),
3468 serde_json::Value::Number(n) => Some(n.to_string()),
3469 serde_json::Value::Bool(b) => Some(b.to_string()),
3470 _ => None,
3471 }
3472}
3473
3474fn select_response_headers(
3489 headers: &HashMap<String, serde_json::Value>,
3490 user_content_type: Option<String>,
3491 inferred_content_type: Option<String>,
3492) -> Vec<(String, String)> {
3493 let conn_tokens = header_policy::connection_tokens(
3494 headers
3495 .iter()
3496 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3497 .filter_map(|(_, v)| v.as_str()),
3498 );
3499 let mut selected: Vec<(String, String)> = Vec::new();
3500 for (k, v) in headers {
3501 if k.starts_with("Camel") {
3502 debug!(header = %k, "reply header dropped: Camel namespace");
3503 continue;
3504 }
3505 if header_policy::excluded_response(k, &conn_tokens) {
3506 debug!(header = %k, "reply header dropped: emission policy");
3507 continue;
3508 }
3509 match scalar_string_form(v) {
3510 Some(s) => selected.push((k.clone(), s)),
3511 None => debug!(
3512 header = %k,
3513 value_kind = json_value_kind(v),
3514 "reply header dropped: no scalar string form"
3515 ),
3516 }
3517 }
3518 if let Some(ct) = user_content_type.or(inferred_content_type) {
3519 selected.push(("Content-Type".to_string(), ct));
3520 }
3521 selected
3522}
3523
3524#[derive(Debug)]
3529struct OutboundHeaderDrop<'a> {
3530 name: &'a str,
3531 reason: &'static str,
3532 value_kind: Option<&'static str>,
3533}
3534
3535struct OutboundHeaderSelection<'a> {
3538 accepted: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>,
3539 drops: Vec<OutboundHeaderDrop<'a>>,
3540}
3541
3542fn select_outbound_headers<'a>(
3556 headers: &'a HashMap<String, serde_json::Value>,
3557 skip_request_headers: &[String],
3558 conn_tokens: &[String],
3559) -> OutboundHeaderSelection<'a> {
3560 let mut accepted = Vec::new();
3561 let mut drops = Vec::new();
3562 for (key, value) in headers {
3563 if key.starts_with("Camel") {
3564 drops.push(OutboundHeaderDrop {
3565 name: key,
3566 reason: "Camel namespace",
3567 value_kind: None,
3568 });
3569 continue;
3570 }
3571 if skip_request_headers
3572 .iter()
3573 .any(|h| h.eq_ignore_ascii_case(key))
3574 {
3575 drops.push(OutboundHeaderDrop {
3576 name: key,
3577 reason: "skip_request_headers",
3578 value_kind: None,
3579 });
3580 continue;
3581 }
3582 if header_policy::excluded_outbound(key, conn_tokens) {
3583 drops.push(OutboundHeaderDrop {
3584 name: key,
3585 reason: "outbound emission policy",
3586 value_kind: None,
3587 });
3588 continue;
3589 }
3590 let Some(val_str) = scalar_string_form(value) else {
3591 drops.push(OutboundHeaderDrop {
3592 name: key,
3593 reason: "no scalar string form",
3594 value_kind: Some(json_value_kind(value)),
3595 });
3596 continue;
3597 };
3598 match constructed_header(key, &val_str) {
3599 Ok((name, val)) => accepted.push((name, val)),
3600 Err(drop) => drops.push(drop),
3601 }
3602 }
3603 OutboundHeaderSelection { accepted, drops }
3604}
3605
3606fn constructed_header<'a>(
3611 name: &'a str,
3612 value: &str,
3613) -> Result<(reqwest::header::HeaderName, reqwest::header::HeaderValue), OutboundHeaderDrop<'a>> {
3614 let header_name = match reqwest::header::HeaderName::from_bytes(name.as_bytes()) {
3615 Ok(header_name) => header_name,
3616 Err(_) => {
3617 return Err(OutboundHeaderDrop {
3618 name,
3619 reason: "invalid header name",
3620 value_kind: None,
3621 });
3622 }
3623 };
3624 let header_value = match reqwest::header::HeaderValue::from_str(value) {
3625 Ok(header_value) => header_value,
3626 Err(_) => {
3627 return Err(OutboundHeaderDrop {
3628 name,
3629 reason: "invalid header value",
3630 value_kind: None,
3631 });
3632 }
3633 };
3634 Ok((header_name, header_value))
3635}
3636
3637#[cfg(test)]
3638mod tests {
3639 use camel_component_api::test_support::NoopRuntimeObservability;
3640
3641 fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3645 std::sync::Arc::new(NoopRuntimeObservability)
3646 }
3647 fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3648 std::sync::Arc::new(NoopRuntimeObservability)
3649 }
3650 fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3651 std::sync::Arc::new(NoopRuntimeObservability)
3652 }
3653
3654 use super::*;
3655 use crate::rest_match::PathSegment;
3656 use camel_component_api::{Message, NoOpComponentContext};
3657 use std::sync::Arc;
3658 use std::time::Duration;
3659
3660 fn test_producer_ctx() -> ProducerContext {
3661 ProducerContext::new()
3662 }
3663
3664 #[test]
3669 fn redact_url_masks_userinfo_and_query() {
3670 let redacted =
3671 redact_url_for_diagnostics("http://user:secretpass@internal.example/api?token=abc123");
3672 assert!(
3673 !redacted.contains("secretpass"),
3674 "password must be masked: {redacted}"
3675 );
3676 assert!(
3677 !redacted.contains("token=abc123"),
3678 "query must be masked: {redacted}"
3679 );
3680 assert!(
3681 !redacted.contains("user@"),
3682 "username must be masked: {redacted}"
3683 );
3684 assert!(
3685 redacted.contains("internal.example"),
3686 "host stays visible: {redacted}"
3687 );
3688 assert!(redacted.contains("[redacted]"), "query marked: {redacted}");
3689 }
3690
3691 #[test]
3692 fn redact_url_keeps_clean_urls_visible() {
3693 let redacted = redact_url_for_diagnostics("https://api.example.com/v1/items");
3694 assert_eq!(redacted, "https://api.example.com/v1/items");
3695 }
3696
3697 #[test]
3698 fn redact_url_masks_password_only_userinfo() {
3699 let redacted = redact_url_for_diagnostics("http://:pwsecret@host.example/");
3700 assert!(
3701 !redacted.contains("pwsecret"),
3702 "password-only userinfo leaked: {redacted}"
3703 );
3704 assert_eq!(redacted, "http://***@host.example/");
3705
3706 let redacted = redact_url_for_diagnostics("http://user:pw2@host.example/api");
3707 assert!(!redacted.contains("pw2"), "password leaked: {redacted}");
3708 assert_eq!(redacted, "http://***@host.example/api");
3709
3710 let redacted = redact_url_for_diagnostics("http://host.example/api");
3711 assert_eq!(redacted, "http://host.example/api");
3712 }
3713
3714 #[test]
3715 fn redact_url_truncates_unparseable() {
3716 let long = "x".repeat(1000);
3717 let redacted = redact_url_for_diagnostics(&long);
3718 assert_eq!(redacted.len(), 256, "unparseable URL must be truncated");
3719 }
3720
3721 #[test]
3722 fn truncate_error_body_caps_attacker_body() {
3723 let big = vec![b'A'; 10 * 1024 * 1024];
3724 let truncated = truncate_error_body(&big);
3725 assert!(
3726 truncated.len() <= MAX_ERROR_RESPONSE_BODY_BYTES + 20,
3727 "body must be capped near {} bytes, got {}",
3728 MAX_ERROR_RESPONSE_BODY_BYTES,
3729 truncated.len()
3730 );
3731 assert!(truncated.ends_with("...[truncated]"));
3732 }
3733
3734 #[test]
3735 fn truncate_error_body_keeps_small_body() {
3736 assert_eq!(truncate_error_body(b"boom"), "boom");
3737 }
3738
3739 #[test]
3740 fn test_http_config_defaults() {
3741 let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
3742 assert_eq!(config.base_url, "http://localhost:8080/api");
3743 assert!(config.http_method.is_none());
3744 assert!(config.throw_exception_on_failure);
3745 assert_eq!(config.ok_status_code_range, (200, 299));
3746 assert!(config.response_timeout.is_none());
3747 assert!(matches!(config.auth, HttpAuth::None));
3748 assert!(!config.bridge_endpoint);
3749 assert!(!config.connection_close);
3750 }
3751
3752 #[test]
3753 fn test_http_config_scheme() {
3754 assert_eq!(HttpEndpointConfig::scheme(), "http");
3756 }
3757
3758 #[test]
3759 fn test_http_config_from_components() {
3760 let components = camel_component_api::UriComponents {
3762 scheme: "https".to_string(),
3763 path: "//api.example.com/v1".to_string(),
3764 params: std::collections::HashMap::from([(
3765 "httpMethod".to_string(),
3766 "POST".to_string(),
3767 )]),
3768 raw_query: None,
3769 };
3770 let config = HttpEndpointConfig::from_components(components).unwrap();
3771 assert_eq!(config.base_url, "https://api.example.com/v1");
3772 assert_eq!(config.http_method, Some("POST".to_string()));
3773 }
3774
3775 #[test]
3776 fn test_http_config_with_options() {
3777 let config = HttpEndpointConfig::from_uri(
3778 "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
3779 ).unwrap();
3780 assert_eq!(config.base_url, "https://api.example.com/v1");
3781 assert_eq!(config.http_method, Some("PUT".to_string()));
3782 assert!(!config.throw_exception_on_failure);
3783 assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
3784 }
3785
3786 #[test]
3787 fn test_http_endpoint_config_auth_and_headers_options() {
3788 let config = HttpEndpointConfig::from_uri(
3789 "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
3790 )
3791 .unwrap();
3792
3793 assert!(matches!(
3794 config.auth,
3795 HttpAuth::Basic { username, password } if username == "u" && password == "p"
3796 ));
3797 assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
3798 assert!(config.bridge_endpoint);
3799 assert!(config.connection_close);
3800 assert_eq!(
3801 config.skip_request_headers,
3802 vec!["authorization".to_string(), "x-secret".to_string()]
3803 );
3804 assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
3805 }
3806
3807 #[test]
3808 fn test_http_endpoint_config_bearer_auth() {
3809 let config = HttpEndpointConfig::from_uri(
3810 "http://localhost/api?authMethod=Bearer&authBearerToken=t",
3811 )
3812 .unwrap();
3813 assert!(matches!(
3814 config.auth,
3815 HttpAuth::Bearer { token } if token == "t"
3816 ));
3817 }
3818
3819 #[test]
3820 fn rejects_cookie_handling_inmemory() {
3821 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
3822 match result {
3823 Err(CamelError::InvalidUri(msg)) => {
3824 assert!(
3825 msg.contains("cookieHandling is not supported"),
3826 "expected rejection message, got: {msg}"
3827 );
3828 }
3829 other => panic!("expected InvalidUri error, got: {other:?}"),
3830 }
3831 }
3832
3833 #[test]
3834 fn rejects_cookie_handling_disabled() {
3835 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
3836 match result {
3837 Err(CamelError::InvalidUri(msg)) => {
3838 assert!(
3839 msg.contains("cookieHandling is not supported"),
3840 "expected rejection message, got: {msg}"
3841 );
3842 }
3843 other => panic!("expected InvalidUri error, got: {other:?}"),
3844 }
3845 }
3846
3847 #[test]
3848 fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
3849 let config = HttpConfig::default()
3850 .with_response_timeout_ms(999)
3851 .with_allow_internal(true)
3852 .with_blocked_hosts(vec!["evil.com".to_string()])
3853 .with_max_body_size(12345);
3854 let endpoint =
3855 HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
3856 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
3857 assert!(endpoint.allow_internal);
3858 assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
3859 assert_eq!(endpoint.max_body_size, 12345);
3860 }
3861
3862 #[test]
3863 fn test_from_uri_with_defaults_uri_overrides_config() {
3864 let config = HttpConfig::default()
3865 .with_response_timeout_ms(999)
3866 .with_allow_internal(true)
3867 .with_blocked_hosts(vec!["evil.com".to_string()])
3868 .with_max_body_size(12345);
3869 let endpoint = HttpEndpointConfig::from_uri_with_defaults(
3870 "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
3871 &config,
3872 )
3873 .unwrap();
3874 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
3875 assert!(!endpoint.allow_internal);
3876 assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
3877 assert_eq!(endpoint.max_body_size, 99);
3878 }
3879
3880 #[test]
3881 fn test_http_config_ok_status_range() {
3882 let config =
3883 HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
3884 assert_eq!(config.ok_status_code_range, (200, 204));
3885 }
3886
3887 #[test]
3888 fn test_http_config_wrong_scheme() {
3889 let result = HttpEndpointConfig::from_uri("file:/tmp");
3890 assert!(result.is_err());
3891 }
3892
3893 #[test]
3894 fn test_http_component_scheme() {
3895 let component = HttpComponent::new();
3896 assert_eq!(component.scheme(), "http");
3897 }
3898
3899 #[test]
3900 fn test_https_component_scheme() {
3901 let component = HttpsComponent::new();
3902 assert_eq!(component.scheme(), "https");
3903 }
3904
3905 #[test]
3906 fn test_http_endpoint_creates_consumer() {
3907 let component = HttpComponent::new();
3908 let ctx = NoOpComponentContext;
3909 let endpoint = component
3910 .create_endpoint("http://0.0.0.0:19100/test", &ctx)
3911 .unwrap();
3912 assert!(endpoint.create_consumer(rt()).is_ok());
3913 }
3914
3915 #[test]
3916 fn test_https_endpoint_creates_consumer_errors_without_tls() {
3917 let component = HttpsComponent::new();
3918 let ctx = NoOpComponentContext;
3919 let endpoint = component
3920 .create_endpoint("https://0.0.0.0:8443/test", &ctx)
3921 .unwrap();
3922 assert!(endpoint.create_consumer(rt()).is_err());
3924 }
3925
3926 #[test]
3927 fn test_http_endpoint_creates_producer() {
3928 let ctx = test_producer_ctx();
3929 let component = HttpComponent::new();
3930 let endpoint_ctx = NoOpComponentContext;
3931 let endpoint = component
3932 .create_endpoint("http://localhost/api", &endpoint_ctx)
3933 .unwrap();
3934 assert!(endpoint.create_producer(rt(), &ctx).is_ok());
3935 }
3936
3937 #[tokio::test]
3942 async fn test_producer_with_token_provider() {
3943 use camel_auth::oauth2::TokenProvider;
3944 use tower::ServiceExt;
3945
3946 let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
3947 Arc::new(std::sync::Mutex::new(None));
3948 let captured_clone = Arc::clone(&captured_auth);
3949
3950 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3951 let port = listener.local_addr().unwrap().port();
3952
3953 let _handle = tokio::spawn(async move {
3954 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3955 if let Ok((mut stream, _)) = listener.accept().await {
3956 let mut buf = vec![0u8; 8192];
3957 let n = stream.read(&mut buf).await.unwrap_or(0);
3958 let request = String::from_utf8_lossy(&buf[..n]).to_string();
3959 let auth = request
3960 .lines()
3961 .find(|l| l.to_lowercase().starts_with("authorization:"))
3962 .map(|l| {
3963 l.split(':')
3964 .nth(1)
3965 .map(|s| s.trim().to_string())
3966 .unwrap_or_default()
3967 });
3968 *captured_clone.lock().unwrap() = auth;
3969 let body = r#"{"echo":"ok"}"#;
3970 let resp = format!(
3971 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
3972 body.len(),
3973 body
3974 );
3975 let _ = stream.write_all(resp.as_bytes()).await;
3976 }
3977 });
3978
3979 #[derive(Debug)]
3980 struct StaticProvider;
3981 #[async_trait::async_trait]
3982 impl TokenProvider for StaticProvider {
3983 async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
3984 Ok("injected-token".into())
3985 }
3986 }
3987
3988 let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
3989 let ctx = test_producer_ctx();
3990 let component = HttpComponent::new();
3991 let endpoint_ctx = NoOpComponentContext;
3992 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
3993 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3994
3995 let exchange = Exchange::new(Message::new("hello"));
3996
3997 let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
3998 let mut layered = layer.layer(producer);
3999 let result = layered.ready().await.unwrap().call(exchange).await;
4000 assert!(result.is_ok(), "producer call failed: {:?}", result);
4001
4002 tokio::time::sleep(Duration::from_millis(100)).await;
4003 let auth = captured_auth.lock().unwrap().take();
4004 assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
4005 }
4006
4007 async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
4008 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4009 let addr = listener.local_addr().unwrap();
4010 let url = format!("http://127.0.0.1:{}", addr.port());
4011
4012 let handle = tokio::spawn(async move {
4013 loop {
4014 if let Ok((mut stream, _)) = listener.accept().await {
4015 tokio::spawn(async move {
4016 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4017 let mut buf = vec![0u8; 4096];
4018 let n = stream.read(&mut buf).await.unwrap_or(0);
4019 let request = String::from_utf8_lossy(&buf[..n]).to_string();
4020
4021 let method = request.split_whitespace().next().unwrap_or("GET");
4022
4023 let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
4024 let response = format!(
4025 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
4026 body.len(),
4027 body
4028 );
4029 let _ = stream.write_all(response.as_bytes()).await;
4030 });
4031 }
4032 }
4033 });
4034
4035 (url, handle)
4036 }
4037
4038 async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
4039 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4040 let addr = listener.local_addr().unwrap();
4041 let url = format!("http://127.0.0.1:{}", addr.port());
4042
4043 let handle = tokio::spawn(async move {
4044 loop {
4045 if let Ok((mut stream, _)) = listener.accept().await {
4046 let status = status;
4047 tokio::spawn(async move {
4048 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4049 let mut buf = vec![0u8; 4096];
4050 let _ = stream.read(&mut buf).await;
4051
4052 let status_text = match status {
4053 404 => "Not Found",
4054 500 => "Internal Server Error",
4055 _ => "Error",
4056 };
4057 let body = "error body";
4058 let response = format!(
4059 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
4060 status,
4061 status_text,
4062 body.len(),
4063 body
4064 );
4065 let _ = stream.write_all(response.as_bytes()).await;
4066 });
4067 }
4068 }
4069 });
4070
4071 (url, handle)
4072 }
4073
4074 async fn start_request_capturing_server() -> (
4075 String,
4076 Arc<std::sync::Mutex<Option<String>>>,
4077 tokio::task::JoinHandle<()>,
4078 ) {
4079 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4080 let port = listener.local_addr().unwrap().port();
4081 let url = format!("http://127.0.0.1:{port}");
4082 let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
4083 let captured_clone = Arc::clone(&captured);
4084 let handle = tokio::spawn(async move {
4085 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4086 if let Ok((mut stream, _)) = listener.accept().await {
4087 let mut buf = vec![0u8; 16384];
4088 let n = stream.read(&mut buf).await.unwrap_or(0);
4089 let request = String::from_utf8_lossy(&buf[..n]).to_string();
4090 if request.contains("\r\n\r\n") {
4091 *captured_clone.lock().unwrap() = Some(request);
4092 }
4093 let body = r#"{"echo":"ok"}"#;
4094 let resp = format!(
4095 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
4096 body.len(),
4097 body
4098 );
4099 let _ = stream.write_all(resp.as_bytes()).await;
4100 }
4101 });
4102 (url, captured, handle)
4103 }
4104
4105 #[tokio::test]
4106 async fn test_http_producer_get_request() {
4107 use tower::ServiceExt;
4108
4109 let (url, _handle) = start_test_server().await;
4110 let ctx = test_producer_ctx();
4111
4112 let component = HttpComponent::new();
4113 let endpoint_ctx = NoOpComponentContext;
4114 let endpoint = component
4115 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4116 .unwrap();
4117 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4118
4119 let exchange = Exchange::new(Message::default());
4120 let result = producer.oneshot(exchange).await.unwrap();
4121
4122 let status = result
4123 .input
4124 .header("CamelHttpResponseCode")
4125 .and_then(|v| v.as_u64())
4126 .unwrap();
4127 assert_eq!(status, 200);
4128
4129 assert!(!result.input.body.is_empty());
4130 }
4131
4132 #[tokio::test]
4133 async fn producer_excludes_host_and_framing() {
4134 use tower::ServiceExt;
4135
4136 let (url, captured, _handle) = start_request_capturing_server().await;
4137 let ctx = test_producer_ctx();
4138 let component = HttpComponent::new();
4139 let endpoint_ctx = NoOpComponentContext;
4140 let endpoint = component
4141 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4142 .unwrap();
4143 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4144
4145 let mut exchange = Exchange::new(Message::default());
4146 exchange.input.set_header("Host", "localhost");
4147 exchange.input.set_header("Content-Length", "42");
4148 exchange.input.set_header("Connection", "keep-alive");
4149 exchange.input.set_header("Upgrade", "h2c");
4150
4151 let result = producer.oneshot(exchange).await;
4152 assert!(result.is_ok(), "producer call failed: {:?}", result);
4153
4154 tokio::time::sleep(Duration::from_millis(100)).await;
4155 let request = captured
4156 .lock()
4157 .unwrap()
4158 .take()
4159 .expect("no outbound request captured");
4160 let lower = request.to_ascii_lowercase();
4161 assert!(
4162 !lower.contains("\r\nhost: localhost"),
4163 "forwarded Host: localhost must be stripped\n{request}"
4164 );
4165 assert!(
4166 !lower.contains("content-length: 42"),
4167 "exchange Content-Length must not be copied\n{request}"
4168 );
4169 assert!(
4170 !lower.lines().any(|l| l.starts_with("connection:")),
4171 "Connection header must not be forwarded\n{request}"
4172 );
4173 assert!(
4174 !lower.lines().any(|l| l.starts_with("upgrade:")),
4175 "Upgrade header must not be forwarded\n{request}"
4176 );
4177 let host_header = lower
4178 .lines()
4179 .find(|l| l.starts_with("host:"))
4180 .map(|l| l.split_once(':').map(|(_, v)| v).unwrap_or("").trim())
4181 .expect("outbound Host header must be set by reqwest");
4182 assert!(
4183 host_header.starts_with("127.0.0.1:"),
4184 "outbound Host '{host_header}' must match the capture-server address"
4185 );
4186 }
4187
4188 #[tokio::test]
4189 async fn producer_forwards_request_only_headers() {
4190 use tower::ServiceExt;
4191
4192 let (url, captured, _handle) = start_request_capturing_server().await;
4193 let ctx = test_producer_ctx();
4194 let component = HttpComponent::new();
4195 let endpoint_ctx = NoOpComponentContext;
4196 let endpoint = component
4197 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4198 .unwrap();
4199 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4200
4201 let mut exchange = Exchange::new(Message::default());
4202 exchange.input.set_header("Accept", "application/json");
4203 exchange.input.set_header("User-Agent", "myclient/1.0");
4204
4205 let result = producer.oneshot(exchange).await;
4206 assert!(result.is_ok(), "producer call failed: {:?}", result);
4207
4208 tokio::time::sleep(Duration::from_millis(100)).await;
4209 let request = captured
4210 .lock()
4211 .unwrap()
4212 .take()
4213 .expect("no outbound request captured");
4214 let lower = request.to_ascii_lowercase();
4215 assert!(
4216 lower.contains("accept: application/json"),
4217 "request-only Accept header must be forwarded\n{request}"
4218 );
4219 assert!(
4220 lower.contains("user-agent: myclient/1.0"),
4221 "request-only User-Agent header must be forwarded\n{request}"
4222 );
4223 }
4224
4225 fn endpoint_with_config_overrides(
4234 base_url: &str,
4235 user_agent: Option<String>,
4236 auth: HttpAuth,
4237 ) -> HttpEndpoint {
4238 let uri = format!("{base_url}/api/test?allowInternal=true");
4239 let mut config =
4240 HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses");
4241 config.user_agent = user_agent;
4242 config.auth = auth;
4243 HttpEndpoint {
4244 uri: uri.clone(),
4245 config,
4246 server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
4247 client: reqwest::Client::new(),
4248 pinned_cache: Arc::new(PinnedClientCache::new(
4249 PINNED_CLIENT_TTL,
4250 PINNED_CLIENT_MAX_ENTRIES,
4251 )),
4252 http_config: HttpConfig::default(),
4253 }
4254 }
4255
4256 #[tracing_test::traced_test]
4261 #[tokio::test]
4262 async fn producer_invalid_configured_headers_surfaced() {
4263 use tower::ServiceExt;
4264
4265 let (bad_url, bad_captured, _bad_handle) = start_request_capturing_server().await;
4266 let (ok_url, ok_captured, _ok_handle) = start_request_capturing_server().await;
4267 let ctx = test_producer_ctx();
4268
4269 let bad_producer = endpoint_with_config_overrides(
4270 &bad_url,
4271 Some("bad\r\nua".to_string()),
4272 HttpAuth::Bearer {
4273 token: "tok\r\nen".to_string(),
4274 },
4275 )
4276 .create_producer(rt(), &ctx)
4277 .unwrap();
4278 let ok_producer = endpoint_with_config_overrides(
4279 &ok_url,
4280 Some("httpsweep-ok/1".to_string()),
4281 HttpAuth::Bearer {
4282 token: "valid-token".to_string(),
4283 },
4284 )
4285 .create_producer(rt(), &ctx)
4286 .unwrap();
4287
4288 let bad_exchange = Exchange::new(Message::default());
4289 let ok_exchange = Exchange::new(Message::default());
4290 let bad_cid = bad_exchange.correlation_id().to_string();
4291 let ok_cid = ok_exchange.correlation_id().to_string();
4292
4293 let bad_result = bad_producer.oneshot(bad_exchange).await;
4294 assert!(
4295 bad_result.is_ok(),
4296 "invalid-config producer call failed: {bad_result:?}"
4297 );
4298 let ok_result = ok_producer.oneshot(ok_exchange).await;
4299 assert!(
4300 ok_result.is_ok(),
4301 "valid-config producer call failed: {ok_result:?}"
4302 );
4303
4304 tokio::time::sleep(Duration::from_millis(100)).await;
4305 let bad_request = bad_captured
4306 .lock()
4307 .unwrap()
4308 .take()
4309 .expect("no outbound request captured");
4310 let ok_request = ok_captured
4311 .lock()
4312 .unwrap()
4313 .take()
4314 .expect("no outbound request captured");
4315
4316 let bad_lower = bad_request.to_ascii_lowercase();
4319 assert!(
4320 !bad_lower.lines().any(|l| l.starts_with("authorization:")),
4321 "invalid Bearer token must not reach the wire\n{bad_request}"
4322 );
4323 assert!(
4324 !bad_request.contains("bad\r\nua"),
4325 "invalid configured user-agent must not reach the wire\n{bad_request}"
4326 );
4327
4328 logs_assert(|lines: &[&str]| {
4329 let drops: Vec<&&str> = lines
4330 .iter()
4331 .filter(|l| {
4332 l.contains("outbound header dropped")
4333 && l.contains(&format!("correlation_id={bad_cid}"))
4334 })
4335 .collect();
4336 if drops.len() != 2 {
4337 return Err(format!(
4338 "expected exactly 2 drop records for {bad_cid}, found {}",
4339 drops.len()
4340 ));
4341 }
4342 let has_ua = drops.iter().any(|l| l.contains("header=user-agent"));
4343 let has_auth = drops.iter().any(|l| l.contains("header=authorization"));
4344 let reason_ok = drops
4345 .iter()
4346 .all(|l| l.contains("outbound header dropped: invalid header value"));
4347 match (has_ua, has_auth, reason_ok) {
4348 (true, true, true) => Ok(()),
4349 _ => Err(format!(
4350 "drop records mismatched: user-agent={has_ua} \
4351 authorization={has_auth} reason-ok={reason_ok}"
4352 )),
4353 }
4354 });
4355 logs_assert(|lines: &[&str]| {
4356 if lines
4357 .iter()
4358 .any(|l| l.contains("bad\r\nua") || l.contains("tok\r\nen"))
4359 {
4360 Err("sentinel CRLF values leaked into logs".to_string())
4361 } else {
4362 Ok(())
4363 }
4364 });
4365
4366 let ok_lower = ok_request.to_ascii_lowercase();
4369 assert!(
4370 ok_lower.contains("user-agent: httpsweep-ok/1"),
4371 "valid configured user-agent must reach the wire\n{ok_request}"
4372 );
4373 assert!(
4374 ok_lower.contains("authorization: bearer valid-token"),
4375 "valid Bearer token must reach the wire\n{ok_request}"
4376 );
4377 logs_assert(|lines: &[&str]| {
4378 let hits = lines
4379 .iter()
4380 .filter(|l| {
4381 l.contains("outbound header dropped")
4382 && l.contains(&format!("correlation_id={ok_cid}"))
4383 })
4384 .count();
4385 match hits {
4386 0 => Ok(()),
4387 n => Err(format!("expected no drop records for {ok_cid}, found {n}")),
4388 }
4389 });
4390 }
4391
4392 #[tokio::test]
4393 async fn producer_honours_skip_request_headers() {
4394 use tower::ServiceExt;
4395
4396 let (url, captured, _handle) = start_request_capturing_server().await;
4397 let ctx = test_producer_ctx();
4398 let component = HttpComponent::new();
4399 let endpoint_ctx = NoOpComponentContext;
4400 let endpoint = component
4401 .create_endpoint(
4402 &format!("{url}/api/test?allowInternal=true&skipRequestHeaders=Authorization"),
4403 &endpoint_ctx,
4404 )
4405 .unwrap();
4406 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4407
4408 let mut exchange = Exchange::new(Message::default());
4409 exchange.input.set_header("Authorization", "Bearer x");
4410
4411 let result = producer.oneshot(exchange).await;
4412 assert!(result.is_ok(), "producer call failed: {:?}", result);
4413
4414 tokio::time::sleep(Duration::from_millis(100)).await;
4415 let request = captured
4416 .lock()
4417 .unwrap()
4418 .take()
4419 .expect("no outbound request captured");
4420 assert!(
4421 !request.to_ascii_lowercase().contains("authorization"),
4422 "Authorization must be stripped by skipRequestHeaders\n{request}"
4423 );
4424 }
4425
4426 #[tokio::test]
4427 async fn producer_stringifies_scalar_header_values_on_wire() {
4428 use tower::ServiceExt;
4429
4430 let (url, captured, _handle) = start_request_capturing_server().await;
4431 let ctx = test_producer_ctx();
4432 let component = HttpComponent::new();
4433 let endpoint_ctx = NoOpComponentContext;
4434 let endpoint = component
4435 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4436 .unwrap();
4437 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4438
4439 let mut exchange = Exchange::new(Message::default());
4440 exchange.input.set_header("X-Retries", serde_json::json!(3));
4441 exchange
4442 .input
4443 .set_header("X-Enabled", serde_json::json!(true));
4444 exchange
4445 .input
4446 .set_header("X-Obj", serde_json::json!({"a": 1}));
4447
4448 let result = producer.oneshot(exchange).await;
4449 assert!(result.is_ok(), "producer call failed: {:?}", result);
4450
4451 tokio::time::sleep(Duration::from_millis(100)).await;
4452 let request = captured
4453 .lock()
4454 .unwrap()
4455 .take()
4456 .expect("no outbound request captured");
4457 let lower = request.to_ascii_lowercase();
4458 assert!(
4459 lower.contains("x-retries: 3"),
4460 "numeric header must reach the wire stringified\n{request}"
4461 );
4462 assert!(
4463 lower.contains("x-enabled: true"),
4464 "bool header must reach the wire stringified\n{request}"
4465 );
4466 assert!(
4467 !lower.contains("x-obj:"),
4468 "object header has no single-value form and must not reach the wire\n{request}"
4469 );
4470 }
4471
4472 #[tokio::test]
4473 async fn test_http_producer_post_with_body() {
4474 use tower::ServiceExt;
4475
4476 let (url, _handle) = start_test_server().await;
4477 let ctx = test_producer_ctx();
4478
4479 let component = HttpComponent::new();
4480 let endpoint_ctx = NoOpComponentContext;
4481 let endpoint = component
4482 .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
4483 .unwrap();
4484 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4485
4486 let exchange = Exchange::new(Message::new("request body"));
4487 let result = producer.oneshot(exchange).await.unwrap();
4488
4489 let status = result
4490 .input
4491 .header("CamelHttpResponseCode")
4492 .and_then(|v| v.as_u64())
4493 .unwrap();
4494 assert_eq!(status, 200);
4495 }
4496
4497 #[tokio::test]
4498 async fn test_http_producer_method_from_header() {
4499 use tower::ServiceExt;
4500
4501 let (url, _handle) = start_test_server().await;
4502 let ctx = test_producer_ctx();
4503
4504 let component = HttpComponent::new();
4505 let endpoint_ctx = NoOpComponentContext;
4506 let endpoint = component
4507 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4508 .unwrap();
4509 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4510
4511 let mut exchange = Exchange::new(Message::default());
4512 exchange.input.set_header(
4513 "CamelHttpMethod",
4514 serde_json::Value::String("DELETE".to_string()),
4515 );
4516
4517 let result = producer.oneshot(exchange).await.unwrap();
4518 let status = result
4519 .input
4520 .header("CamelHttpResponseCode")
4521 .and_then(|v| v.as_u64())
4522 .unwrap();
4523 assert_eq!(status, 200);
4524 }
4525
4526 #[tokio::test]
4527 async fn test_http_producer_forced_method() {
4528 use tower::ServiceExt;
4529
4530 let (url, _handle) = start_test_server().await;
4531 let ctx = test_producer_ctx();
4532
4533 let component = HttpComponent::new();
4534 let endpoint_ctx = NoOpComponentContext;
4535 let endpoint = component
4536 .create_endpoint(
4537 &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
4538 &endpoint_ctx,
4539 )
4540 .unwrap();
4541 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4542
4543 let exchange = Exchange::new(Message::default());
4544 let result = producer.oneshot(exchange).await.unwrap();
4545
4546 let status = result
4547 .input
4548 .header("CamelHttpResponseCode")
4549 .and_then(|v| v.as_u64())
4550 .unwrap();
4551 assert_eq!(status, 200);
4552 }
4553
4554 #[tokio::test]
4555 async fn test_http_producer_throw_exception_on_failure() {
4556 use tower::ServiceExt;
4557
4558 let (url, _handle) = start_status_server(404).await;
4559 let ctx = test_producer_ctx();
4560
4561 let component = HttpComponent::new();
4562 let endpoint_ctx = NoOpComponentContext;
4563 let endpoint = component
4564 .create_endpoint(
4565 &format!("{url}/not-found?allowInternal=true"),
4566 &endpoint_ctx,
4567 )
4568 .unwrap();
4569 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4570
4571 let exchange = Exchange::new(Message::default());
4572 let result = producer.oneshot(exchange).await;
4573 assert!(result.is_err());
4574
4575 match result.unwrap_err() {
4576 CamelError::HttpOperationFailed { status_code, .. } => {
4577 assert_eq!(status_code, 404);
4578 }
4579 e => panic!("Expected HttpOperationFailed, got: {e}"),
4580 }
4581 }
4582
4583 #[tokio::test]
4584 async fn test_http_producer_no_throw_on_failure() {
4585 use tower::ServiceExt;
4586
4587 let (url, _handle) = start_status_server(500).await;
4588 let ctx = test_producer_ctx();
4589
4590 let component = HttpComponent::new();
4591 let endpoint_ctx = NoOpComponentContext;
4592 let endpoint = component
4593 .create_endpoint(
4594 &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
4595 &endpoint_ctx,
4596 )
4597 .unwrap();
4598 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4599
4600 let exchange = Exchange::new(Message::default());
4601 let result = producer.oneshot(exchange).await.unwrap();
4602
4603 let status = result
4604 .input
4605 .header("CamelHttpResponseCode")
4606 .and_then(|v| v.as_u64())
4607 .unwrap();
4608 assert_eq!(status, 500);
4609 }
4610
4611 #[tokio::test]
4612 async fn test_http_producer_uri_override() {
4613 use tower::ServiceExt;
4614
4615 let (url, _handle) = start_test_server().await;
4616 let ctx = test_producer_ctx();
4617
4618 let component = HttpComponent::new();
4619 let endpoint_ctx = NoOpComponentContext;
4620 let endpoint = component
4621 .create_endpoint(
4622 "http://localhost:1/does-not-exist?allowInternal=true",
4623 &endpoint_ctx,
4624 )
4625 .unwrap();
4626 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4627
4628 let mut exchange = Exchange::new(Message::default());
4629 exchange.input.set_header(
4630 "CamelHttpUri",
4631 serde_json::Value::String(format!("{url}/api")),
4632 );
4633
4634 let result = producer.oneshot(exchange).await.unwrap();
4635 let status = result
4636 .input
4637 .header("CamelHttpResponseCode")
4638 .and_then(|v| v.as_u64())
4639 .unwrap();
4640 assert_eq!(status, 200);
4641 }
4642
4643 #[tokio::test]
4644 async fn test_http_producer_response_headers_mapped() {
4645 use tower::ServiceExt;
4646
4647 let (url, _handle) = start_test_server().await;
4648 let ctx = test_producer_ctx();
4649
4650 let component = HttpComponent::new();
4651 let endpoint_ctx = NoOpComponentContext;
4652 let endpoint = component
4653 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4654 .unwrap();
4655 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4656
4657 let exchange = Exchange::new(Message::default());
4658 let result = producer.oneshot(exchange).await.unwrap();
4659
4660 assert!(
4661 result.input.header("Content-Type").is_some(),
4662 "Response should have Content-Type header"
4663 );
4664 assert!(result.input.header("CamelHttpResponseText").is_some());
4665 }
4666
4667 async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
4672 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4673 let addr = listener.local_addr().unwrap();
4674 let url = format!("http://127.0.0.1:{}", addr.port());
4675
4676 let handle = tokio::spawn(async move {
4677 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4678 loop {
4679 if let Ok((mut stream, _)) = listener.accept().await {
4680 tokio::spawn(async move {
4681 let mut buf = vec![0u8; 4096];
4682 let n = stream.read(&mut buf).await.unwrap_or(0);
4683 let request = String::from_utf8_lossy(&buf[..n]).to_string();
4684
4685 if request.contains("GET /final") {
4687 let body = r#"{"status":"final"}"#;
4688 let response = format!(
4689 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
4690 body.len(),
4691 body
4692 );
4693 let _ = stream.write_all(response.as_bytes()).await;
4694 } else {
4695 let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
4700 let _ = stream.write_all(response.as_bytes()).await;
4701 }
4702 });
4703 }
4704 }
4705 });
4706
4707 (url, handle)
4708 }
4709
4710 struct CapturedRequest {
4711 method: String,
4712 path: String,
4713 body: Vec<u8>,
4714 content_length: Option<String>,
4715 transfer_encoding: Option<String>,
4716 }
4717
4718 async fn capture_request(stream: &mut tokio::net::TcpStream) -> Option<CapturedRequest> {
4724 use tokio::io::AsyncReadExt;
4725
4726 let mut buf: Vec<u8> = Vec::new();
4728 let mut chunk = [0u8; 4096];
4729 let head_end: usize;
4730 loop {
4731 let n = stream.read(&mut chunk).await.unwrap_or(0);
4732 if n == 0 {
4733 return None;
4734 }
4735 buf.extend_from_slice(&chunk[..n]);
4736 if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
4737 head_end = pos + 4;
4738 break;
4739 }
4740 }
4741
4742 let head = String::from_utf8_lossy(&buf[..head_end]);
4744 let mut lines = head.split("\r\n");
4745 let request_line = lines.next().unwrap_or("");
4746 let mut parts = request_line.split_whitespace();
4747 let method = parts.next().unwrap_or("").to_string();
4748 let path = parts.next().unwrap_or("").to_string();
4749
4750 let mut content_length: Option<String> = None;
4751 let mut transfer_encoding: Option<String> = None;
4752 for line in lines {
4753 if let Some((name, value)) = line.split_once(':') {
4754 let name = name.trim().to_ascii_lowercase();
4755 let value = value.trim().to_string();
4756 if name == "content-length" {
4757 content_length = Some(value);
4758 } else if name == "transfer-encoding" {
4759 transfer_encoding = Some(value);
4760 }
4761 }
4762 }
4763
4764 let body_len: usize = content_length
4766 .as_deref()
4767 .and_then(|v| v.parse::<usize>().ok())
4768 .unwrap_or(0);
4769
4770 let mut body: Vec<u8> = buf[head_end..].to_vec();
4771 while body.len() < body_len {
4772 let n = stream.read(&mut chunk).await.unwrap_or(0);
4773 if n == 0 {
4774 break;
4775 }
4776 body.extend_from_slice(&chunk[..n]);
4777 }
4778 body.truncate(body_len);
4779
4780 Some(CapturedRequest {
4781 method,
4782 path,
4783 body,
4784 content_length,
4785 transfer_encoding,
4786 })
4787 }
4788
4789 async fn start_capture_server() -> (
4794 String,
4795 tokio::task::JoinHandle<()>,
4796 Arc<Mutex<Vec<CapturedRequest>>>,
4797 ) {
4798 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4799 let addr = listener.local_addr().unwrap();
4800 let url = format!("http://127.0.0.1:{}", addr.port());
4801
4802 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
4803 let captured_for_return = Arc::clone(&captured);
4804
4805 let handle = tokio::spawn(async move {
4806 use tokio::io::AsyncWriteExt;
4807 loop {
4808 if let Ok((mut stream, _)) = listener.accept().await {
4809 let captured = Arc::clone(&captured);
4810 tokio::spawn(async move {
4811 let Some(req) = capture_request(&mut stream).await else {
4812 return;
4813 };
4814 captured.lock().unwrap().push(req);
4815
4816 let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
4819 let _ = stream.write_all(response.as_bytes()).await;
4820 });
4821 }
4822 }
4823 });
4824
4825 (url, handle, captured_for_return)
4826 }
4827
4828 async fn start_redirect_capture_server() -> (
4834 String,
4835 tokio::task::JoinHandle<()>,
4836 Arc<Mutex<Vec<CapturedRequest>>>,
4837 ) {
4838 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4839 let addr = listener.local_addr().unwrap();
4840 let url = format!("http://127.0.0.1:{}", addr.port());
4841
4842 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
4843 let captured_for_return = Arc::clone(&captured);
4844
4845 let handle = tokio::spawn(async move {
4846 use tokio::io::AsyncWriteExt;
4847 loop {
4848 if let Ok((mut stream, _)) = listener.accept().await {
4849 let captured = Arc::clone(&captured);
4850 tokio::spawn(async move {
4851 let Some(req) = capture_request(&mut stream).await else {
4852 return;
4853 };
4854 let path = req.path.clone();
4855 captured.lock().unwrap().push(req);
4856
4857 let (status_line, location) = match path.as_str() {
4858 "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
4859 "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
4860 "/final" => ("HTTP/1.1 200 OK", None),
4861 _ => ("HTTP/1.1 404 Not Found", None),
4862 };
4863
4864 let response = match location {
4865 Some(loc) => format!(
4869 "{status_line}\r\nLocation: {loc}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
4870 ),
4871 None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
4872 };
4873 let _ = stream.write_all(response.as_bytes()).await;
4874 });
4875 }
4876 }
4877 });
4878
4879 (url, handle, captured_for_return)
4880 }
4881
4882 #[tokio::test]
4883 async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
4884 use tower::ServiceExt;
4885
4886 let (url, _handle, captured) = start_capture_server().await;
4887 let ctx = test_producer_ctx();
4888
4889 let component = HttpComponent::with_config(HttpConfig::default());
4890 let endpoint_ctx = NoOpComponentContext;
4891 let endpoint = component
4892 .create_endpoint(
4893 &format!("{url}?httpMethod=GET&allowInternal=true"),
4894 &endpoint_ctx,
4895 )
4896 .unwrap();
4897 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4898
4899 let mut exchange = Exchange::new(Message::default());
4900 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
4901
4902 let result = producer.oneshot(exchange).await.unwrap();
4903
4904 let status = result
4905 .input
4906 .header("CamelHttpResponseCode")
4907 .and_then(|v| v.as_u64())
4908 .unwrap();
4909 assert_eq!(status, 200);
4910
4911 let captured = captured.lock().unwrap();
4912 assert_eq!(captured.len(), 1, "expected exactly one captured request");
4913 let req = &captured[0];
4914 assert_eq!(req.method, "GET");
4915 assert_eq!(req.path, "/");
4918 assert!(req.body.is_empty(), "GET must not carry a body");
4919 assert!(
4920 req.content_length.is_none(),
4921 "suppressed request must not carry Content-Length"
4922 );
4923 assert!(
4924 req.transfer_encoding.is_none(),
4925 "suppressed request must not carry Transfer-Encoding"
4926 );
4927
4928 assert!(
4930 result.input.body.is_empty(),
4931 "exchange body must be consumed"
4932 );
4933 }
4934
4935 #[tokio::test]
4936 async fn test_head_with_body_suppressed_via_header() {
4937 use tower::ServiceExt;
4938
4939 let (url, _handle, captured) = start_capture_server().await;
4940 let ctx = test_producer_ctx();
4941
4942 let component = HttpComponent::with_config(HttpConfig::default());
4943 let endpoint_ctx = NoOpComponentContext;
4944 let endpoint = component
4945 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
4946 .unwrap();
4947 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4948
4949 let mut exchange = Exchange::new(Message::default());
4950 exchange.input.set_header(
4951 "CamelHttpMethod",
4952 serde_json::Value::String("HEAD".to_string()),
4953 );
4954 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
4955
4956 let result = producer.oneshot(exchange).await.unwrap();
4957 let status = result
4958 .input
4959 .header("CamelHttpResponseCode")
4960 .and_then(|v| v.as_u64())
4961 .unwrap();
4962 assert_eq!(status, 200);
4963
4964 let captured = captured.lock().unwrap();
4965 assert_eq!(captured.len(), 1);
4966 let req = &captured[0];
4967 assert_eq!(req.method, "HEAD");
4968 assert!(req.body.is_empty(), "HEAD must not carry a body");
4969 }
4970
4971 #[tokio::test]
4972 async fn test_delete_options_trace_with_body_suppressed() {
4973 use tower::ServiceExt;
4974
4975 let (url, _handle, captured) = start_capture_server().await;
4976 let ctx = test_producer_ctx();
4977 let component = HttpComponent::with_config(HttpConfig::default());
4978 let endpoint_ctx = NoOpComponentContext;
4979
4980 for method in ["DELETE", "OPTIONS", "TRACE"] {
4981 let endpoint = component
4982 .create_endpoint(
4983 &format!("{url}?httpMethod={method}&allowInternal=true"),
4984 &endpoint_ctx,
4985 )
4986 .unwrap();
4987 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4988
4989 let mut exchange = Exchange::new(Message::default());
4990 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
4991
4992 let result = producer.oneshot(exchange).await.unwrap();
4993 let status = result
4994 .input
4995 .header("CamelHttpResponseCode")
4996 .and_then(|v| v.as_u64())
4997 .unwrap();
4998 assert_eq!(status, 200, "method {method} should succeed");
4999 }
5000
5001 let captured = captured.lock().unwrap();
5002 assert_eq!(captured.len(), 3, "expected three captured requests");
5003 for method in ["DELETE", "OPTIONS", "TRACE"] {
5004 let req = captured
5005 .iter()
5006 .find(|r| r.method == method)
5007 .unwrap_or_else(|| panic!("missing captured request for {method}"));
5008 assert!(req.body.is_empty(), "{} must not carry a body", req.method);
5009 }
5010 }
5011
5012 #[tokio::test]
5013 async fn test_post_put_patch_with_body_still_sent() {
5014 use tower::ServiceExt;
5015
5016 let (url, _handle, captured) = start_capture_server().await;
5017 let ctx = test_producer_ctx();
5018 let component = HttpComponent::with_config(HttpConfig::default());
5019 let endpoint_ctx = NoOpComponentContext;
5020
5021 for method in ["POST", "PUT", "PATCH"] {
5022 let endpoint = component
5023 .create_endpoint(
5024 &format!("{url}?httpMethod={method}&allowInternal=true"),
5025 &endpoint_ctx,
5026 )
5027 .unwrap();
5028 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5029
5030 let payload = format!("body-for-{method}");
5031 let mut exchange = Exchange::new(Message::default());
5032 exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
5033
5034 let result = producer.oneshot(exchange).await.unwrap();
5035 let status = result
5036 .input
5037 .header("CamelHttpResponseCode")
5038 .and_then(|v| v.as_u64())
5039 .unwrap();
5040 assert_eq!(status, 200, "method {method} should succeed");
5041 }
5042
5043 let captured = captured.lock().unwrap();
5044 assert_eq!(captured.len(), 3, "expected three captured requests");
5045 for method in ["POST", "PUT", "PATCH"] {
5046 let req = captured
5047 .iter()
5048 .find(|r| r.method == method)
5049 .unwrap_or_else(|| panic!("missing captured request for {method}"));
5050 let expected = format!("body-for-{method}");
5051 assert!(!req.body.is_empty(), "{method} must still carry its body");
5052 assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
5053 }
5054 }
5055
5056 #[tokio::test]
5060 async fn test_stream_body_under_get_not_attached() {
5061 use tower::ServiceExt;
5062
5063 let (url, _handle, captured) = start_capture_server().await;
5064 let ctx = test_producer_ctx();
5065
5066 let component = HttpComponent::with_config(HttpConfig::default());
5067 let endpoint_ctx = NoOpComponentContext;
5068 let endpoint = component
5069 .create_endpoint(
5070 &format!("{url}?httpMethod=GET&allowInternal=true"),
5071 &endpoint_ctx,
5072 )
5073 .unwrap();
5074 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5075
5076 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
5077 vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
5078 let stream = Box::pin(futures::stream::iter(chunks));
5079 let mut exchange = Exchange::new(Message::default());
5080 exchange.input.body = Body::Stream(StreamBody {
5081 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
5082 metadata: StreamMetadata::default(),
5083 });
5084
5085 let result = producer.oneshot(exchange).await.unwrap();
5086
5087 let status = result
5088 .input
5089 .header("CamelHttpResponseCode")
5090 .and_then(|v| v.as_u64())
5091 .unwrap();
5092 assert_eq!(status, 200);
5093
5094 let captured = captured.lock().unwrap();
5095 assert_eq!(captured.len(), 1, "expected exactly one captured request");
5096 assert!(
5097 captured[0].body.is_empty(),
5098 "GET must not carry a stream body"
5099 );
5100 assert!(
5101 captured[0].transfer_encoding.is_none(),
5102 "suppressed request must not carry Transfer-Encoding"
5103 );
5104 assert!(
5105 captured[0].content_length.is_none(),
5106 "suppressed request must not carry Content-Length"
5107 );
5108 assert!(
5109 result.input.body.is_empty(),
5110 "exchange body must be consumed to Empty, not left as a stream"
5111 );
5112 }
5113
5114 #[tokio::test]
5118 async fn test_redirect_hops_never_replay_suppressed_body() {
5119 use tower::ServiceExt;
5120
5121 let (url, _handle, captured) = start_redirect_capture_server().await;
5122 let ctx = test_producer_ctx();
5123
5124 let component =
5125 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5126 let endpoint_ctx = NoOpComponentContext;
5127
5128 for path in ["/hop307", "/hop308"] {
5129 let endpoint = component
5130 .create_endpoint(
5131 &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
5132 &endpoint_ctx,
5133 )
5134 .unwrap();
5135 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5136
5137 let mut exchange = Exchange::new(Message::default());
5138 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5139
5140 let result = producer.oneshot(exchange).await.unwrap();
5141 let status = result
5142 .input
5143 .header("CamelHttpResponseCode")
5144 .and_then(|v| v.as_u64())
5145 .unwrap();
5146 assert_eq!(
5147 status, 200,
5148 "redirect chain for {path} should end at /final"
5149 );
5150 }
5151
5152 let captured = captured.lock().unwrap();
5154 assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
5155 for req in captured.iter() {
5156 assert!(
5157 req.body.is_empty(),
5158 "hop {} {} must not carry a body",
5159 req.method,
5160 req.path
5161 );
5162 }
5163 }
5164
5165 #[tracing_test::traced_test]
5174 #[tokio::test]
5175 async fn test_suppressed_body_logs_exactly_one_warn() {
5176 use tower::ServiceExt;
5177
5178 let (url, _handle, _captured) = start_capture_server().await;
5179 let ctx = test_producer_ctx();
5180
5181 let component = HttpComponent::with_config(HttpConfig::default());
5182 let endpoint_ctx = NoOpComponentContext;
5183 let endpoint = component
5184 .create_endpoint(
5185 &format!("{url}?httpMethod=GET&allowInternal=true"),
5186 &endpoint_ctx,
5187 )
5188 .unwrap();
5189 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5190
5191 let mut exchange = Exchange::new(Message::default());
5192 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5193 let correlation_id = exchange.correlation_id().to_string();
5194
5195 let result = producer.oneshot(exchange).await.unwrap();
5196 let status = result
5197 .input
5198 .header("CamelHttpResponseCode")
5199 .and_then(|v| v.as_u64())
5200 .unwrap();
5201 assert_eq!(status, 200);
5202
5203 logs_assert(|lines: &[&str]| {
5204 let hits = lines
5205 .iter()
5206 .filter(|l| {
5207 l.contains("dropping request body")
5208 && l.contains("method=GET")
5209 && l.contains(&format!("correlation_id={correlation_id}"))
5210 })
5211 .count();
5212 match hits {
5213 1 => Ok(()),
5214 n => Err(format!("expected exactly one body-drop warn, found {n}")),
5215 }
5216 });
5217 }
5218
5219 #[tracing_test::traced_test]
5220 #[tokio::test]
5221 async fn test_empty_body_get_emits_no_warn() {
5222 use tower::ServiceExt;
5223
5224 let (url, _handle, _captured) = start_capture_server().await;
5225 let ctx = test_producer_ctx();
5226
5227 let component = HttpComponent::with_config(HttpConfig::default());
5228 let endpoint_ctx = NoOpComponentContext;
5229 let endpoint = component
5230 .create_endpoint(
5231 &format!("{url}?httpMethod=GET&allowInternal=true"),
5232 &endpoint_ctx,
5233 )
5234 .unwrap();
5235 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5236
5237 let exchange = Exchange::new(Message::default());
5238 let result = producer.oneshot(exchange).await.unwrap();
5239 let status = result
5240 .input
5241 .header("CamelHttpResponseCode")
5242 .and_then(|v| v.as_u64())
5243 .unwrap();
5244 assert_eq!(status, 200);
5245
5246 logs_assert(|lines: &[&str]| {
5247 let hits = lines
5248 .iter()
5249 .filter(|l| l.contains("dropping request body"))
5250 .count();
5251 match hits {
5252 0 => Ok(()),
5253 n => Err(format!("expected no body-drop warn, found {n}")),
5254 }
5255 });
5256 }
5257
5258 #[tokio::test]
5259 async fn test_follow_redirects_false_does_not_follow() {
5260 use tower::ServiceExt;
5261
5262 let (url, _handle) = start_redirect_server().await;
5263 let ctx = test_producer_ctx();
5264
5265 let component =
5266 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
5267 let endpoint_ctx = NoOpComponentContext;
5268 let endpoint = component
5269 .create_endpoint(
5270 &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
5271 &endpoint_ctx,
5272 )
5273 .unwrap();
5274 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5275
5276 let exchange = Exchange::new(Message::default());
5277 let result = producer.oneshot(exchange).await.unwrap();
5278
5279 let status = result
5281 .input
5282 .header("CamelHttpResponseCode")
5283 .and_then(|v| v.as_u64())
5284 .unwrap();
5285 assert_eq!(
5286 status, 302,
5287 "Should NOT follow redirect when followRedirects=false"
5288 );
5289 }
5290
5291 #[tokio::test]
5292 async fn test_follow_redirects_true_follows_redirect() {
5293 use tower::ServiceExt;
5294
5295 let (url, _handle) = start_redirect_server().await;
5296 let ctx = test_producer_ctx();
5297
5298 let component =
5299 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5300 let endpoint_ctx = NoOpComponentContext;
5301 let endpoint = component
5302 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5303 .unwrap();
5304 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5305
5306 let exchange = Exchange::new(Message::default());
5307 let result = producer.oneshot(exchange).await.unwrap();
5308
5309 let status = result
5311 .input
5312 .header("CamelHttpResponseCode")
5313 .and_then(|v| v.as_u64())
5314 .unwrap();
5315 assert_eq!(
5316 status, 200,
5317 "Should follow redirect when followRedirects=true"
5318 );
5319 }
5320
5321 #[tokio::test]
5324 async fn test_redirect_to_private_ip_is_ssrf_blocked() {
5325 use tower::ServiceExt;
5326
5327 let (url, _handle) = start_redirect_server().await;
5329 let ctx = test_producer_ctx();
5330
5331 let component =
5332 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5333 let endpoint_ctx = NoOpComponentContext;
5334 let endpoint = component
5335 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5336 .unwrap();
5337 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5338
5339 let exchange = Exchange::new(Message::default());
5340 let result = producer.oneshot(exchange).await;
5341
5342 assert!(
5344 result.is_ok(),
5345 "Redirect should succeed with allowInternal=true, got: {:?}",
5346 result
5347 );
5348 let exchange = result.unwrap();
5349 let status = exchange
5350 .input
5351 .header("CamelHttpResponseCode")
5352 .and_then(|v| v.as_u64())
5353 .unwrap();
5354 assert_eq!(status, 200, "Should follow redirect to /final");
5355 }
5356
5357 #[tokio::test]
5359 async fn test_redirect_to_private_ip_allowed_when_configured() {
5360 use tower::ServiceExt;
5361
5362 let (url, _handle) = start_redirect_server().await;
5364 let ctx = test_producer_ctx();
5365
5366 let component =
5367 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5368 let endpoint_ctx = NoOpComponentContext;
5369 let endpoint = component
5370 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5371 .unwrap();
5372 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5373
5374 let exchange = Exchange::new(Message::default());
5375 let result = producer.oneshot(exchange).await.unwrap();
5376
5377 let status = result
5378 .input
5379 .header("CamelHttpResponseCode")
5380 .and_then(|v| v.as_u64())
5381 .unwrap();
5382 assert_eq!(
5383 status, 200,
5384 "Should follow redirect to private IP when allowInternal=true"
5385 );
5386 }
5387
5388 #[tokio::test]
5391 async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
5392 use tower::ServiceExt;
5393
5394 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5396 let addr = listener.local_addr().unwrap();
5397 let url = format!("http://127.0.0.1:{}", addr.port());
5398
5399 let handle = tokio::spawn(async move {
5400 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5401 loop {
5402 if let Ok((mut stream, _)) = listener.accept().await {
5403 tokio::spawn(async move {
5404 let mut buf = vec![0u8; 4096];
5405 let _ = stream.read(&mut buf).await;
5406 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";
5408 let _ = stream.write_all(response.as_bytes()).await;
5409 });
5410 }
5411 }
5412 });
5413
5414 let ctx = test_producer_ctx();
5415 let component =
5416 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5417 let endpoint_ctx = NoOpComponentContext;
5418 let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
5420 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5421
5422 let exchange = Exchange::new(Message::default());
5423 let result = producer.oneshot(exchange).await;
5424
5425 assert!(
5427 result.is_err(),
5428 "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
5429 );
5430 let err = result.unwrap_err().to_string();
5431 assert!(
5432 err.contains("blocked IP")
5433 || err.contains("private IP")
5434 || err.contains("SSRF")
5435 || err.contains("not allowed"),
5436 "Error should mention SSRF/IP blocking, got: {err}"
5437 );
5438
5439 handle.abort();
5440 }
5441
5442 #[tokio::test]
5444 async fn test_too_many_redirects_returns_error() {
5445 use tower::ServiceExt;
5446
5447 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5449 let addr = listener.local_addr().unwrap();
5450 let url = format!("http://127.0.0.1:{}", addr.port());
5451
5452 let handle = tokio::spawn(async move {
5453 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5454 loop {
5455 if let Ok((mut stream, _)) = listener.accept().await {
5456 tokio::spawn(async move {
5457 let mut buf = vec![0u8; 4096];
5458 let _ = stream.read(&mut buf).await;
5459 let response = "HTTP/1.1 302 Found\r\nLocation: /loop\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
5464 let _ = stream.write_all(response.as_bytes()).await;
5465 });
5466 }
5467 }
5468 });
5469
5470 let ctx = test_producer_ctx();
5471 let component =
5472 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5473 let endpoint_ctx = NoOpComponentContext;
5474 let endpoint = component
5475 .create_endpoint(
5476 &format!("{url}?allowInternal=true&maxRedirects=2"),
5477 &endpoint_ctx,
5478 )
5479 .unwrap();
5480 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5481
5482 let exchange = Exchange::new(Message::default());
5483 let result = producer.oneshot(exchange).await;
5484
5485 match result {
5493 Err(e) => {
5494 let msg = e.to_string();
5496 assert!(
5497 msg.contains("HTTP operation failed") || msg.contains("302"),
5498 "expected redirect-after-exhaustion error, got: {msg}"
5499 );
5500 }
5501 Ok(ex) => {
5502 let response_code = ex
5503 .input
5504 .header("CamelHttpResponseCode")
5505 .and_then(|v| v.as_u64());
5506 assert_eq!(
5507 response_code,
5508 Some(302),
5509 "expected 302 after exhausting redirects"
5510 );
5511 }
5512 }
5513
5514 handle.abort();
5515 }
5516
5517 #[tokio::test]
5518 async fn test_query_params_forwarded_to_http_request() {
5519 use tower::ServiceExt;
5520
5521 let (url, _handle) = start_test_server().await;
5522 let ctx = test_producer_ctx();
5523
5524 let component = HttpComponent::new();
5525 let endpoint_ctx = NoOpComponentContext;
5526 let endpoint = component
5528 .create_endpoint(
5529 &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
5530 &endpoint_ctx,
5531 )
5532 .unwrap();
5533 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5534
5535 let exchange = Exchange::new(Message::default());
5536 let result = producer.oneshot(exchange).await.unwrap();
5537
5538 let status = result
5541 .input
5542 .header("CamelHttpResponseCode")
5543 .and_then(|v| v.as_u64())
5544 .unwrap();
5545 assert_eq!(status, 200);
5546 }
5547
5548 #[test]
5549 fn test_non_camel_query_params_are_forwarded() {
5550 let config = HttpEndpointConfig::from_uri(
5553 "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
5554 )
5555 .unwrap();
5556
5557 assert_eq!(
5560 config.raw_query.as_deref(),
5561 Some("apiKey=secret123&httpMethod=GET&token=abc456")
5562 );
5563 assert!(config.query_params.is_empty());
5564 }
5565
5566 #[test]
5567 fn test_authored_query_bytes_survive_resolve_url() {
5568 let config =
5569 HttpEndpointConfig::from_uri("http://example.com/api?q=hello%20world&tag=a+b").unwrap();
5570 let exchange = Exchange::new(Message::default());
5571
5572 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
5573
5574 assert!(url.contains("q=hello%20world"), "url was: {url}");
5577 assert!(url.contains("tag=a+b"), "url was: {url}");
5578 }
5579
5580 async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
5585 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5586 let addr = listener.local_addr().unwrap();
5587 let url = format!("http://127.0.0.1:{}", addr.port());
5588
5589 let handle = tokio::spawn(async move {
5590 loop {
5591 if let Ok((mut stream, _)) = listener.accept().await {
5592 let delay = delay_ms;
5593 tokio::spawn(async move {
5594 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5595 let mut buf = vec![0u8; 4096];
5596 let _ = stream.read(&mut buf).await;
5597 let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
5599 let _ = stream.write_all(headers.as_bytes()).await;
5600 tokio::time::sleep(Duration::from_millis(delay)).await;
5602 let body = r#"{"status":"slow"}"#;
5603 let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
5604 let _ = stream.write_all(chunk.as_bytes()).await;
5605 });
5606 }
5607 }
5608 });
5609
5610 (url, handle)
5611 }
5612
5613 #[tokio::test]
5614 async fn test_http_producer_timeout() {
5615 use tower::ServiceExt;
5616
5617 let (url, _handle) = start_slow_server(500).await;
5619 let ctx = test_producer_ctx();
5620
5621 let component = HttpComponent::with_config(
5622 HttpConfig::default()
5623 .with_read_timeout_ms(100)
5624 .with_response_timeout_ms(30_000), );
5626 let endpoint_ctx = NoOpComponentContext;
5627 let endpoint = component
5628 .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
5629 .unwrap();
5630 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5631
5632 let exchange = Exchange::new(Message::default());
5633 let result = producer.oneshot(exchange).await;
5634
5635 assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
5636 let err = result.unwrap_err().to_string();
5637 assert!(
5638 err.contains("Read timeout") || err.contains("timeout"),
5639 "Error should mention timeout, got: {}",
5640 err
5641 );
5642 }
5643
5644 #[tokio::test]
5645 async fn test_http_producer_no_timeout_when_fast() {
5646 use tower::ServiceExt;
5647
5648 let (url, _handle) = start_test_server().await;
5649 let ctx = test_producer_ctx();
5650
5651 let component =
5652 HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
5653 let endpoint_ctx = NoOpComponentContext;
5654 let endpoint = component
5655 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5656 .unwrap();
5657 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5658
5659 let exchange = Exchange::new(Message::default());
5660 let result = producer.oneshot(exchange).await.unwrap();
5661
5662 let status = result
5663 .input
5664 .header("CamelHttpResponseCode")
5665 .and_then(|v| v.as_u64())
5666 .unwrap();
5667 assert_eq!(status, 200);
5668 }
5669
5670 #[tokio::test]
5675 async fn test_http_producer_blocks_metadata_endpoint() {
5676 use tower::ServiceExt;
5677
5678 let ctx = test_producer_ctx();
5679 let component = HttpComponent::new();
5680 let endpoint_ctx = NoOpComponentContext;
5681 let endpoint = component
5682 .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
5683 .unwrap();
5684 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5685
5686 let mut exchange = Exchange::new(Message::default());
5687 exchange.input.set_header(
5688 "CamelHttpUri",
5689 serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
5690 );
5691
5692 let result = producer.oneshot(exchange).await;
5693 assert!(result.is_err(), "Should block AWS metadata endpoint");
5694
5695 let err = result.unwrap_err();
5696 assert!(
5697 err.to_string().contains("Private IP"),
5698 "Error should mention private IP blocking, got: {}",
5699 err
5700 );
5701 }
5702
5703 #[test]
5704 fn test_ssrf_config_defaults() {
5705 let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
5706 assert!(
5707 !config.allow_internal,
5708 "Private IPs should be blocked by default"
5709 );
5710 assert!(
5711 config.blocked_hosts.is_empty(),
5712 "Blocked hosts should be empty by default"
5713 );
5714 }
5715
5716 #[test]
5717 fn test_ssrf_config_allow_internal() {
5718 let config =
5719 HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
5720 assert!(
5721 config.allow_internal,
5722 "Private IPs should be allowed when explicitly set"
5723 );
5724 }
5725
5726 #[test]
5727 fn test_ssrf_config_blocked_hosts() {
5728 let config = HttpEndpointConfig::from_uri(
5729 "http://example.com/api?blockedHosts=evil.com,malware.net",
5730 )
5731 .unwrap();
5732 assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
5733 }
5734
5735 #[tokio::test]
5736 async fn test_http_producer_blocks_localhost() {
5737 use tower::ServiceExt;
5738
5739 let ctx = test_producer_ctx();
5740 let component = HttpComponent::new();
5741 let endpoint_ctx = NoOpComponentContext;
5742 let endpoint = component
5743 .create_endpoint("http://example.com/api", &endpoint_ctx)
5744 .unwrap();
5745 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5746
5747 let mut exchange = Exchange::new(Message::default());
5748 exchange.input.set_header(
5749 "CamelHttpUri",
5750 serde_json::Value::String("http://localhost:8080/internal".to_string()),
5751 );
5752
5753 let result = producer.oneshot(exchange).await;
5754 assert!(result.is_err(), "Should block localhost");
5755 }
5756
5757 #[tokio::test]
5758 async fn test_http_producer_blocks_loopback_ip() {
5759 use tower::ServiceExt;
5760
5761 let ctx = test_producer_ctx();
5762 let component = HttpComponent::new();
5763 let endpoint_ctx = NoOpComponentContext;
5764 let endpoint = component
5765 .create_endpoint("http://example.com/api", &endpoint_ctx)
5766 .unwrap();
5767 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5768
5769 let mut exchange = Exchange::new(Message::default());
5770 exchange.input.set_header(
5771 "CamelHttpUri",
5772 serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
5773 );
5774
5775 let result = producer.oneshot(exchange).await;
5776 assert!(result.is_err(), "Should block loopback IP");
5777 }
5778
5779 #[tokio::test]
5780 async fn test_http_producer_allows_private_ip_when_enabled() {
5781 use tower::ServiceExt;
5782
5783 let ctx = test_producer_ctx();
5784 let component = HttpComponent::new();
5785 let endpoint_ctx = NoOpComponentContext;
5786 let endpoint = component
5789 .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
5790 .unwrap();
5791 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5792
5793 let exchange = Exchange::new(Message::default());
5794
5795 let result = producer.oneshot(exchange).await;
5798 if let Err(ref e) = result {
5800 let err_str = e.to_string();
5801 assert!(
5802 !err_str.contains("Private IP") && !err_str.contains("not allowed"),
5803 "Should not be SSRF error, got: {}",
5804 err_str
5805 );
5806 }
5807 }
5808
5809 #[test]
5814 fn test_http_server_config_parse() {
5815 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
5816 assert_eq!(cfg.host, "0.0.0.0");
5817 assert_eq!(cfg.port, 8080);
5818 assert_eq!(cfg.path, "/orders");
5819 assert_eq!(cfg.max_inflight_requests, 1024);
5820 }
5821
5822 #[test]
5823 fn test_http_server_config_scheme() {
5824 assert_eq!(HttpServerConfig::scheme(), "http");
5826 }
5827
5828 #[test]
5829 fn test_http_server_config_from_components() {
5830 let components = camel_component_api::UriComponents {
5832 scheme: "https".to_string(),
5833 path: "//0.0.0.0:8443/api".to_string(),
5834 params: std::collections::HashMap::from([
5835 ("maxRequestBody".to_string(), "5242880".to_string()),
5836 ("maxInflightRequests".to_string(), "7".to_string()),
5837 ]),
5838 raw_query: None,
5839 };
5840 let cfg = HttpServerConfig::from_components(components).unwrap();
5841 assert_eq!(cfg.host, "0.0.0.0");
5842 assert_eq!(cfg.port, 8443);
5843 assert_eq!(cfg.path, "/api");
5844 assert_eq!(cfg.max_request_body, 5242880);
5845 assert_eq!(cfg.max_inflight_requests, 7);
5846 }
5847
5848 #[test]
5849 fn test_http_server_config_default_path() {
5850 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
5851 assert_eq!(cfg.path, "/");
5852 }
5853
5854 #[test]
5855 fn test_http_server_config_wrong_scheme() {
5856 assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
5857 }
5858
5859 #[test]
5860 fn test_http_server_config_invalid_port() {
5861 assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
5862 }
5863
5864 #[test]
5865 fn test_http_server_config_default_port_by_scheme() {
5866 let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
5868 assert_eq!(cfg_http.port, 80);
5869
5870 let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
5872 assert_eq!(cfg_https.port, 443);
5873 }
5874
5875 #[test]
5876 fn test_request_envelope_and_reply_are_send() {
5877 fn assert_send<T: Send>() {}
5878 assert_send::<RequestEnvelope>();
5879 assert_send::<HttpReply>();
5880 }
5881
5882 #[test]
5887 fn test_server_registry_global_is_singleton() {
5888 let r1 = ServerRegistry::global();
5889 let r2 = ServerRegistry::global();
5890 assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
5891 }
5892
5893 #[allow(clippy::await_holding_lock)]
5894 #[tokio::test]
5895 async fn test_concurrent_get_or_spawn_returns_same_registry() {
5896 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5897 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5898 let port = listener.local_addr().unwrap().port();
5899 drop(listener);
5900
5901 let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
5902 Arc::new(std::sync::Mutex::new(Vec::new()));
5903
5904 let mut handles = Vec::new();
5905 for _ in 0..4 {
5906 let results = results.clone();
5907 handles.push(tokio::spawn(async move {
5908 let registry = ServerRegistry::global()
5909 .get_or_spawn(
5910 "127.0.0.1",
5911 port,
5912 2 * 1024 * 1024,
5913 10 * 1024 * 1024,
5914 1024,
5915 test_rt(),
5916 "test-route".into(),
5917 None,
5918 )
5919 .await
5920 .unwrap();
5921 results.lock().unwrap().push(registry);
5922 }));
5923 }
5924
5925 for h in handles {
5926 h.await.unwrap();
5927 }
5928
5929 let registries = results.lock().unwrap();
5930 assert_eq!(registries.len(), 4);
5931 for i in 1..registries.len() {
5932 assert!(
5933 Arc::ptr_eq(®istries[0].inner, ®istries[i].inner),
5934 "all concurrent callers should get same route registry"
5935 );
5936 }
5937 }
5938
5939 #[test]
5940 fn test_server_registry_distinguishes_host_and_port() {
5941 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5942 let rt = tokio::runtime::Runtime::new().expect("runtime");
5943 rt.block_on(async {
5944 let registry = ServerRegistry::global();
5945 let d1 = registry
5949 .get_or_spawn(
5950 "127.0.0.1",
5951 0,
5952 1024 * 1024,
5953 10 * 1024 * 1024,
5954 1024,
5955 test_rt(),
5956 "test-route-1".into(),
5957 None,
5958 )
5959 .await;
5960 let d2 = registry
5961 .get_or_spawn(
5962 "0.0.0.0",
5963 0,
5964 1024 * 1024,
5965 10 * 1024 * 1024,
5966 1024,
5967 test_rt(),
5968 "test-route-2".into(),
5969 None,
5970 )
5971 .await;
5972 assert!(d1.is_ok());
5973 assert!(d2.is_ok());
5974 assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
5975 });
5976 }
5977
5978 #[allow(clippy::await_holding_lock)]
5979 #[tokio::test]
5980 async fn test_shared_server_max_request_body_policy_is_deterministic() {
5981 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5982 let registry = ServerRegistry::global();
5983 let d1 = registry
5985 .get_or_spawn(
5986 "127.0.0.1",
5987 9991,
5988 1024 * 1024,
5989 10 * 1024 * 1024,
5990 1024,
5991 test_rt(),
5992 "test-route".into(),
5993 None,
5994 )
5995 .await;
5996 assert!(d1.is_ok());
5997
5998 let d2 = registry
6001 .get_or_spawn(
6002 "127.0.0.1",
6003 9991,
6004 2 * 1024 * 1024,
6005 10 * 1024 * 1024,
6006 1024,
6007 test_rt(),
6008 "test-route-2".into(),
6009 None,
6010 )
6011 .await;
6012 assert!(d2.is_err());
6013 let err = d2.unwrap_err();
6014 assert!(
6015 err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
6016 "Expected incompatible maxRequestBody error, got: {}",
6017 err
6018 );
6019 }
6020
6021 #[test]
6022 fn test_server_registry_reset_clears_entries() {
6023 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6024 let rt = tokio::runtime::Runtime::new().expect("runtime");
6025 rt.block_on(async {
6026 let d1 = ServerRegistry::global()
6028 .get_or_spawn(
6029 "127.0.0.1",
6030 9992,
6031 1024 * 1024,
6032 10 * 1024 * 1024,
6033 1024,
6034 test_rt(),
6035 "test-route".into(),
6036 None,
6037 )
6038 .await;
6039 assert!(d1.is_ok());
6040
6041 let guard = ServerRegistry::global().inner.lock().expect("lock");
6043 assert!(guard.entries.contains_key(&("127.0.0.1".to_string(), 9992)));
6044 drop(guard);
6045
6046 ServerRegistry::reset();
6048
6049 let guard = ServerRegistry::global().inner.lock().expect("lock");
6051 assert!(
6052 guard.entries.is_empty(),
6053 "registry should be empty after reset, has {} entries",
6054 guard.entries.len()
6055 );
6056 });
6057 }
6058
6059 #[tokio::test]
6060 async fn registry_rejects_tls_on_plain_port() {
6061 ServerRegistry::reset();
6062 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
6063
6064 let _r1 = ServerRegistry::global()
6066 .get_or_spawn(
6067 "127.0.0.1",
6068 0,
6069 1024,
6070 1024,
6071 16,
6072 Arc::clone(&rt),
6073 "route-1".into(),
6074 None, )
6076 .await;
6077
6078 let result = ServerRegistry::global()
6080 .get_or_spawn(
6081 "127.0.0.1",
6082 0,
6083 1024,
6084 1024,
6085 16,
6086 Arc::clone(&rt),
6087 "route-2".into(),
6088 Some(crate::config::ServerTlsConfig {
6089 cert_path: "/x.pem".into(),
6090 key_path: "/y.pem".into(),
6091 }),
6092 )
6093 .await;
6094 assert!(result.is_err(), "must reject TLS on plain port");
6095 }
6096
6097 #[allow(clippy::await_holding_lock)]
6102 #[tokio::test]
6103 async fn test_unregister_last_http_route_keeps_server_alive() {
6104 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6105 ServerRegistry::reset();
6106 let registry = ServerRegistry::global();
6107
6108 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6109 let port = listener.local_addr().unwrap().port();
6110 drop(listener); let rt = test_rt();
6112
6113 let _r1 = registry
6116 .get_or_spawn(
6117 "127.0.0.1",
6118 port,
6119 1024 * 1024,
6120 10 * 1024 * 1024,
6121 16,
6122 rt.clone(),
6123 "test-route-1".into(),
6124 None,
6125 )
6126 .await
6127 .unwrap();
6128 let _r2 = registry
6129 .get_or_spawn(
6130 "127.0.0.1",
6131 port,
6132 1024 * 1024,
6133 10 * 1024 * 1024,
6134 16,
6135 rt,
6136 "test-route-2".into(),
6137 None,
6138 )
6139 .await
6140 .unwrap();
6141
6142 let key = ("127.0.0.1".to_string(), port);
6143 let cell = {
6144 let guard = registry.inner.lock().expect("lock");
6145 guard.entries.get(&key).expect("entry should exist").clone()
6146 };
6147
6148 registry.unregister("127.0.0.1", port).await;
6150 {
6151 let handle = cell
6152 .get()
6153 .expect("handle should still exist after first unregister");
6154 assert!(
6155 !handle.monitor_task.is_finished(),
6156 "monitor task should still be alive after first unregister"
6157 );
6158 }
6159
6160 registry.unregister("127.0.0.1", port).await;
6162 tokio::time::sleep(Duration::from_millis(20)).await;
6163 {
6164 let handle = cell
6165 .get()
6166 .expect("handle should still exist after last unregister");
6167 assert!(
6168 !handle.monitor_task.is_finished(),
6169 "monitor task should still be alive — server is process-lifetime"
6170 );
6171 }
6172
6173 {
6175 let guard = registry.inner.lock().expect("lock");
6176 assert!(
6177 guard.entries.contains_key(&key),
6178 "entry should remain in registry — server kept alive for restart"
6179 );
6180 }
6181 }
6182
6183 async fn clone_fixture_listener() -> (
6192 tokio::net::TcpListener,
6193 std::net::TcpListener,
6194 std::net::SocketAddr,
6195 ) {
6196 let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind std listener");
6197 let probe = l.try_clone().expect("clone probe");
6198 l.set_nonblocking(true).expect("set_nonblocking");
6199 let listener = tokio::net::TcpListener::from_std(l).expect("from_std");
6200 let addr = listener.local_addr().expect("local_addr");
6201 (listener, probe, addr)
6202 }
6203
6204 fn staged_limits() -> (usize, usize, usize) {
6206 (1024 * 1024, 10 * 1024 * 1024, 1024)
6207 }
6208
6209 #[allow(clippy::await_holding_lock)]
6210 #[tokio::test]
6211 async fn staged_listener_first_spawn_serves_without_second_bind() {
6212 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6213 ServerRegistry::reset();
6214 let registry = ServerRegistry::global();
6215 let (listener, _probe, addr) = clone_fixture_listener().await;
6216 let port = addr.port();
6217 registry
6218 .stage_listener(listener)
6219 .await
6220 .expect("stage listener");
6221
6222 let (max_req, max_res, max_inflight) = staged_limits();
6223 let routes = registry
6224 .get_or_spawn(
6225 "127.0.0.1",
6226 port,
6227 max_req,
6228 max_res,
6229 max_inflight,
6230 test_rt(),
6231 "staged-first-spawn".into(),
6232 None,
6233 )
6234 .await
6235 .expect("spawn from staged listener must succeed");
6236
6237 assert_eq!(
6238 registry.bound_addr("127.0.0.1", port),
6239 Some(addr),
6240 "served socket must be the staged listener's addr"
6241 );
6242 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__staged_probe__"))
6245 .await
6246 .expect("http request against staged listener must connect");
6247 assert!(
6248 resp.status().as_u16() >= 200,
6249 "any status proves the staged socket serves"
6250 );
6251 drop(routes);
6252 }
6253
6254 #[allow(clippy::await_holding_lock)]
6255 #[tokio::test]
6256 async fn staged_entry_reused_by_second_caller() {
6257 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6258 ServerRegistry::reset();
6259 let registry = ServerRegistry::global();
6260 let (listener, _probe, addr) = clone_fixture_listener().await;
6261 let port = addr.port();
6262 registry
6263 .stage_listener(listener)
6264 .await
6265 .expect("stage listener");
6266
6267 let (max_req, max_res, max_inflight) = staged_limits();
6268 let first = registry
6269 .get_or_spawn(
6270 "127.0.0.1",
6271 port,
6272 max_req,
6273 max_res,
6274 max_inflight,
6275 test_rt(),
6276 "staged-reuse-1".into(),
6277 None,
6278 )
6279 .await
6280 .expect("first spawn from staged listener");
6281 let second = registry
6282 .get_or_spawn(
6283 "127.0.0.1",
6284 port,
6285 max_req,
6286 max_res,
6287 max_inflight,
6288 test_rt(),
6289 "staged-reuse-2".into(),
6290 None,
6291 )
6292 .await
6293 .expect("second caller must reuse the entry");
6294 assert_eq!(
6295 registry.bound_addr("127.0.0.1", port),
6296 Some(addr),
6297 "entry reused — bound addr unchanged, no second bind"
6298 );
6299 drop(first);
6300 drop(second);
6301 }
6302
6303 #[allow(clippy::await_holding_lock)]
6304 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6305 async fn staged_race_two_callers_single_resolver() {
6306 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6307 ServerRegistry::reset();
6308 let registry = ServerRegistry::global();
6309 let (listener, _probe, addr) = clone_fixture_listener().await;
6310 let port = addr.port();
6311 registry
6312 .stage_listener(listener)
6313 .await
6314 .expect("stage listener");
6315
6316 let (max_req, max_res, max_inflight) = staged_limits();
6321 let (first, second) = tokio::join!(
6322 registry.get_or_spawn(
6323 "127.0.0.1",
6324 port,
6325 max_req,
6326 max_res,
6327 max_inflight,
6328 test_rt(),
6329 "staged-race-1".into(),
6330 None,
6331 ),
6332 registry.get_or_spawn(
6333 "127.0.0.1",
6334 port,
6335 max_req,
6336 max_res,
6337 max_inflight,
6338 test_rt(),
6339 "staged-race-2".into(),
6340 None,
6341 ),
6342 );
6343 let first = first.expect("first racing caller must succeed");
6344 let second = second.expect("second racing caller must succeed");
6345 assert_eq!(
6346 registry.bound_addr("127.0.0.1", port),
6347 Some(addr),
6348 "single entry must be served from the staged socket — no EADDRINUSE path"
6349 );
6350 drop(first);
6351 drop(second);
6352 }
6353
6354 #[allow(clippy::await_holding_lock)]
6355 #[tokio::test]
6356 async fn unstaged_spawn_binds_legacy() {
6357 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6358 ServerRegistry::reset();
6359 let registry = ServerRegistry::global();
6360 let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe bind");
6362 let port = probe.local_addr().expect("local addr").port();
6363 drop(probe);
6364
6365 let (max_req, max_res, max_inflight) = staged_limits();
6366 registry
6367 .get_or_spawn(
6368 "127.0.0.1",
6369 port,
6370 max_req,
6371 max_res,
6372 max_inflight,
6373 test_rt(),
6374 "legacy-bind".into(),
6375 None,
6376 )
6377 .await
6378 .expect("legacy bind spawn");
6379 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__legacy_probe__"))
6380 .await
6381 .expect("connect to freshly bound port must succeed");
6382 assert!(resp.status().as_u16() >= 200);
6383 assert_eq!(
6384 registry.bound_addr("127.0.0.1", port),
6385 Some(std::net::SocketAddr::from(([127, 0, 0, 1], port))),
6386 "bound addr must be the legacy bound (host, port)"
6387 );
6388 }
6389
6390 #[allow(clippy::await_holding_lock)]
6391 #[tokio::test]
6392 async fn wrong_host_staged_port_fails_deterministically() {
6393 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6394 ServerRegistry::reset();
6395 let registry = ServerRegistry::global();
6396 let (listener, _probe, addr) = clone_fixture_listener().await;
6397 let port = addr.port();
6398 registry
6399 .stage_listener(listener)
6400 .await
6401 .expect("stage listener under 127.0.0.1");
6402
6403 let (max_req, max_res, max_inflight) = staged_limits();
6404 let err = registry
6405 .get_or_spawn(
6406 "localhost",
6407 port,
6408 max_req,
6409 max_res,
6410 max_inflight,
6411 test_rt(),
6412 "conflict-probe".into(),
6413 None,
6414 )
6415 .await
6416 .expect_err("wrong host on staged port must fail deterministically");
6417 assert!(
6418 err.to_string().contains("staged listener conflict on port"),
6419 "unexpected error: {err}"
6420 );
6421
6422 registry
6424 .get_or_spawn(
6425 "127.0.0.1",
6426 port,
6427 max_req,
6428 max_res,
6429 max_inflight,
6430 test_rt(),
6431 "conflict-after".into(),
6432 None,
6433 )
6434 .await
6435 .expect("correct host must serve the staged listener");
6436 assert_eq!(
6437 registry.bound_addr("127.0.0.1", port),
6438 Some(addr),
6439 "staged slot must be untouched by the conflicting call"
6440 );
6441 }
6442
6443 #[allow(clippy::await_holding_lock)]
6444 #[tokio::test]
6445 async fn duplicate_stage_same_key_rejected() {
6446 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6447 ServerRegistry::reset();
6448 let registry = ServerRegistry::global();
6449 let (listener, probe, addr) = clone_fixture_listener().await;
6450 registry
6451 .stage_listener(listener)
6452 .await
6453 .expect("stage listener A");
6454
6455 let dup = probe.try_clone().expect("clone2");
6457 dup.set_nonblocking(true).expect("set_nonblocking2");
6458 let b = tokio::net::TcpListener::from_std(dup).expect("from_std2");
6459
6460 let err = registry
6461 .stage_listener(b)
6462 .await
6463 .expect_err("duplicate stage must be rejected");
6464 assert!(
6465 err.to_string().contains("listener already staged"),
6466 "unexpected error: {err}"
6467 );
6468
6469 let (max_req, max_res, max_inflight) = staged_limits();
6470 registry
6471 .get_or_spawn(
6472 "127.0.0.1",
6473 addr.port(),
6474 max_req,
6475 max_res,
6476 max_inflight,
6477 test_rt(),
6478 "dup-stage-after".into(),
6479 None,
6480 )
6481 .await
6482 .expect("spawn from first staged listener");
6483 assert_eq!(
6484 registry.bound_addr("127.0.0.1", addr.port()),
6485 Some(addr),
6486 "first staged listener retained"
6487 );
6488 }
6489
6490 #[allow(clippy::await_holding_lock)]
6491 #[tokio::test]
6492 async fn distinct_keys_stage_independently() {
6493 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6494 ServerRegistry::reset();
6495 let registry = ServerRegistry::global();
6496 let (l1, _p1, addr1) = clone_fixture_listener().await;
6497 let (l2, _p2, addr2) = clone_fixture_listener().await;
6498 registry.stage_listener(l1).await.expect("stage P1");
6499 registry.stage_listener(l2).await.expect("stage P2");
6500
6501 let (max_req, max_res, max_inflight) = staged_limits();
6502 registry
6503 .get_or_spawn(
6504 "127.0.0.1",
6505 addr1.port(),
6506 max_req,
6507 max_res,
6508 max_inflight,
6509 test_rt(),
6510 "distinct-1".into(),
6511 None,
6512 )
6513 .await
6514 .expect("spawn P1");
6515 registry
6516 .get_or_spawn(
6517 "127.0.0.1",
6518 addr2.port(),
6519 max_req,
6520 max_res,
6521 max_inflight,
6522 test_rt(),
6523 "distinct-2".into(),
6524 None,
6525 )
6526 .await
6527 .expect("spawn P2");
6528 assert_eq!(
6529 registry.bound_addr("127.0.0.1", addr1.port()),
6530 Some(addr1),
6531 "P1 bound addr must be its own listener"
6532 );
6533 assert_eq!(
6534 registry.bound_addr("127.0.0.1", addr2.port()),
6535 Some(addr2),
6536 "P2 bound addr must be its own listener"
6537 );
6538 let r1 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr1.port()))
6539 .await
6540 .expect("connect P1");
6541 assert!(r1.status().as_u16() >= 200);
6542 let r2 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr2.port()))
6543 .await
6544 .expect("connect P2");
6545 assert!(r2.status().as_u16() >= 200);
6546 }
6547
6548 #[allow(clippy::await_holding_lock)]
6549 #[tokio::test]
6550 async fn tls_prebound_listener_served() {
6551 use camel_component_api::test_support::tls;
6552
6553 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
6556
6557 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6558 ServerRegistry::reset();
6559 let registry = ServerRegistry::global();
6560 let (listener, _probe, addr) = clone_fixture_listener().await;
6561 let port = addr.port();
6562
6563 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
6564 let cert_path = tls::write_pem_tmp("http-staged-tls-cert.pem", &cert_pem);
6565 let key_path = tls::write_pem_tmp("http-staged-tls-key.pem", &key_pem);
6566 let ca_path = tls::write_pem_tmp("http-staged-tls-ca.pem", &ca_pem);
6567
6568 let (max_req, max_res, max_inflight) = staged_limits();
6569 let routes = registry
6570 .get_or_spawn_with_listener(
6571 listener,
6572 max_req,
6573 max_res,
6574 max_inflight,
6575 test_rt(),
6576 "staged-tls".into(),
6577 Some(crate::config::ServerTlsConfig {
6578 cert_path: cert_path.to_string_lossy().into_owned(),
6579 key_path: key_path.to_string_lossy().into_owned(),
6580 }),
6581 )
6582 .await
6583 .expect("spawn TLS server from pre-bound listener");
6584
6585 let ca_bytes = std::fs::read(&ca_path).expect("read ca pem");
6588 let client = reqwest::Client::builder()
6589 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).expect("parse ca pem"))
6590 .build()
6591 .expect("build tls client");
6592
6593 let resp = client
6594 .get(format!("https://127.0.0.1:{port}/__staged_tls__"))
6595 .send()
6596 .await
6597 .expect("TLS handshake + request must succeed");
6598 assert_eq!(resp.status().as_u16(), 404, "unknown path 404s through TLS");
6599 assert_eq!(
6600 registry.bound_addr("127.0.0.1", port),
6601 Some(addr),
6602 "bound addr equals the pre-bound listener addr"
6603 );
6604 drop(routes);
6605 }
6606
6607 #[allow(clippy::await_holding_lock)]
6608 #[tokio::test]
6609 async fn with_listener_direct_spawn_keyed_by_actual_addr() {
6610 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6611 ServerRegistry::reset();
6612 let registry = ServerRegistry::global();
6613 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
6614 .await
6615 .expect("bind un-staged listener");
6616 let addr = listener.local_addr().expect("local addr");
6617 let port = addr.port();
6618
6619 let (max_req, max_res, max_inflight) = staged_limits();
6620 registry
6621 .get_or_spawn_with_listener(
6622 listener,
6623 max_req,
6624 max_res,
6625 max_inflight,
6626 test_rt(),
6627 "with-listener".into(),
6628 None,
6629 )
6630 .await
6631 .expect("direct spawn from un-staged listener");
6632 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__with_listener__"))
6633 .await
6634 .expect("connect on actual port");
6635 assert!(resp.status().as_u16() >= 200);
6636 assert_eq!(
6637 registry.bound_addr("127.0.0.1", port),
6638 Some(addr),
6639 "registry key is the listener's actual port"
6640 );
6641
6642 registry
6643 .get_or_spawn(
6644 "127.0.0.1",
6645 port,
6646 max_req,
6647 max_res,
6648 max_inflight,
6649 test_rt(),
6650 "with-listener-reuse".into(),
6651 None,
6652 )
6653 .await
6654 .expect("legacy caller must reuse the entry");
6655 assert_eq!(
6656 registry.bound_addr("127.0.0.1", port),
6657 Some(addr),
6658 "entry reused — no second bind"
6659 );
6660 }
6661
6662 #[tokio::test]
6667 async fn test_dispatch_handler_returns_404_for_unknown_path() {
6668 let registry = HttpRouteRegistry::new();
6669 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6671 let port = listener.local_addr().unwrap().port();
6672 tokio::spawn(run_axum_server(
6673 listener,
6674 registry,
6675 2 * 1024 * 1024,
6676 10 * 1024 * 1024,
6677 Arc::new(tokio::sync::Semaphore::new(1024)),
6678 test_rt(),
6679 "test-route".into(),
6680 ));
6681
6682 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
6684
6685 let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
6686 .await
6687 .unwrap();
6688 assert_eq!(resp.status().as_u16(), 404);
6689 }
6690
6691 #[tokio::test]
6696 async fn test_http_consumer_start_registers_path() {
6697 use camel_component_api::ConsumerContext;
6698
6699 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6701 let port = listener.local_addr().unwrap().port();
6702 drop(listener); let consumer_cfg = HttpServerConfig {
6705 scheme: "http".to_string(),
6706 host: "127.0.0.1".to_string(),
6707 port,
6708 path: "/ping".to_string(),
6709 max_request_body: 2 * 1024 * 1024,
6710 max_response_body: 10 * 1024 * 1024,
6711 max_inflight_requests: 1024,
6712 method: None,
6713 tls_config: None,
6714 };
6715 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6716
6717 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6718 let token = tokio_util::sync::CancellationToken::new();
6719 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6720
6721 tokio::spawn(async move {
6722 consumer.start(ctx).await.unwrap();
6723 });
6724
6725 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6726
6727 let client = reqwest::Client::new();
6728 let resp_future = client
6729 .post(format!("http://127.0.0.1:{port}/ping"))
6730 .body("hello world")
6731 .send();
6732
6733 let (http_result, _) = tokio::join!(resp_future, async {
6734 if let Some(mut envelope) = rx.recv().await {
6735 envelope.exchange.input.set_header(
6737 "CamelHttpResponseCode",
6738 serde_json::Value::Number(201.into()),
6739 );
6740 if let Some(reply_tx) = envelope.reply_tx {
6741 let _ = reply_tx.send(Ok(envelope.exchange));
6742 }
6743 }
6744 });
6745
6746 let resp = http_result.unwrap();
6747 assert_eq!(resp.status().as_u16(), 201);
6748
6749 token.cancel();
6750 }
6751
6752 #[test]
6756 fn test_envelope_channel_capacity_follows_max_inflight() {
6757 assert_eq!(envelope_channel_capacity(0), 1);
6758 assert_eq!(envelope_channel_capacity(1), 1);
6759 assert_eq!(envelope_channel_capacity(7), 7);
6760 assert_eq!(envelope_channel_capacity(64), 64);
6761 assert_eq!(envelope_channel_capacity(1024), 1024);
6762 }
6763
6764 #[tokio::test]
6768 async fn test_http_consumer_start_with_zero_max_inflight_rejects_503() {
6769 use camel_component_api::ConsumerContext;
6770
6771 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6772 let port = listener.local_addr().unwrap().port();
6773 drop(listener);
6774
6775 let consumer_cfg = HttpServerConfig {
6776 scheme: "http".to_string(),
6777 host: "127.0.0.1".to_string(),
6778 port,
6779 path: "/ping".to_string(),
6780 max_request_body: 2 * 1024 * 1024,
6781 max_response_body: 10 * 1024 * 1024,
6782 max_inflight_requests: 0,
6783 method: None,
6784 tls_config: None,
6785 };
6786 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6787
6788 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6789 let token = tokio_util::sync::CancellationToken::new();
6790 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6791
6792 let start_handle = tokio::spawn(async move {
6793 consumer.start(ctx).await.unwrap();
6794 });
6795
6796 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6797
6798 let client = reqwest::Client::new();
6799 let resp = client
6800 .post(format!("http://127.0.0.1:{port}/ping"))
6801 .body("hello world")
6802 .send()
6803 .await
6804 .unwrap();
6805 assert_eq!(resp.status().as_u16(), 503);
6806
6807 token.cancel();
6808 let _ = start_handle.await;
6809 }
6810
6811 #[test]
6814 fn test_http_consumer_startup_mode_is_explicit() {
6815 use camel_component_api::ConsumerStartupMode;
6816 let consumer_cfg = HttpServerConfig {
6817 scheme: "http".to_string(),
6818 host: "127.0.0.1".to_string(),
6819 port: 0,
6820 path: "/x".to_string(),
6821 max_request_body: 2 * 1024 * 1024,
6822 max_response_body: 10 * 1024 * 1024,
6823 max_inflight_requests: 1024,
6824 method: None,
6825 tls_config: None,
6826 };
6827 let consumer = HttpConsumer::new(consumer_cfg, test_rt());
6828 assert_eq!(
6829 consumer.startup_mode(),
6830 ConsumerStartupMode::Explicit,
6831 "HttpConsumer must opt into Explicit startup"
6832 );
6833 }
6834
6835 #[allow(clippy::await_holding_lock)]
6841 #[tokio::test]
6842 async fn test_http_consumer_emits_mark_ready_after_bind() {
6843 use camel_component_api::{ConsumerContext, StartupSignal};
6844
6845 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6846
6847 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6848 let port = listener.local_addr().unwrap().port();
6849 drop(listener);
6850
6851 let consumer_cfg = HttpServerConfig {
6852 scheme: "http".to_string(),
6853 host: "127.0.0.1".to_string(),
6854 port,
6855 path: "/ready-probe".to_string(),
6856 max_request_body: 2 * 1024 * 1024,
6857 max_response_body: 10 * 1024 * 1024,
6858 max_inflight_requests: 1024,
6859 method: None,
6860 tls_config: None,
6861 };
6862 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6863
6864 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6865 let token = tokio_util::sync::CancellationToken::new();
6866 let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
6867
6868 let (signal, startup_rx) = StartupSignal::pair();
6870 let ctx = ctx.with_startup(signal);
6871
6872 tokio::spawn(async move {
6875 let _ = consumer.start(ctx).await;
6876 });
6877
6878 let result =
6883 tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
6884 .await
6885 .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
6886 assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
6887
6888 token.cancel();
6890 }
6891
6892 #[tokio::test]
6893 async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
6894 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6895
6896 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6897 let port = listener.local_addr().unwrap().port();
6898 drop(listener);
6899
6900 let consumer_cfg = HttpServerConfig {
6901 scheme: "http".to_string(),
6902 host: "127.0.0.1".to_string(),
6903 port,
6904 path: "/saturation".to_string(),
6905 max_request_body: 2 * 1024 * 1024,
6906 max_response_body: 10 * 1024 * 1024,
6907 max_inflight_requests: 1,
6908 method: None,
6909 tls_config: None,
6910 };
6911 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6912
6913 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6914 let token = tokio_util::sync::CancellationToken::new();
6915 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6916 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6917 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6918
6919 let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
6920 let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
6921
6922 tokio::spawn(async move {
6923 let mut first_seen_tx = Some(first_seen_tx);
6924 let mut unblock_first_rx = Some(unblock_first_rx);
6925
6926 while let Some(envelope) = rx.recv().await {
6927 if let Some(tx) = first_seen_tx.take() {
6928 let _ = tx.send(());
6929 if let Some(rx_unblock) = unblock_first_rx.take() {
6930 let _ = rx_unblock.await;
6931 }
6932 }
6933
6934 if let Some(reply_tx) = envelope.reply_tx {
6935 let _ = reply_tx.send(Ok(envelope.exchange));
6936 }
6937 }
6938 });
6939
6940 let client = reqwest::Client::new();
6941 let first_req = {
6942 let client = client.clone();
6943 async move {
6944 client
6945 .get(format!("http://127.0.0.1:{port}/saturation"))
6946 .send()
6947 .await
6948 .unwrap()
6949 }
6950 };
6951
6952 let first_handle = tokio::spawn(first_req);
6953 first_seen_rx.await.unwrap();
6954
6955 let second_resp = client
6956 .get(format!("http://127.0.0.1:{port}/saturation"))
6957 .send()
6958 .await
6959 .unwrap();
6960
6961 assert_eq!(second_resp.status().as_u16(), 503);
6962
6963 let _ = unblock_first_tx.send(());
6964 let first_resp = first_handle.await.unwrap();
6965 assert_eq!(first_resp.status().as_u16(), 200);
6966
6967 token.cancel();
6968 }
6969
6970 #[tokio::test]
6974 async fn test_http_consumer_chunked_body_is_capped() {
6975 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6976
6977 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6978 let port = listener.local_addr().unwrap().port();
6979 drop(listener);
6980
6981 let consumer_cfg = HttpServerConfig {
6982 scheme: "http".to_string(),
6983 host: "127.0.0.1".to_string(),
6984 port,
6985 path: "/chunked-cap".to_string(),
6986 max_request_body: 1024, max_response_body: 10 * 1024 * 1024,
6988 max_inflight_requests: 16,
6989 method: None,
6990 tls_config: None,
6991 };
6992 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6993
6994 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6995 let token = tokio_util::sync::CancellationToken::new();
6996 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6997 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6998 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6999
7000 let chunks: Vec<Result<bytes::Bytes, std::io::Error>> = (0..8)
7002 .map(|_| Ok(bytes::Bytes::from(vec![b'A'; 512])))
7003 .collect();
7004 let stream_body = reqwest::Body::wrap_stream(futures::stream::iter(chunks));
7005
7006 let client = reqwest::Client::new();
7007 let send_fut = client
7008 .post(format!("http://127.0.0.1:{port}/chunked-cap"))
7009 .body(stream_body)
7010 .send();
7011
7012 let (http_result, _) = tokio::join!(send_fut, async {
7013 if let Some(mut envelope) = rx.recv().await {
7014 let materialized = envelope
7016 .exchange
7017 .input
7018 .body
7019 .clone()
7020 .into_bytes(64 * 1024)
7021 .await;
7022 assert!(
7023 materialized.is_err(),
7024 "materializing a 4 KiB chunked body under a 1 KiB cap must fail"
7025 );
7026 let err = materialized.unwrap_err().to_string();
7027 assert!(
7028 err.contains("limit") || err.contains("exceeds"),
7029 "error should mention the limit: {err}"
7030 );
7031 if let Some(reply_tx) = envelope.reply_tx {
7032 envelope.exchange.input.body =
7033 camel_component_api::Body::Text("handled".to_string());
7034 let _ = reply_tx.send(Ok(envelope.exchange));
7035 }
7036 }
7037 });
7038
7039 let resp = http_result.unwrap();
7040 assert_eq!(resp.status().as_u16(), 200);
7041
7042 token.cancel();
7043 }
7044
7045 #[tokio::test]
7046 #[allow(clippy::await_holding_lock)]
7047 async fn test_http_consumer_enforces_max_response_body_for_bytes() {
7048 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7049
7050 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7051
7052 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7053 let port = listener.local_addr().unwrap().port();
7054 drop(listener);
7055
7056 let consumer_cfg = HttpServerConfig {
7057 scheme: "http".to_string(),
7058 host: "127.0.0.1".to_string(),
7059 port,
7060 path: "/limit-bytes".to_string(),
7061 max_request_body: 2 * 1024 * 1024,
7062 max_response_body: 16,
7063 max_inflight_requests: 1024,
7064 method: None,
7065 tls_config: None,
7066 };
7067 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7068
7069 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7070 let token = tokio_util::sync::CancellationToken::new();
7071 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7072 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7073 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7074
7075 let client = reqwest::Client::new();
7076 let send_fut = client
7077 .get(format!("http://127.0.0.1:{port}/limit-bytes"))
7078 .send();
7079
7080 let (http_result, _) = tokio::join!(send_fut, async {
7081 if let Some(mut envelope) = rx.recv().await {
7082 envelope.exchange.input.body =
7083 camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
7084 if let Some(reply_tx) = envelope.reply_tx {
7085 let _ = reply_tx.send(Ok(envelope.exchange));
7086 }
7087 }
7088 });
7089
7090 let resp = http_result.unwrap();
7091 assert_eq!(resp.status().as_u16(), 500);
7092 let body = resp.text().await.unwrap();
7093 assert_eq!(body, "Response body exceeds configured limit");
7094 token.cancel();
7095 }
7096
7097 #[tokio::test]
7098 #[allow(clippy::await_holding_lock)]
7099 async fn test_http_consumer_enforces_max_response_body_for_json() {
7100 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7101
7102 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7103
7104 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7105 let port = listener.local_addr().unwrap().port();
7106 drop(listener);
7107
7108 let consumer_cfg = HttpServerConfig {
7109 scheme: "http".to_string(),
7110 host: "127.0.0.1".to_string(),
7111 port,
7112 path: "/limit-json".to_string(),
7113 max_request_body: 2 * 1024 * 1024,
7114 max_response_body: 16,
7115 max_inflight_requests: 1024,
7116 method: None,
7117 tls_config: None,
7118 };
7119 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7120
7121 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7122 let token = tokio_util::sync::CancellationToken::new();
7123 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7124 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7125 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7126
7127 let client = reqwest::Client::new();
7128 let send_fut = client
7129 .get(format!("http://127.0.0.1:{port}/limit-json"))
7130 .send();
7131
7132 let (http_result, _) = tokio::join!(send_fut, async {
7133 if let Some(mut envelope) = rx.recv().await {
7134 envelope.exchange.input.body = camel_component_api::Body::Json(
7135 serde_json::json!({"message":"this response is bigger than sixteen"}),
7136 );
7137 if let Some(reply_tx) = envelope.reply_tx {
7138 let _ = reply_tx.send(Ok(envelope.exchange));
7139 }
7140 }
7141 });
7142
7143 let resp = http_result.unwrap();
7144 assert_eq!(resp.status().as_u16(), 500);
7145 let body = resp.text().await.unwrap();
7146 assert_eq!(body, "Response body exceeds configured limit");
7147 token.cancel();
7148 }
7149
7150 #[tokio::test]
7151 #[allow(clippy::await_holding_lock)]
7152 async fn test_http_consumer_enforces_max_response_body_for_xml() {
7153 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7154
7155 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7156
7157 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7158 let port = listener.local_addr().unwrap().port();
7159 drop(listener);
7160
7161 let consumer_cfg = HttpServerConfig {
7162 scheme: "http".to_string(),
7163 host: "127.0.0.1".to_string(),
7164 port,
7165 path: "/limit-xml".to_string(),
7166 max_request_body: 2 * 1024 * 1024,
7167 max_response_body: 16,
7168 max_inflight_requests: 1024,
7169 method: None,
7170 tls_config: None,
7171 };
7172 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7173
7174 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7175 let token = tokio_util::sync::CancellationToken::new();
7176 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7177 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7178 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7179
7180 let client = reqwest::Client::new();
7181 let send_fut = client
7182 .get(format!("http://127.0.0.1:{port}/limit-xml"))
7183 .send();
7184
7185 let (http_result, _) = tokio::join!(send_fut, async {
7186 if let Some(mut envelope) = rx.recv().await {
7187 envelope.exchange.input.body = camel_component_api::Body::Xml(
7188 "<root><value>way-too-large</value></root>".into(),
7189 );
7190 if let Some(reply_tx) = envelope.reply_tx {
7191 let _ = reply_tx.send(Ok(envelope.exchange));
7192 }
7193 }
7194 });
7195
7196 let resp = http_result.unwrap();
7197 assert_eq!(resp.status().as_u16(), 500);
7198 let body = resp.text().await.unwrap();
7199 assert_eq!(body, "Response body exceeds configured limit");
7200 token.cancel();
7201 }
7202
7203 #[tokio::test]
7204 #[allow(clippy::await_holding_lock)]
7205 async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
7206 use camel_component_api::{
7207 CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
7208 };
7209 use futures::stream;
7210
7211 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7212
7213 let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
7214 let port = listener.local_addr().unwrap().port();
7215 drop(listener);
7216
7217 let consumer_cfg = HttpServerConfig {
7218 scheme: "http".to_string(),
7219 host: "0.0.0.0".to_string(),
7220 port,
7221 path: "/limit-stream".to_string(),
7222 max_request_body: 2 * 1024 * 1024,
7223 max_response_body: 16,
7224 max_inflight_requests: 1024,
7225 method: None,
7226 tls_config: None,
7227 };
7228 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7229
7230 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7231 let token = tokio_util::sync::CancellationToken::new();
7232 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7233 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7234 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7235
7236 let client = reqwest::Client::new();
7237 let send_fut = client
7238 .get(format!("http://127.0.0.1:{port}/limit-stream"))
7239 .send();
7240
7241 let (http_result, _) = tokio::join!(send_fut, async {
7242 if let Some(mut envelope) = rx.recv().await {
7243 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
7244 vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
7245 let stream = Box::pin(stream::iter(chunks));
7246 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
7247 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
7248 metadata: StreamMetadata {
7249 size_hint: Some(32),
7250 content_type: Some("application/octet-stream".into()),
7251 origin: None,
7252 },
7253 });
7254 if let Some(reply_tx) = envelope.reply_tx {
7255 let _ = reply_tx.send(Ok(envelope.exchange));
7256 }
7257 }
7258 });
7259
7260 let resp = http_result.unwrap();
7261 assert_eq!(resp.status().as_u16(), 200);
7262 let body = resp.bytes().await.unwrap();
7263 assert_eq!(body.len(), 32);
7264 token.cancel();
7265 }
7266
7267 #[tokio::test]
7272 #[allow(clippy::await_holding_lock)]
7273 async fn test_integration_single_consumer_round_trip() {
7274 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7275
7276 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7280
7281 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7283 let port = listener.local_addr().unwrap().port();
7284 drop(listener); let component = HttpComponent::new();
7287 let endpoint_ctx = NoOpComponentContext;
7288 let endpoint = component
7289 .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
7290 .unwrap();
7291 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7292
7293 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7294 let token = tokio_util::sync::CancellationToken::new();
7295 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7296
7297 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7298 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7299
7300 let client = reqwest::Client::new();
7301 let send_fut = client
7302 .post(format!("http://127.0.0.1:{port}/echo"))
7303 .header("Content-Type", "text/plain")
7304 .body("ping")
7305 .send();
7306
7307 let (http_result, _) = tokio::join!(send_fut, async {
7308 if let Some(mut envelope) = rx.recv().await {
7309 assert_eq!(
7310 envelope.exchange.input.header("CamelHttpMethod"),
7311 Some(&serde_json::Value::String("POST".into()))
7312 );
7313 assert_eq!(
7314 envelope.exchange.input.header("CamelHttpPath"),
7315 Some(&serde_json::Value::String("/echo".into()))
7316 );
7317 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
7318 if let Some(reply_tx) = envelope.reply_tx {
7319 let _ = reply_tx.send(Ok(envelope.exchange));
7320 }
7321 }
7322 });
7323
7324 let resp = http_result.unwrap();
7325 assert_eq!(resp.status().as_u16(), 200);
7326 let body = resp.text().await.unwrap();
7327 assert_eq!(body, "pong");
7328
7329 token.cancel();
7330 }
7331
7332 #[tokio::test]
7333 #[allow(clippy::await_holding_lock)]
7334 async fn test_integration_two_consumers_shared_port() {
7335 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7336
7337 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7338
7339 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7341 let port = listener.local_addr().unwrap().port();
7342 drop(listener);
7343
7344 let component = HttpComponent::new();
7345 let endpoint_ctx = NoOpComponentContext;
7346
7347 let endpoint_a = component
7349 .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
7350 .unwrap();
7351 let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
7352
7353 let endpoint_b = component
7355 .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
7356 .unwrap();
7357 let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
7358
7359 let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7360 let token_a = tokio_util::sync::CancellationToken::new();
7361 let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
7362
7363 let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7364 let token_b = tokio_util::sync::CancellationToken::new();
7365 let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
7366
7367 tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
7368 tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
7369 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7370
7371 let client = reqwest::Client::new();
7372
7373 let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
7375 let (resp_hello, _) = tokio::join!(fut_hello, async {
7376 if let Some(mut envelope) = rx_a.recv().await {
7377 envelope.exchange.input.body =
7378 camel_component_api::Body::Text("hello-response".to_string());
7379 if let Some(reply_tx) = envelope.reply_tx {
7380 let _ = reply_tx.send(Ok(envelope.exchange));
7381 }
7382 }
7383 });
7384
7385 let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
7387 let (resp_world, _) = tokio::join!(fut_world, async {
7388 if let Some(mut envelope) = rx_b.recv().await {
7389 envelope.exchange.input.body =
7390 camel_component_api::Body::Text("world-response".to_string());
7391 if let Some(reply_tx) = envelope.reply_tx {
7392 let _ = reply_tx.send(Ok(envelope.exchange));
7393 }
7394 }
7395 });
7396
7397 let body_a = resp_hello.unwrap().text().await.unwrap();
7398 let body_b = resp_world.unwrap().text().await.unwrap();
7399
7400 assert_eq!(body_a, "hello-response");
7401 assert_eq!(body_b, "world-response");
7402
7403 token_a.cancel();
7404 token_b.cancel();
7405 }
7406
7407 #[tokio::test]
7408 #[allow(clippy::await_holding_lock)]
7409 async fn test_integration_unregistered_path_returns_404() {
7410 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7411
7412 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7413
7414 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7416 let port = listener.local_addr().unwrap().port();
7417 drop(listener);
7418
7419 let component = HttpComponent::new();
7420 let endpoint_ctx = NoOpComponentContext;
7421 let endpoint = component
7422 .create_endpoint(
7423 &format!("http://127.0.0.1:{port}/registered"),
7424 &endpoint_ctx,
7425 )
7426 .unwrap();
7427 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7428
7429 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7430 let token = tokio_util::sync::CancellationToken::new();
7431 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7432
7433 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7434
7435 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
7437 loop {
7438 if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
7439 .await
7440 .is_ok()
7441 {
7442 break;
7443 }
7444 if std::time::Instant::now() >= deadline {
7445 panic!("HTTP server did not start within 5s on port {port}");
7446 }
7447 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
7448 }
7449
7450 let client = reqwest::Client::new();
7451 let resp = client
7452 .get(format!("http://127.0.0.1:{port}/not-there"))
7453 .send()
7454 .await
7455 .unwrap();
7456 assert_eq!(resp.status().as_u16(), 404);
7457
7458 token.cancel();
7459 }
7460
7461 #[test]
7462 fn test_http_consumer_declares_concurrent() {
7463 use camel_component_api::ConcurrencyModel;
7464
7465 let config = HttpServerConfig {
7466 scheme: "http".to_string(),
7467 host: "127.0.0.1".to_string(),
7468 port: 19999,
7469 path: "/test".to_string(),
7470 max_request_body: 2 * 1024 * 1024,
7471 max_response_body: 10 * 1024 * 1024,
7472 max_inflight_requests: 1024,
7473 method: None,
7474 tls_config: None,
7475 };
7476 let consumer = HttpConsumer::new(config, test_rt());
7477 assert_eq!(
7478 consumer.concurrency_model(),
7479 ConcurrencyModel::Concurrent { max: None }
7480 );
7481 }
7482
7483 #[test]
7484 fn server_config_parses_tls_cert_and_key() {
7485 let cfg = HttpServerConfig::from_uri(
7486 "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
7487 )
7488 .unwrap();
7489 assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
7490 assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
7491 }
7492
7493 #[test]
7494 fn server_config_no_tls_when_params_absent() {
7495 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
7496 assert!(cfg.tls_config.is_none());
7497 }
7498
7499 #[tokio::test]
7504 async fn test_http_reply_body_stream_variant_exists() {
7505 use bytes::Bytes;
7506 use camel_component_api::CamelError;
7507 use futures::stream;
7508
7509 let chunks: Vec<Result<Bytes, CamelError>> =
7510 vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
7511 let stream = Box::pin(stream::iter(chunks));
7512 let reply_body = HttpReplyBody::Stream(stream);
7513 match reply_body {
7515 HttpReplyBody::Stream(_) => {}
7516 HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
7517 }
7518 }
7519
7520 #[cfg(feature = "otel")]
7525 mod otel_tests {
7526 use super::*;
7527 use camel_component_api::Message;
7528 use tower::ServiceExt;
7529
7530 #[tokio::test]
7531 async fn test_producer_injects_traceparent_header() {
7532 let (url, _handle) = start_test_server_with_header_capture().await;
7533 let ctx = test_producer_ctx();
7534
7535 let component = HttpComponent::new();
7536 let endpoint_ctx = NoOpComponentContext;
7537 let endpoint = component
7538 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
7539 .unwrap();
7540 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7541
7542 let mut exchange = Exchange::new(Message::default());
7544 let mut headers = std::collections::HashMap::new();
7545 headers.insert(
7546 "traceparent".to_string(),
7547 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
7548 );
7549 camel_otel::extract_into_exchange(&mut exchange, &headers);
7550
7551 let result = producer.oneshot(exchange).await.unwrap();
7552
7553 let status = result
7555 .input
7556 .header("CamelHttpResponseCode")
7557 .and_then(|v| v.as_u64())
7558 .unwrap();
7559 assert_eq!(status, 200);
7560
7561 let traceparent = result.input.header("X-Received-Traceparent");
7563 assert!(
7564 traceparent.is_some(),
7565 "traceparent header should have been sent"
7566 );
7567
7568 let traceparent_str = traceparent.unwrap().as_str().unwrap();
7569 let parts: Vec<&str> = traceparent_str.split('-').collect();
7571 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
7572 assert_eq!(parts[0], "00", "version should be 00");
7573 assert_eq!(
7574 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
7575 "trace-id should match"
7576 );
7577 assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
7578 assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
7579 }
7580
7581 #[tokio::test]
7582 async fn test_consumer_extracts_traceparent_header() {
7583 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7584
7585 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7587 let port = listener.local_addr().unwrap().port();
7588 drop(listener);
7589
7590 let component = HttpComponent::new();
7591 let endpoint_ctx = NoOpComponentContext;
7592 let endpoint = component
7593 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
7594 .unwrap();
7595 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7596
7597 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7598 let token = tokio_util::sync::CancellationToken::new();
7599 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7600
7601 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7602 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7603
7604 let client = reqwest::Client::new();
7606 let send_fut = client
7607 .post(format!("http://127.0.0.1:{port}/trace"))
7608 .header(
7609 "traceparent",
7610 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
7611 )
7612 .body("test")
7613 .send();
7614
7615 let (http_result, _) = tokio::join!(send_fut, async {
7616 if let Some(envelope) = rx.recv().await {
7617 let mut injected_headers = std::collections::HashMap::new();
7620 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
7621
7622 assert!(
7623 injected_headers.contains_key("traceparent"),
7624 "Exchange should have traceparent after extraction"
7625 );
7626
7627 let traceparent = injected_headers.get("traceparent").unwrap();
7628 let parts: Vec<&str> = traceparent.split('-').collect();
7629 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
7630 assert_eq!(
7631 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
7632 "Trace ID should match the original traceparent header"
7633 );
7634
7635 if let Some(reply_tx) = envelope.reply_tx {
7636 let _ = reply_tx.send(Ok(envelope.exchange));
7637 }
7638 }
7639 });
7640
7641 let resp = http_result.unwrap();
7642 assert_eq!(resp.status().as_u16(), 200);
7643
7644 token.cancel();
7645 }
7646
7647 #[tokio::test]
7648 async fn test_consumer_extracts_mixed_case_traceparent_header() {
7649 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7650
7651 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7653 let port = listener.local_addr().unwrap().port();
7654 drop(listener);
7655
7656 let component = HttpComponent::new();
7657 let endpoint_ctx = NoOpComponentContext;
7658 let endpoint = component
7659 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
7660 .unwrap();
7661 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7662
7663 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7664 let token = tokio_util::sync::CancellationToken::new();
7665 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7666
7667 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7668 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7669
7670 let client = reqwest::Client::new();
7672 let send_fut = client
7673 .post(format!("http://127.0.0.1:{port}/trace"))
7674 .header(
7675 "TraceParent",
7676 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
7677 )
7678 .body("test")
7679 .send();
7680
7681 let (http_result, _) = tokio::join!(send_fut, async {
7682 if let Some(envelope) = rx.recv().await {
7683 let mut injected_headers = HashMap::new();
7686 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
7687
7688 assert!(
7689 injected_headers.contains_key("traceparent"),
7690 "Exchange should have traceparent after extraction from mixed-case header"
7691 );
7692
7693 let traceparent = injected_headers.get("traceparent").unwrap();
7694 let parts: Vec<&str> = traceparent.split('-').collect();
7695 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
7696 assert_eq!(
7697 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
7698 "Trace ID should match the original mixed-case TraceParent header"
7699 );
7700
7701 if let Some(reply_tx) = envelope.reply_tx {
7702 let _ = reply_tx.send(Ok(envelope.exchange));
7703 }
7704 }
7705 });
7706
7707 let resp = http_result.unwrap();
7708 assert_eq!(resp.status().as_u16(), 200);
7709
7710 token.cancel();
7711 }
7712
7713 #[tokio::test]
7714 async fn test_producer_no_trace_context_no_crash() {
7715 let (url, _handle) = start_test_server().await;
7716 let ctx = test_producer_ctx();
7717
7718 let component = HttpComponent::new();
7719 let endpoint_ctx = NoOpComponentContext;
7720 let endpoint = component
7721 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
7722 .unwrap();
7723 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7724
7725 let exchange = Exchange::new(Message::default());
7727
7728 let result = producer.oneshot(exchange).await.unwrap();
7730
7731 let status = result
7733 .input
7734 .header("CamelHttpResponseCode")
7735 .and_then(|v| v.as_u64())
7736 .unwrap();
7737 assert_eq!(status, 200);
7738 }
7739
7740 async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
7742 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7743 let addr = listener.local_addr().unwrap();
7744 let url = format!("http://127.0.0.1:{}", addr.port());
7745
7746 let handle = tokio::spawn(async move {
7747 loop {
7748 if let Ok((mut stream, _)) = listener.accept().await {
7749 tokio::spawn(async move {
7750 use tokio::io::{AsyncReadExt, AsyncWriteExt};
7751 let mut buf = vec![0u8; 8192];
7752 let n = stream.read(&mut buf).await.unwrap_or(0);
7753 let request = String::from_utf8_lossy(&buf[..n]).to_string();
7754
7755 let traceparent = request
7757 .lines()
7758 .find(|line| line.to_lowercase().starts_with("traceparent:"))
7759 .map(|line| {
7760 line.split(':')
7761 .nth(1)
7762 .map(|s| s.trim().to_string())
7763 .unwrap_or_default()
7764 })
7765 .unwrap_or_default();
7766
7767 let body =
7768 format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
7769 let response = format!(
7770 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
7771 body.len(),
7772 traceparent,
7773 body
7774 );
7775 let _ = stream.write_all(response.as_bytes()).await;
7776 });
7777 }
7778 }
7779 });
7780
7781 (url, handle)
7782 }
7783 }
7784
7785 #[tokio::test]
7794 async fn test_request_body_arrives_as_stream() {
7795 use camel_component_api::Body;
7796 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7797
7798 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7799 let port = listener.local_addr().unwrap().port();
7800 drop(listener);
7801
7802 let component = HttpComponent::new();
7803 let endpoint_ctx = NoOpComponentContext;
7804 let endpoint = component
7805 .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
7806 .unwrap();
7807 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7808
7809 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7810 let token = tokio_util::sync::CancellationToken::new();
7811 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7812
7813 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7814 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7815
7816 let client = reqwest::Client::new();
7817 let send_fut = client
7818 .post(format!("http://127.0.0.1:{port}/upload"))
7819 .body("hello streaming world")
7820 .send();
7821
7822 let (http_result, _) = tokio::join!(send_fut, async {
7823 if let Some(mut envelope) = rx.recv().await {
7824 assert!(
7826 matches!(envelope.exchange.input.body, Body::Stream(_)),
7827 "expected Body::Stream, got discriminant {:?}",
7828 std::mem::discriminant(&envelope.exchange.input.body)
7829 );
7830 let bytes = envelope
7832 .exchange
7833 .input
7834 .body
7835 .into_bytes(1024 * 1024)
7836 .await
7837 .unwrap();
7838 assert_eq!(&bytes[..], b"hello streaming world");
7839
7840 envelope.exchange.input.body = camel_component_api::Body::Empty;
7841 if let Some(reply_tx) = envelope.reply_tx {
7842 let _ = reply_tx.send(Ok(envelope.exchange));
7843 }
7844 }
7845 });
7846
7847 let resp = http_result.unwrap();
7848 assert_eq!(resp.status().as_u16(), 200);
7849
7850 token.cancel();
7851 }
7852
7853 #[tokio::test]
7858 async fn test_streaming_response_chunked() {
7859 use bytes::Bytes;
7860 use camel_component_api::Body;
7861 use camel_component_api::CamelError;
7862 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7863 use camel_component_api::{StreamBody, StreamMetadata};
7864 use futures::stream;
7865 use std::sync::Arc;
7866 use tokio::sync::Mutex;
7867
7868 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7869 let port = listener.local_addr().unwrap().port();
7870 drop(listener);
7871
7872 let component = HttpComponent::new();
7873 let endpoint_ctx = NoOpComponentContext;
7874 let endpoint = component
7875 .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
7876 .unwrap();
7877 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7878
7879 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7880 let token = tokio_util::sync::CancellationToken::new();
7881 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7882
7883 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7884 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7885
7886 let client = reqwest::Client::new();
7887 let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
7888
7889 let (http_result, _) = tokio::join!(send_fut, async {
7890 if let Some(mut envelope) = rx.recv().await {
7891 let chunks: Vec<Result<Bytes, CamelError>> =
7893 vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
7894 let stream = Box::pin(stream::iter(chunks));
7895 envelope.exchange.input.body = Body::Stream(StreamBody {
7896 stream: Arc::new(Mutex::new(Some(stream))),
7897 metadata: StreamMetadata::default(),
7898 });
7899 if let Some(reply_tx) = envelope.reply_tx {
7900 let _ = reply_tx.send(Ok(envelope.exchange));
7901 }
7902 }
7903 });
7904
7905 let resp = http_result.unwrap();
7906 assert_eq!(resp.status().as_u16(), 200);
7907 let body = resp.text().await.unwrap();
7908 assert_eq!(body, "chunk1chunk2");
7909
7910 token.cancel();
7911 }
7912
7913 #[tokio::test]
7918 async fn test_413_when_content_length_exceeds_limit() {
7919 use camel_component_api::ConsumerContext;
7920
7921 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7922 let port = listener.local_addr().unwrap().port();
7923 drop(listener);
7924
7925 let component = HttpComponent::new();
7927 let endpoint_ctx = NoOpComponentContext;
7928 let endpoint = component
7929 .create_endpoint(
7930 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
7931 &endpoint_ctx,
7932 )
7933 .unwrap();
7934 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7935
7936 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7937 let token = tokio_util::sync::CancellationToken::new();
7938 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7939
7940 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7941 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7942
7943 let client = reqwest::Client::new();
7944 let resp = client
7945 .post(format!("http://127.0.0.1:{port}/upload"))
7946 .header("Content-Length", "1000") .body("x".repeat(1000))
7948 .send()
7949 .await
7950 .unwrap();
7951
7952 assert_eq!(resp.status().as_u16(), 413);
7953
7954 token.cancel();
7955 }
7956
7957 #[tokio::test]
7961 async fn test_chunked_upload_without_content_length_bypasses_limit() {
7962 use bytes::Bytes;
7963 use camel_component_api::Body;
7964 use camel_component_api::ConsumerContext;
7965 use futures::stream;
7966
7967 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7968 let port = listener.local_addr().unwrap().port();
7969 drop(listener);
7970
7971 let component = HttpComponent::new();
7973 let endpoint_ctx = NoOpComponentContext;
7974 let endpoint = component
7975 .create_endpoint(
7976 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
7977 &endpoint_ctx,
7978 )
7979 .unwrap();
7980 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7981
7982 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7983 let token = tokio_util::sync::CancellationToken::new();
7984 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7985
7986 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7987 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7988
7989 let client = reqwest::Client::new();
7990
7991 let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
7995 Ok(Bytes::from("y".repeat(50))),
7996 Ok(Bytes::from("y".repeat(50))),
7997 ];
7998 let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
7999 let send_fut = client
8000 .post(format!("http://127.0.0.1:{port}/upload"))
8001 .body(stream_body)
8002 .send();
8003
8004 let consumer_fut = async {
8005 match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
8007 Ok(Some(mut envelope)) => {
8008 assert!(
8009 matches!(envelope.exchange.input.body, Body::Stream(_)),
8010 "expected Body::Stream"
8011 );
8012 envelope.exchange.input.body = camel_component_api::Body::Empty;
8013 if let Some(reply_tx) = envelope.reply_tx {
8014 let _ = reply_tx.send(Ok(envelope.exchange));
8015 }
8016 }
8017 Ok(None) => panic!("consumer channel closed unexpectedly"),
8018 Err(_) => {
8019 }
8022 }
8023 };
8024
8025 let (http_result, _) = tokio::join!(send_fut, consumer_fut);
8026
8027 let resp = http_result.unwrap();
8028 assert_ne!(
8035 resp.status().as_u16(),
8036 413,
8037 "chunked upload has no Content-Length to pre-check"
8038 );
8039 assert_eq!(resp.status().as_u16(), 200);
8040
8041 token.cancel();
8042 }
8043
8044 #[test]
8045 fn test_is_private_ip_ranges() {
8046 use camel_api::is_ssrf_blocked_ip;
8047 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(
8066 &"2001:4860:4860::8888".parse().unwrap()
8067 )); }
8069
8070 #[test]
8071 fn test_title_case_header() {
8072 assert_eq!(title_case_header("content-type"), "Content-Type");
8073 assert_eq!(title_case_header("authorization"), "Authorization");
8074 assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
8075 assert_eq!(title_case_header("host"), "Host");
8076 assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
8077 assert_eq!(title_case_header("single"), "Single");
8078 assert_eq!(title_case_header(""), "");
8079 }
8080
8081 #[test]
8082 fn test_resolve_url_combines_path_and_query_sources() {
8083 let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
8084 let mut exchange = Exchange::new(Message::default());
8085 exchange.input.set_header(
8086 "CamelHttpPath",
8087 serde_json::Value::String("next".to_string()),
8088 );
8089 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8090 assert!(url.starts_with("http://example.com/base/next?"));
8091 assert!(url.contains("foo=bar"));
8092
8093 exchange.input.set_header(
8094 "CamelHttpUri",
8095 serde_json::Value::String("http://other.test/root".to_string()),
8096 );
8097 exchange.input.set_header(
8098 "CamelHttpQuery",
8099 serde_json::Value::String("a=1&b=2".to_string()),
8100 );
8101
8102 let override_url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8103 assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
8104 }
8105
8106 fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
8107 let mut exchange = Exchange::new(Message::default());
8108 exchange
8109 .input
8110 .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
8111 exchange.input.set_header(
8112 "CamelHttpQuery",
8113 serde_json::Value::String(query.to_string()),
8114 );
8115 exchange
8116 }
8117
8118 #[test]
8119 fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
8120 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8121 cfg.bridge_endpoint = true;
8122 cfg.query_params
8123 .push(("token".to_string(), "secret".to_string()));
8124 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
8125 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8126 assert_eq!(url, "http://x?token=secret");
8130 assert!(!url.contains("/foo"));
8131 assert!(!url.contains("dropme"));
8132 }
8133
8134 #[test]
8135 fn resolve_url_bridge_endpoint_false_merges_path() {
8136 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8137 cfg.bridge_endpoint = false;
8138 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
8139 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8140 assert!(url.contains("/foo"), "url should contain /foo: {url}");
8141 assert!(
8142 url.contains("dropme=1"),
8143 "url should contain dropme=1: {url}"
8144 );
8145 }
8146
8147 #[test]
8148 fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
8149 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8150 cfg.bridge_endpoint = true;
8151 let mut exchange = Exchange::new(Message::default());
8152 exchange.input.set_header(
8153 "CamelHttpPath",
8154 serde_json::Value::String("/foo".to_string()),
8155 );
8156 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8157 assert_eq!(url, "http://x");
8158 assert!(!url.contains("/foo"));
8159 }
8160
8161 #[test]
8162 fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
8163 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8164 cfg.bridge_endpoint = true;
8165 let mut exchange = Exchange::new(Message::default());
8167 exchange.input.set_header(
8168 "CamelHttpUri",
8169 serde_json::Value::String("http://dest/explicit".to_string()),
8170 );
8171 exchange.input.set_header(
8172 "CamelHttpPath",
8173 serde_json::Value::String("/foo".to_string()),
8174 );
8175 exchange.input.set_header(
8176 "CamelHttpQuery",
8177 serde_json::Value::String("x=1".to_string()),
8178 );
8179 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8180 assert_eq!(url, "http://x");
8184 }
8185
8186 #[test]
8187 fn bridge_programmatic_params_use_percent20() {
8188 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8189 cfg.bridge_endpoint = true;
8190 cfg.query_params = vec![("b".to_string(), "x y".to_string())];
8191 let exchange = Exchange::new(Message::default());
8192
8193 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8194
8195 assert_eq!(url, "http://x?b=x%20y");
8199 assert!(!url.contains('+'));
8200 }
8201
8202 #[test]
8203 fn bridge_arm_carries_authored_raw_query() {
8204 let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
8205 let exchange = exchange_with_path_and_query("/ignored", "dropme=1");
8208
8209 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8210
8211 assert_eq!(url, "http://h/p?a=1");
8214 assert!(!url.contains("dropme"), "exchange query leaked: {url}");
8215 assert!(!url.contains("/ignored"), "exchange path leaked: {url}");
8216 }
8217
8218 #[test]
8225 fn resolve_url_bridge_preserves_dot_segments() {
8226 let mut cfg = HttpEndpointConfig::from_uri("http://h/a/../b").unwrap();
8227 cfg.bridge_endpoint = true;
8228 cfg.query_params.push(("k".to_string(), "1".to_string()));
8229 let exchange = Exchange::new(Message::default());
8230
8231 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8232
8233 assert_eq!(url, "http://h/a/../b?k=1");
8236 }
8237
8238 #[test]
8239 fn resolve_url_bridge_preserves_default_port() {
8240 let mut cfg = HttpEndpointConfig::from_uri("http://h:80/p").unwrap();
8241 cfg.bridge_endpoint = true;
8242 cfg.query_params.push(("k".to_string(), "1".to_string()));
8243 let exchange = Exchange::new(Message::default());
8244
8245 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8246
8247 assert_eq!(url, "http://h:80/p?k=1");
8250 }
8251
8252 #[test]
8253 fn resolve_url_bridge_preserves_scheme_and_host_case() {
8254 let mut cfg = HttpEndpointConfig::from_uri("http://ExAMPLE.COM/p").unwrap();
8255 cfg.bridge_endpoint = true;
8256 cfg.query_params.push(("k".to_string(), "1".to_string()));
8257 cfg.base_url = "HTTP://ExAMPLE.COM/p".to_string();
8261 let exchange = Exchange::new(Message::default());
8262
8263 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8264
8265 assert_eq!(url, "HTTP://ExAMPLE.COM/p?k=1");
8268 }
8269
8270 #[test]
8271 fn resolve_url_bridge_no_query_emits_base_verbatim() {
8272 let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8273 cfg.bridge_endpoint = true;
8274 let exchange = Exchange::new(Message::default());
8275
8276 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8277
8278 assert_eq!(url, "http://h/p");
8281 }
8282
8283 #[test]
8284 fn resolve_url_bridge_and_non_bridge_byte_identical() {
8285 let mut bridged = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
8288 bridged.bridge_endpoint = true;
8289 bridged
8290 .query_params
8291 .push(("k".to_string(), "1".to_string()));
8292 let bridge_url =
8293 HttpProducer::resolve_url(&Exchange::new(Message::default()), &bridged).unwrap();
8294
8295 let plain = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
8298 let mut exchange = Exchange::new(Message::default());
8299 exchange.input.set_header(
8300 "CamelHttpQuery",
8301 serde_json::Value::String("k=1".to_string()),
8302 );
8303 let plain_url = HttpProducer::resolve_url(&exchange, &plain).unwrap();
8304
8305 assert_eq!(bridge_url, plain_url);
8306 assert_eq!(bridge_url, "http://H:80/a/../b?k=1");
8307 }
8308
8309 #[test]
8310 fn resolve_url_bridge_preserves_ipv6_authority_verbatim() {
8311 let mut cfg = HttpEndpointConfig::from_uri("http://[::1]:8080/p").unwrap();
8312 cfg.bridge_endpoint = true;
8313 cfg.query_params.push(("k".to_string(), "1".to_string()));
8314 let exchange = Exchange::new(Message::default());
8315
8316 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8317
8318 assert_eq!(url, "http://[::1]:8080/p?k=1");
8319 }
8320
8321 #[test]
8322 fn resolve_url_bridge_empty_base_path_keeps_no_synthetic_slash() {
8323 let cfg = HttpEndpointConfig::from_uri("http://h?x=1&bridgeEndpoint=true").unwrap();
8324 let exchange = Exchange::new(Message::default());
8325
8326 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8327
8328 assert_eq!(url, "http://h?x=1");
8331 }
8332
8333 #[test]
8338 fn resolve_url_preserves_authored_query_order_and_bytes() {
8339 let config =
8340 HttpEndpointConfig::from_uri("http://h/p?a=1&b=x,y&c=t:1&connectTimeout=5000").unwrap();
8341 let exchange = Exchange::new(Message::default());
8342
8343 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8344
8345 assert_eq!(url, "http://h/p?a=1&b=x,y&c=t:1");
8348 }
8349
8350 #[test]
8351 fn resolve_url_consumes_encoded_option_key() {
8352 let config = HttpEndpointConfig::from_uri("http://h/p?connect%54imeout=5000&a=1").unwrap();
8353 let exchange = Exchange::new(Message::default());
8354
8355 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8356
8357 assert_eq!(url, "http://h/p?a=1");
8359 }
8360
8361 #[test]
8362 fn resolve_url_all_options_consumed_drops_query() {
8363 let config = HttpEndpointConfig::from_uri("http://h/p?connectTimeout=5000").unwrap();
8364 let exchange = Exchange::new(Message::default());
8365
8366 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8367
8368 assert_eq!(url, "http://h/p");
8371 assert!(!url.contains('?'));
8372 }
8373
8374 #[test]
8375 fn resolve_url_preserves_empty_query_marker() {
8376 let config = HttpEndpointConfig::from_uri("http://h/p?").unwrap();
8377 let exchange = Exchange::new(Message::default());
8378
8379 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8380
8381 assert_eq!(url, "http://h/p?");
8384 }
8385
8386 #[test]
8387 fn resolve_url_raw_wrapper_not_re_encoded() {
8388 let config = HttpEndpointConfig::from_uri("http://h/p?token=RAW(abc)").unwrap();
8389 let exchange = Exchange::new(Message::default());
8390
8391 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8392
8393 assert_eq!(url, "http://h/p?token=RAW(abc)");
8395 assert!(!url.contains("%28"), "RAW( wrapper re-encoded: {url}");
8396 }
8397
8398 #[test]
8399 fn resolve_url_camel_http_query_composes_verbatim_span() {
8400 let config = HttpEndpointConfig::from_uri("http://h/p?x=1").unwrap();
8401 let mut exchange = Exchange::new(Message::default());
8402 exchange.input.set_header(
8403 "CamelHttpQuery",
8404 serde_json::Value::String("userFilter=a%2Cb".to_string()),
8405 );
8406
8407 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8408
8409 assert_eq!(url, "http://h/p?x=1&userFilter=a%2Cb");
8414 assert!(!url.contains("%252C"), "header bytes re-encoded: {url}");
8415 }
8416
8417 #[test]
8422 fn header_composes_with_endpoint_query() {
8423 let config =
8424 HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret&lang=en").unwrap();
8425 let mut exchange = Exchange::new(Message::default());
8426 exchange.input.set_header(
8427 "CamelHttpQuery",
8428 serde_json::Value::String("lang=es&page=2".to_string()),
8429 );
8430
8431 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8432
8433 assert_eq!(url, "http://upstream/api?apiKey=secret&lang=en&page=2");
8436 }
8437
8438 #[test]
8439 fn header_alone_still_rides() {
8440 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
8441 let mut exchange = Exchange::new(Message::default());
8442 exchange.input.set_header(
8443 "CamelHttpQuery",
8444 serde_json::Value::String("page=2".to_string()),
8445 );
8446
8447 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8448
8449 assert_eq!(url, "http://upstream/api?page=2");
8451 }
8452
8453 #[test]
8454 fn empty_reflected_query_leaves_endpoint_query_intact() {
8455 let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
8456 let mut exchange = Exchange::new(Message::default());
8457 exchange
8460 .input
8461 .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
8462
8463 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8464
8465 assert_eq!(url, "http://upstream/api?apiKey=secret");
8467 assert!(!url.ends_with('?'), "dangling '?' marker: {url}");
8468 }
8469
8470 #[test]
8471 fn forbidden_byte_in_header_query_errors() {
8472 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
8473 let mut exchange = Exchange::new(Message::default());
8474 exchange.input.set_header(
8475 "CamelHttpQuery",
8476 serde_json::Value::String("q=ab<cd".to_string()),
8477 );
8478
8479 let err = HttpProducer::resolve_url(&exchange, &config)
8480 .unwrap_err()
8481 .to_string();
8482
8483 assert!(err.contains("0x3C"), "error must name the byte: {err}");
8486 }
8487
8488 #[test]
8489 fn override_uri_with_query_plus_header_query() {
8490 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
8491 let mut exchange = Exchange::new(Message::default());
8492 exchange.input.set_header(
8493 "CamelHttpUri",
8494 serde_json::Value::String("http://host/api?a=1".to_string()),
8495 );
8496 exchange.input.set_header(
8497 "CamelHttpQuery",
8498 serde_json::Value::String("a=2&b=3".to_string()),
8499 );
8500
8501 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8502
8503 assert_eq!(url, "http://host/api?a=1&b=3");
8506 }
8507
8508 #[test]
8509 fn path_applies_before_query_composition() {
8510 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
8511 let mut exchange = Exchange::new(Message::default());
8512 exchange.input.set_header(
8513 "CamelHttpUri",
8514 serde_json::Value::String("http://host/api?a=1".to_string()),
8515 );
8516 exchange.input.set_header(
8517 "CamelHttpPath",
8518 serde_json::Value::String("/extra".to_string()),
8519 );
8520 exchange.input.set_header(
8521 "CamelHttpQuery",
8522 serde_json::Value::String("b=2".to_string()),
8523 );
8524
8525 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8526
8527 assert_eq!(url, "http://host/api/extra?a=1&b=2");
8530 }
8531
8532 #[test]
8533 fn plain_proxy_reflection_composes() {
8534 let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
8535 let exchange = exchange_with_path_and_query("/in/extra", "page=2");
8537
8538 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8539
8540 assert_eq!(url, "http://upstream/api/in/extra?apiKey=secret&page=2");
8543 }
8544
8545 #[test]
8546 fn bridge_endpoint_ignores_url_headers() {
8547 let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
8548 let mut exchange = Exchange::new(Message::default());
8549 exchange.input.set_header(
8550 "CamelHttpUri",
8551 serde_json::Value::String("http://evil.test/x".to_string()),
8552 );
8553 exchange.input.set_header(
8554 "CamelHttpPath",
8555 serde_json::Value::String("/foo".to_string()),
8556 );
8557 exchange.input.set_header(
8558 "CamelHttpQuery",
8559 serde_json::Value::String("z=9".to_string()),
8560 );
8561
8562 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8563
8564 assert_eq!(url, "http://h/p?a=1");
8567 assert!(!url.contains("evil"), "override leaked: {url}");
8568 assert!(!url.contains("z=9"), "header query leaked: {url}");
8569 assert!(!url.contains("/foo"), "header path leaked: {url}");
8570 }
8571
8572 #[test]
8573 fn resolve_url_programmatic_params_use_percent20_deterministic() {
8574 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8575 config.query_params = vec![
8576 ("b".to_string(), "x y".to_string()),
8577 ("a".to_string(), "1".to_string()),
8578 ];
8579 let exchange = Exchange::new(Message::default());
8580
8581 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8582
8583 assert_eq!(url, "http://h/p?b=x%20y&a=1");
8586 assert!(!url.contains('+'));
8587 }
8588
8589 #[test]
8590 fn resolve_url_authored_and_programmatic_merge() {
8591 let mut config = HttpEndpointConfig::from_uri("http://h/p?a=1&c=t:1").unwrap();
8592 config.query_params = vec![
8593 ("b".to_string(), "2".to_string()),
8594 ("a".to_string(), "9".to_string()),
8595 ];
8596 let exchange = Exchange::new(Message::default());
8597
8598 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8599
8600 assert_eq!(url, "http://h/p?a=1&c=t:1&b=2");
8603 }
8604
8605 #[test]
8606 fn from_uri_no_longer_fills_query_params_from_uri() {
8607 let config = HttpEndpointConfig::from_uri("http://h/p?a=1&connectTimeout=5000").unwrap();
8608
8609 assert!(
8611 config.query_params.is_empty(),
8612 "query_params is programmatic-only: {:?}",
8613 config.query_params
8614 );
8615 assert_eq!(config.raw_query.as_deref(), Some("a=1&connectTimeout=5000"));
8616 }
8617
8618 #[test]
8619 fn resolve_url_forbidden_raw_byte_errors() {
8620 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8621 config.raw_query = Some("a=x y".to_string());
8622 let exchange = Exchange::new(Message::default());
8623
8624 let err = HttpProducer::resolve_url(&exchange, &config)
8625 .expect_err("literal space in raw query must error");
8626
8627 assert!(
8629 err.to_string().contains("0x20"),
8630 "error must name the forbidden byte: {err}"
8631 );
8632 }
8633
8634 #[test]
8638 fn resolve_url_override_query_forbidden_byte_errors() {
8639 let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8640 let mut exchange = Exchange::new(Message::default());
8641 exchange.input.set_header(
8642 "CamelHttpUri",
8643 serde_json::Value::String("http://h2/p?a=x y".to_string()),
8644 );
8645
8646 let err = HttpProducer::resolve_url(&exchange, &config)
8647 .expect_err("literal space in the override URI's query must error");
8648
8649 assert!(
8650 err.to_string().contains("0x20"),
8651 "error must name the forbidden byte from the override query: {err}"
8652 );
8653 }
8654
8655 #[test]
8660 fn merge_header_query_decoded_key_collision_drops_header_pair() {
8661 let merged = merge_header_query(Some("a=1"), "%61=2")
8662 .expect("decoded-key collision must not be a parse error");
8663 assert_eq!(
8664 merged.as_deref(),
8665 Some("a=1"),
8666 "the higher-precedence span wins and the colliding header pair is dropped"
8667 );
8668 }
8669
8670 #[test]
8673 fn merge_header_query_duplicate_keys_within_header_ride_verbatim() {
8674 let merged = merge_header_query(None, "k=1&k=2")
8675 .expect("duplicate header keys must not be a parse error");
8676 assert_eq!(
8677 merged.as_deref(),
8678 Some("k=1&k=2"),
8679 "intra-header duplicate keys ride verbatim"
8680 );
8681 }
8682
8683 #[test]
8686 fn endpoint_config_debug_masks_base_url_userinfo() {
8687 let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
8688 config.base_url = "http://user:pass@h.example/p".to_string();
8689 let rendered = format!("{config:?}");
8690 assert!(
8691 rendered.contains("***@h.example"),
8692 "userinfo must render masked: {rendered}"
8693 );
8694 assert!(
8695 !rendered.contains("user:pass"),
8696 "no credentials in Debug output: {rendered}"
8697 );
8698
8699 let plain = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
8700 let rendered_plain = format!("{plain:?}");
8701 assert!(
8702 rendered_plain.contains("http://h.example/p"),
8703 "a base without userinfo renders unchanged: {rendered_plain}"
8704 );
8705 }
8706
8707 #[test]
8713 fn resolve_url_authored_apostrophe_rejected_percent_escape_rides() {
8714 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8715
8716 config.raw_query = Some("q=it's".to_string());
8717 let exchange = Exchange::new(Message::default());
8718 let err = HttpProducer::resolve_url(&exchange, &config)
8719 .expect_err("authored apostrophe must be rejected, not silently %27-normalized");
8720 assert!(
8721 err.to_string().contains("0x27"),
8722 "error must name the apostrophe byte: {err}"
8723 );
8724
8725 config.raw_query = Some("q=it%27s".to_string());
8726 let url = HttpProducer::resolve_url(&exchange, &config)
8727 .expect("authored %27 escape is wire-legal");
8728 assert!(
8729 url.contains("q=it%27s"),
8730 "the authored escape must ride byte-for-byte: {url}"
8731 );
8732
8733 for &byte in b"\"`<>" {
8737 config.raw_query = Some(format!("k={}x", byte as char));
8738 let err = HttpProducer::resolve_url(&exchange, &config)
8739 .expect_err("WHATWG special-query byte must be rejected");
8740 assert!(
8741 err.to_string().contains(&format!("0x{byte:02X}")),
8742 "error must name byte 0x{byte:02X}: {err}"
8743 );
8744 }
8745 }
8746
8747 #[test]
8748 fn armed_fence_rejects_unknown_host_redacted() {
8749 let cfg = HttpEndpointConfig::from_uri(
8750 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
8751 )
8752 .unwrap();
8753 let mut exchange = Exchange::new(Message::default());
8754 exchange.input.set_header(
8755 "CamelHttpUri",
8756 serde_json::Value::String(
8757 "http://user:pass@evil.example.com/x?token=s3cret".to_string(),
8758 ),
8759 );
8760
8761 let err = HttpProducer::resolve_url(&exchange, &cfg)
8762 .expect_err("override host outside the fence must fail resolution");
8763
8764 let message = err.to_string();
8765 assert!(!message.contains("pass"), "userinfo leaked: {message}");
8766 assert!(!message.contains("s3cret"), "query leaked: {message}");
8767 }
8768
8769 #[test]
8770 fn armed_fence_rejects_password_only_userinfo_redacted() {
8771 let cfg = HttpEndpointConfig::from_uri(
8772 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
8773 )
8774 .unwrap();
8775 let mut exchange = Exchange::new(Message::default());
8776 exchange.input.set_header(
8777 "CamelHttpUri",
8778 serde_json::Value::String(
8779 "http://:passwordonly@evil.example.com/x?token=querysecret".to_string(),
8780 ),
8781 );
8782
8783 let err = HttpProducer::resolve_url(&exchange, &cfg)
8784 .expect_err("password-only override outside the fence must fail resolution");
8785
8786 let message = err.to_string();
8787 assert!(
8788 !message.contains("passwordonly"),
8789 "password-only userinfo leaked: {message}"
8790 );
8791 assert!(!message.contains("querysecret"), "query leaked: {message}");
8792 assert!(
8793 message.contains("http://***@evil.example.com/x?[redacted]"),
8794 "masked shape missing: {message}"
8795 );
8796 }
8797
8798 #[test]
8799 fn armed_fence_allows_listed_host() {
8800 let cfg = HttpEndpointConfig::from_uri(
8801 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
8802 )
8803 .unwrap();
8804 let mut exchange = Exchange::new(Message::default());
8805 exchange.input.set_header(
8806 "CamelHttpUri",
8807 serde_json::Value::String("http://cdn.example.com/x".to_string()),
8808 );
8809
8810 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8811 assert_eq!(url, "http://cdn.example.com/x");
8812 }
8813
8814 #[test]
8815 fn host_only_entry_permits_any_port() {
8816 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=cdn.example.com").unwrap();
8817 let mut exchange = Exchange::new(Message::default());
8818 exchange.input.set_header(
8819 "CamelHttpUri",
8820 serde_json::Value::String("http://cdn.example.com:9443/x".to_string()),
8821 );
8822
8823 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8824 assert_eq!(url, "http://cdn.example.com:9443/x");
8825 }
8826
8827 #[test]
8828 fn unarmed_endpoint_unchanged() {
8829 let cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8830 let mut exchange = Exchange::new(Message::default());
8831 exchange.input.set_header(
8832 "CamelHttpUri",
8833 serde_json::Value::String("http://any.example.com/path".to_string()),
8834 );
8835
8836 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8837 assert_eq!(url, "http://any.example.com/path");
8838 }
8839
8840 #[test]
8841 fn empty_allowlist_fails_endpoint_creation() {
8842 assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=,,").is_err());
8843 }
8844
8845 #[test]
8846 fn malformed_entry_fails_endpoint_creation() {
8847 assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=not a host!").is_err());
8848 }
8849
8850 #[test]
8851 fn fence_entry_with_path_fails_creation() {
8852 assert!(
8855 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=api.internal:8443/v2").is_err()
8856 );
8857 }
8858
8859 #[test]
8860 fn fence_entry_with_userinfo_fails_creation() {
8861 assert!(
8862 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=user@cdn.example.com").is_err()
8863 );
8864 }
8865
8866 #[test]
8867 fn ipv6_fence_entry_allows_bracketed_host() {
8868 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=[::1]:8443").unwrap();
8869 for uri in ["http://[::1]:8443/x", "http://[0:0:0:0:0:0:0:1]:8443/x"] {
8872 let mut exchange = Exchange::new(Message::default());
8873 exchange
8874 .input
8875 .set_header("CamelHttpUri", serde_json::Value::String(uri.to_string()));
8876 let url = HttpProducer::resolve_url(&exchange, &cfg)
8877 .unwrap_or_else(|e| panic!("override {uri} must be honored: {e}"));
8878 assert_eq!(url, uri, "bracketed IPv6 override not honored");
8879 }
8880 }
8881
8882 #[test]
8883 fn dns_case_insensitive_fence_match() {
8884 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=CDN.Example.COM").unwrap();
8887 let mut exchange = Exchange::new(Message::default());
8888 exchange.input.set_header(
8889 "CamelHttpUri",
8890 serde_json::Value::String("http://cdn.example.com/x".to_string()),
8891 );
8892 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8893 assert_eq!(url, "http://cdn.example.com/x");
8894 }
8895
8896 #[test]
8897 fn fence_allowed_override_query_merges_with_header() {
8898 let cfg =
8901 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example&k=v").unwrap();
8902 let mut exchange = Exchange::new(Message::default());
8903 exchange.input.set_header(
8904 "CamelHttpUri",
8905 serde_json::Value::String("http://host.example/api?a=1".to_string()),
8906 );
8907 exchange.input.set_header(
8908 "CamelHttpQuery",
8909 serde_json::Value::String("b=2".to_string()),
8910 );
8911
8912 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8913 assert_eq!(url, "http://host.example/api?a=1&b=2");
8914 }
8915
8916 #[test]
8917 fn empty_header_with_armed_fence_leaves_no_query() {
8918 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example").unwrap();
8919 let mut exchange = Exchange::new(Message::default());
8920 exchange.input.set_header(
8921 "CamelHttpUri",
8922 serde_json::Value::String("http://host.example/api".to_string()),
8923 );
8924 exchange
8925 .input
8926 .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
8927
8928 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8929 assert_eq!(url, "http://host.example/api");
8930 assert!(!url.contains('?'), "query marker leaked: {url}");
8931 }
8932
8933 #[test]
8934 fn fence_option_is_consumed() {
8935 let cfg =
8939 HttpEndpointConfig::from_uri("http://h/p?x=1&allowedUriHosts=cdn.example.com").unwrap();
8940 let exchange = Exchange::new(Message::default());
8941
8942 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8943 assert!(!url.contains("allowedUriHosts"), "option leaked: {url}");
8944 assert!(url.contains("x=1"), "authored query lost: {url}");
8945 }
8946
8947 #[tokio::test]
8948 async fn resolve_url_malformed_base_url_errors_no_panic() {
8949 use tower::ServiceExt;
8950
8951 let (url, _handle) = start_test_server().await;
8952 let mut config = HttpEndpointConfig::from_uri("http://[::1:bad").unwrap();
8953 config.allow_internal = true; let producer = HttpProducer {
8955 config: Arc::new(config),
8956 client: build_client(&HttpConfig::default(), None),
8957 pinned_cache: Arc::new(PinnedClientCache::new(
8958 PINNED_CLIENT_TTL,
8959 PINNED_CLIENT_MAX_ENTRIES,
8960 )),
8961 http_config: Arc::new(HttpConfig::default()),
8962 runtime: rt(),
8963 };
8964
8965 let first = producer
8968 .clone()
8969 .oneshot(Exchange::new(Message::default()))
8970 .await;
8971 let err = first.expect_err("malformed base URL must error, not panic");
8972 assert!(
8973 err.to_string().to_lowercase().contains("url"),
8974 "error must name the malformed URL: {err}"
8975 );
8976
8977 let mut exchange = Exchange::new(Message::default());
8980 exchange.input.set_header(
8981 "CamelHttpUri",
8982 serde_json::Value::String(format!("{url}/api")),
8983 );
8984 let response = producer
8985 .oneshot(exchange)
8986 .await
8987 .expect("valid request through same producer must succeed");
8988 let status = response
8989 .input
8990 .header("CamelHttpResponseCode")
8991 .and_then(|v| v.as_u64())
8992 .unwrap();
8993 assert_eq!(status, 200);
8994 }
8995
8996 #[test]
8997 fn resolve_url_bridge_malformed_base_errors_no_panic() {
8998 let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8999 cfg.bridge_endpoint = true;
9000 cfg.query_params.push(("k".to_string(), "1".to_string()));
9001 cfg.base_url = "http://[::1:bad".to_string();
9006 let exchange = Exchange::new(Message::default());
9007
9008 let err = HttpProducer::resolve_url(&exchange, &cfg)
9009 .expect_err("malformed bridge base URL must error");
9010 assert!(
9011 err.to_string().contains("invalid base URL"),
9012 "error must name the invalid base URL: {err}"
9013 );
9014 }
9015
9016 #[test]
9017 fn test_http_producer_helpers_status_and_size_boundaries() {
9018 assert!(HttpProducer::is_ok_status(200, (200, 299)));
9019 assert!(HttpProducer::is_ok_status(299, (200, 299)));
9020 assert!(!HttpProducer::is_ok_status(199, (200, 299)));
9021 assert!(!HttpProducer::is_ok_status(300, (200, 299)));
9022
9023 assert!(!exceeds_max_response_body(10, 10));
9024 assert!(exceeds_max_response_body(11, 10));
9025 }
9026
9027 async fn setup_consumer_on_free_port(
9032 path: &str,
9033 ) -> (
9034 u16,
9035 tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
9036 tokio_util::sync::CancellationToken,
9037 ) {
9038 use camel_component_api::ConsumerContext;
9039
9040 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9045 let port = listener.local_addr().unwrap().port();
9046 ServerRegistry::global()
9047 .stage_listener(listener)
9048 .await
9049 .expect("stage consumer test listener");
9050
9051 let consumer_cfg = HttpServerConfig {
9052 scheme: "http".to_string(),
9053 host: "127.0.0.1".to_string(),
9054 port,
9055 path: path.to_string(),
9056 max_request_body: 2 * 1024 * 1024,
9057 max_response_body: 10 * 1024 * 1024,
9058 max_inflight_requests: 1024,
9059 method: None,
9060 tls_config: None,
9061 };
9062 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
9063
9064 let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
9065 let token = tokio_util::sync::CancellationToken::new();
9066 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9067
9068 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9069
9070 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
9076 while ServerRegistry::global()
9077 .bound_addr("127.0.0.1", port)
9078 .is_none()
9079 {
9080 assert!(
9081 tokio::time::Instant::now() < deadline,
9082 "consumer server did not become ready on port {port}"
9083 );
9084 tokio::time::sleep(std::time::Duration::from_millis(1)).await;
9085 }
9086 for _ in 0..8 {
9087 tokio::task::yield_now().await;
9088 }
9089
9090 (port, rx, token)
9091 }
9092
9093 #[tokio::test]
9094 async fn test_content_type_inferred_for_json_body() {
9095 let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
9096
9097 let client = reqwest::Client::new();
9098 let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
9099
9100 let (http_result, _) = tokio::join!(send_fut, async {
9101 if let Some(mut envelope) = rx.recv().await {
9102 envelope.exchange.input.body =
9103 camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
9104 if let Some(reply_tx) = envelope.reply_tx {
9105 let _ = reply_tx.send(Ok(envelope.exchange));
9106 }
9107 }
9108 });
9109
9110 let resp = http_result.unwrap();
9111 assert_eq!(resp.status().as_u16(), 200);
9112 let ct = resp
9113 .headers()
9114 .get("content-type")
9115 .expect("Content-Type header should be present");
9116 assert_eq!(ct, "application/json");
9117 let body = resp.text().await.unwrap();
9118 assert_eq!(body, r#"{"message":"hello"}"#);
9119
9120 token.cancel();
9121 }
9122
9123 #[tokio::test]
9124 async fn test_content_type_inferred_for_text_body() {
9125 let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
9126
9127 let client = reqwest::Client::new();
9128 let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
9129
9130 let (http_result, _) = tokio::join!(send_fut, async {
9131 if let Some(mut envelope) = rx.recv().await {
9132 envelope.exchange.input.body =
9133 camel_component_api::Body::Text("plain text response".to_string());
9134 if let Some(reply_tx) = envelope.reply_tx {
9135 let _ = reply_tx.send(Ok(envelope.exchange));
9136 }
9137 }
9138 });
9139
9140 let resp = http_result.unwrap();
9141 assert_eq!(resp.status().as_u16(), 200);
9142 let ct = resp
9143 .headers()
9144 .get("content-type")
9145 .expect("Content-Type header should be present");
9146 assert_eq!(ct, "text/plain; charset=utf-8");
9147 let body = resp.text().await.unwrap();
9148 assert_eq!(body, "plain text response");
9149
9150 token.cancel();
9151 }
9152
9153 #[tokio::test]
9154 async fn test_content_type_inferred_for_xml_body() {
9155 let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
9156
9157 let client = reqwest::Client::new();
9158 let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
9159
9160 let (http_result, _) = tokio::join!(send_fut, async {
9161 if let Some(mut envelope) = rx.recv().await {
9162 envelope.exchange.input.body =
9163 camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
9164 if let Some(reply_tx) = envelope.reply_tx {
9165 let _ = reply_tx.send(Ok(envelope.exchange));
9166 }
9167 }
9168 });
9169
9170 let resp = http_result.unwrap();
9171 assert_eq!(resp.status().as_u16(), 200);
9172 let ct = resp
9173 .headers()
9174 .get("content-type")
9175 .expect("Content-Type header should be present");
9176 assert_eq!(ct, "application/xml");
9177 let body = resp.text().await.unwrap();
9178 assert_eq!(body, "<root><item>value</item></root>");
9179
9180 token.cancel();
9181 }
9182
9183 #[tokio::test]
9184 async fn test_no_content_type_for_empty_body() {
9185 let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
9186
9187 let client = reqwest::Client::new();
9188 let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
9189
9190 let (http_result, _) = tokio::join!(send_fut, async {
9191 if let Some(mut envelope) = rx.recv().await {
9192 envelope.exchange.input.body = camel_component_api::Body::Empty;
9193 if let Some(reply_tx) = envelope.reply_tx {
9194 let _ = reply_tx.send(Ok(envelope.exchange));
9195 }
9196 }
9197 });
9198
9199 let resp = http_result.unwrap();
9200 assert_eq!(resp.status().as_u16(), 200);
9201 assert!(
9202 resp.headers().get("content-type").is_none(),
9203 "Empty body should not set Content-Type"
9204 );
9205
9206 token.cancel();
9207 }
9208
9209 #[tokio::test]
9210 async fn test_no_content_type_for_raw_bytes_body() {
9211 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
9212
9213 let client = reqwest::Client::new();
9214 let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
9215
9216 let (http_result, _) = tokio::join!(send_fut, async {
9217 if let Some(mut envelope) = rx.recv().await {
9218 envelope.exchange.input.body =
9219 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
9220 if let Some(reply_tx) = envelope.reply_tx {
9221 let _ = reply_tx.send(Ok(envelope.exchange));
9222 }
9223 }
9224 });
9225
9226 let resp = http_result.unwrap();
9227 assert_eq!(resp.status().as_u16(), 200);
9228 assert!(
9229 resp.headers().get("content-type").is_none(),
9230 "Raw Bytes body should not set Content-Type"
9231 );
9232
9233 token.cancel();
9234 }
9235
9236 #[tokio::test]
9237 async fn test_content_type_from_stream_metadata() {
9238 use camel_component_api::{StreamBody, StreamMetadata};
9239 use futures::stream;
9240
9241 let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
9242
9243 let client = reqwest::Client::new();
9244 let send_fut = client
9245 .get(format!("http://127.0.0.1:{port}/stream-ct"))
9246 .send();
9247
9248 let (http_result, _) = tokio::join!(send_fut, async {
9249 if let Some(mut envelope) = rx.recv().await {
9250 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
9251 vec![Ok(bytes::Bytes::from("audio data"))];
9252 let stream = Box::pin(stream::iter(chunks));
9253 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
9254 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
9255 metadata: StreamMetadata {
9256 size_hint: None,
9257 content_type: Some("audio/mpeg".to_string()),
9258 origin: None,
9259 },
9260 });
9261 if let Some(reply_tx) = envelope.reply_tx {
9262 let _ = reply_tx.send(Ok(envelope.exchange));
9263 }
9264 }
9265 });
9266
9267 let resp = http_result.unwrap();
9268 assert_eq!(resp.status().as_u16(), 200);
9269 let ct = resp
9270 .headers()
9271 .get("content-type")
9272 .expect("Content-Type header should be present");
9273 assert_eq!(ct, "audio/mpeg");
9274 let body = resp.text().await.unwrap();
9275 assert_eq!(body, "audio data");
9276
9277 token.cancel();
9278 }
9279
9280 #[tokio::test]
9281 async fn test_user_content_type_overrides_inferred() {
9282 let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
9283
9284 let client = reqwest::Client::new();
9285 let send_fut = client
9286 .get(format!("http://127.0.0.1:{port}/override-ct"))
9287 .send();
9288
9289 let (http_result, _) = tokio::join!(send_fut, async {
9290 if let Some(mut envelope) = rx.recv().await {
9291 envelope.exchange.input.body =
9292 camel_component_api::Body::Json(serde_json::json!({"ok": true}));
9293 envelope.exchange.input.set_header(
9294 "Content-Type",
9295 serde_json::Value::String("text/html".to_string()),
9296 );
9297 if let Some(reply_tx) = envelope.reply_tx {
9298 let _ = reply_tx.send(Ok(envelope.exchange));
9299 }
9300 }
9301 });
9302
9303 let resp = http_result.unwrap();
9304 assert_eq!(resp.status().as_u16(), 200);
9305 let ct = resp
9306 .headers()
9307 .get("content-type")
9308 .expect("Content-Type header should be present");
9309 assert_eq!(
9310 ct, "text/html",
9311 "User-set Content-Type should take precedence over inferred type"
9312 );
9313
9314 token.cancel();
9315 }
9316
9317 #[tokio::test]
9318 async fn test_user_content_type_with_bytes_body() {
9319 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
9320
9321 let client = reqwest::Client::new();
9322 let send_fut = client
9323 .get(format!("http://127.0.0.1:{port}/bytes-ct"))
9324 .send();
9325
9326 let (http_result, _) = tokio::join!(send_fut, async {
9327 if let Some(mut envelope) = rx.recv().await {
9328 envelope.exchange.input.body =
9329 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
9330 envelope.exchange.input.set_header(
9331 "Content-Type",
9332 serde_json::Value::String("application/json".to_string()),
9333 );
9334 if let Some(reply_tx) = envelope.reply_tx {
9335 let _ = reply_tx.send(Ok(envelope.exchange));
9336 }
9337 }
9338 });
9339
9340 let resp = http_result.unwrap();
9341 assert_eq!(resp.status().as_u16(), 200);
9342 let ct = resp
9343 .headers()
9344 .get("content-type")
9345 .expect("Content-Type header should be present for Bytes body with user header");
9346 assert_eq!(
9347 ct, "application/json",
9348 "User Content-Type should be sent for Bytes body"
9349 );
9350
9351 token.cancel();
9352 }
9353
9354 #[tokio::test]
9359 async fn monitor_task_silent_on_clean_exit() {
9360 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
9361 monitor_axum_task(
9363 handle,
9364 "127.0.0.1:0".to_string(),
9365 noop_rt(),
9366 "test-monitor".into(),
9367 )
9368 .await;
9369 }
9370
9371 #[tokio::test]
9372 async fn monitor_task_handles_panicked_task() {
9373 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
9374 panic!("simulated server crash");
9375 });
9376 monitor_axum_task(
9378 handle,
9379 "127.0.0.1:9999".to_string(),
9380 noop_rt(),
9381 "test-monitor".into(),
9382 )
9383 .await;
9384 }
9385
9386 #[test]
9391 fn http_auth_basic_debug_redacts_password() {
9392 let auth = HttpAuth::Basic {
9393 username: "admin".to_string(),
9394 password: "hunter2".to_string(),
9395 };
9396 let debug = format!("{:?}", auth);
9397 assert!(
9398 !debug.contains("hunter2"),
9399 "password must be redacted: {debug}"
9400 );
9401 assert!(debug.contains("admin"), "username should appear: {debug}");
9402 }
9403
9404 #[test]
9405 fn http_auth_bearer_debug_redacts_token() {
9406 let auth = HttpAuth::Bearer {
9407 token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
9408 };
9409 let debug = format!("{:?}", auth);
9410 assert!(
9411 !debug.contains("eyJhbGci"),
9412 "token must be redacted: {debug}"
9413 );
9414 }
9415
9416 #[test]
9417 fn http_auth_none_debug_shows_variant() {
9418 let debug = format!("{:?}", HttpAuth::None);
9419 assert!(
9420 debug.contains("None"),
9421 "None variant should appear: {debug}"
9422 );
9423 }
9424
9425 #[test]
9426 fn http_endpoint_config_debug_redacts_auth_credentials() {
9427 let config = HttpEndpointConfig::from_uri(
9428 "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
9429 )
9430 .unwrap();
9431 let debug = format!("{:?}", config);
9432 assert!(
9433 !debug.contains("secret123"),
9434 "password must be redacted in HttpEndpointConfig debug: {debug}"
9435 );
9436 }
9437
9438 #[test]
9439 fn debug_lists_all_public_fields() {
9440 let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9441 let debug = format!("{:?}", config);
9442 for field in [
9443 "base_url",
9444 "http_method",
9445 "throw_exception_on_failure",
9446 "ok_status_code_range",
9447 "response_timeout",
9448 "query_params",
9449 "raw_query",
9450 "allow_internal",
9451 "blocked_hosts",
9452 "max_body_size",
9453 "read_timeout_ms",
9454 "max_response_bytes",
9455 "auth",
9456 "token_provider",
9457 "user_agent",
9458 "bridge_endpoint",
9459 "connection_close",
9460 "skip_request_headers",
9461 "skip_response_headers",
9462 "follow_redirects",
9463 "max_redirects",
9464 ] {
9465 assert!(
9466 debug.contains(field),
9467 "Debug output missing field '{field}': {debug}"
9468 );
9469 }
9470 }
9471
9472 use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
9477 use tower_http::services::ServeDir;
9478
9479 fn make_test_registry() -> HttpRouteRegistry {
9480 HttpRouteRegistry::new()
9481 }
9482
9483 fn make_test_state(registry: HttpRouteRegistry) -> AppState {
9484 AppState {
9485 registry,
9486 max_request_body: 2 * 1024 * 1024,
9487 max_response_body: 10 * 1024 * 1024,
9488 inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
9489 }
9490 }
9491
9492 #[allow(clippy::await_holding_lock)]
9493 #[tokio::test]
9494 async fn test_static_file_serving_serves_file_contents() {
9495 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
9496 ServerRegistry::reset();
9497
9498 let temp_dir =
9500 std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
9501 std::fs::create_dir_all(&temp_dir).unwrap();
9502 std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
9503 std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
9504
9505 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
9506
9507 let registry = make_test_registry();
9508 let serve_dir = ServeDir::new(&canonical_dir)
9509 .precompressed_gzip()
9510 .precompressed_br()
9511 .append_index_html_on_directories(true);
9512
9513 let mount = StaticMount {
9514 mount_path: "/".to_string(),
9515 mode: MountMode::Static,
9516 dir: canonical_dir.clone(),
9517 cache_control: "public, max-age=3600".to_string(),
9518 error_pages: std::collections::HashMap::new(),
9519 serve_dir,
9520 };
9521 registry.register_static_mount(mount).await.unwrap();
9522
9523 let state = make_test_state(registry);
9524
9525 let req = Request::builder()
9527 .uri("/hello.txt")
9528 .body(AxumBody::empty())
9529 .unwrap();
9530 let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
9531 assert_eq!(resp.status(), StatusCode::OK);
9532 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9533 .await
9534 .unwrap();
9535 assert_eq!(&body[..], b"Hello, static world!");
9536
9537 let req = Request::builder()
9539 .uri("/style.css")
9540 .body(AxumBody::empty())
9541 .unwrap();
9542 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
9543 assert_eq!(resp.status(), StatusCode::OK);
9544 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9545 .await
9546 .unwrap();
9547 assert_eq!(&body[..], b"body { color: red; }");
9548
9549 let req = Request::builder()
9551 .uri("/missing.txt")
9552 .body(AxumBody::empty())
9553 .unwrap();
9554 let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
9555 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9556
9557 std::fs::remove_dir_all(&temp_dir).ok();
9559 }
9560
9561 #[allow(clippy::await_holding_lock)]
9562 #[tokio::test]
9563 async fn test_spa_fallback_serves_index_for_unknown_paths() {
9564 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
9565 ServerRegistry::reset();
9566
9567 let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
9568 std::fs::create_dir_all(&temp_dir).unwrap();
9569 std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
9570 std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
9571
9572 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
9573
9574 let registry = make_test_registry();
9575 let serve_dir = ServeDir::new(&canonical_dir)
9576 .precompressed_gzip()
9577 .precompressed_br()
9578 .append_index_html_on_directories(true);
9579
9580 let mount = StaticMount {
9581 mount_path: "/".to_string(),
9582 mode: MountMode::Spa,
9583 dir: canonical_dir.clone(),
9584 cache_control: "public, max-age=0".to_string(),
9585 error_pages: std::collections::HashMap::new(),
9586 serve_dir,
9587 };
9588 registry.register_static_mount(mount).await.unwrap();
9590
9591 let state = make_test_state(registry);
9592
9593 let req = Request::builder()
9595 .method("GET")
9596 .uri("/dashboard")
9597 .header("Accept", "text/html")
9598 .body(AxumBody::empty())
9599 .unwrap();
9600 let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
9601 assert_eq!(resp.status(), StatusCode::OK);
9602 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9603 .await
9604 .unwrap();
9605 assert_eq!(&body[..], b"<h1>SPA App</h1>");
9606
9607 let req = Request::builder()
9609 .method("GET")
9610 .uri("/app.js")
9611 .body(AxumBody::empty())
9612 .unwrap();
9613 let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
9614 assert_eq!(resp.status(), StatusCode::OK);
9615 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9616 .await
9617 .unwrap();
9618 assert_eq!(&body[..], b"console.log('app')");
9619
9620 let req = Request::builder()
9622 .method("GET")
9623 .uri("/api/data")
9624 .header("Accept", "application/json")
9625 .body(AxumBody::empty())
9626 .unwrap();
9627 let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
9628 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9629
9630 let req = Request::builder()
9632 .method("GET")
9633 .uri("/style.css")
9634 .header("Accept", "text/html")
9635 .body(AxumBody::empty())
9636 .unwrap();
9637 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
9638 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9639
9640 std::fs::remove_dir_all(&temp_dir).ok();
9642 }
9643
9644 #[allow(clippy::await_holding_lock)]
9651 async fn run_conditional_get_returns_304(mode: MountMode) {
9652 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
9653 ServerRegistry::reset();
9654
9655 let temp_dir = std::env::temp_dir().join(format!(
9656 "http_cond_get_{}_{}",
9657 if mode == MountMode::Spa {
9658 "spa"
9659 } else {
9660 "static"
9661 },
9662 std::process::id()
9663 ));
9664 std::fs::create_dir_all(&temp_dir).unwrap();
9665 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
9666
9667 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
9668
9669 let registry = make_test_registry();
9670 let serve_dir = ServeDir::new(&canonical_dir)
9671 .precompressed_gzip()
9672 .precompressed_br()
9673 .append_index_html_on_directories(true);
9674
9675 let mount = StaticMount {
9676 mount_path: "/".to_string(),
9677 mode,
9678 dir: canonical_dir.clone(),
9679 cache_control: "public, max-age=3600".to_string(),
9680 error_pages: std::collections::HashMap::new(),
9681 serve_dir,
9682 };
9683 registry.register_static_mount(mount).await.unwrap();
9684
9685 let state = make_test_state(registry);
9686
9687 let req = Request::builder()
9689 .method("GET")
9690 .uri("/index.html")
9691 .body(AxumBody::empty())
9692 .unwrap();
9693 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
9694 assert_eq!(
9695 resp.status(),
9696 StatusCode::OK,
9697 "first GET should return 200, got {}",
9698 resp.status()
9699 );
9700 assert!(
9702 resp.headers().contains_key(http::header::CACHE_CONTROL),
9703 "200 response missing Cache-Control"
9704 );
9705 let etag = resp
9706 .headers()
9707 .get(http::header::ETAG)
9708 .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
9709 .clone();
9710 let last_modified = resp
9711 .headers()
9712 .get(http::header::LAST_MODIFIED)
9713 .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
9714 .clone();
9715 let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
9717 .await
9718 .unwrap();
9719
9720 let req = Request::builder()
9724 .method("GET")
9725 .uri("/index.html")
9726 .header(http::header::IF_NONE_MATCH, etag.clone())
9727 .body(AxumBody::empty())
9728 .unwrap();
9729 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
9730 assert_eq!(
9731 resp.status(),
9732 StatusCode::NOT_MODIFIED,
9733 "If-None-Match with matching ETag should return 304, got {}",
9734 resp.status()
9735 );
9736 assert!(
9738 resp.headers().contains_key(http::header::CACHE_CONTROL),
9739 "304 (If-None-Match) missing Cache-Control"
9740 );
9741 assert_eq!(
9744 resp.headers().get(http::header::ETAG),
9745 Some(&etag),
9746 "304 (If-None-Match) must echo the ETag validator"
9747 );
9748 assert_eq!(
9749 resp.headers().get(http::header::LAST_MODIFIED),
9750 Some(&last_modified),
9751 "304 (If-None-Match) must carry Last-Modified"
9752 );
9753
9754 let req = Request::builder()
9756 .method("GET")
9757 .uri("/index.html")
9758 .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
9759 .body(AxumBody::empty())
9760 .unwrap();
9761 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
9762 assert_eq!(
9763 resp.status(),
9764 StatusCode::NOT_MODIFIED,
9765 "If-Modified-Since with matching timestamp should return 304, got {}",
9766 resp.status()
9767 );
9768 assert!(
9769 resp.headers().contains_key(http::header::CACHE_CONTROL),
9770 "304 (If-Modified-Since) missing Cache-Control"
9771 );
9772 assert_eq!(
9773 resp.headers().get(http::header::ETAG),
9774 Some(&etag),
9775 "304 (If-Modified-Since) must carry the ETag validator"
9776 );
9777 assert_eq!(
9778 resp.headers().get(http::header::LAST_MODIFIED),
9779 Some(&last_modified),
9780 "304 (If-Modified-Since) must echo Last-Modified"
9781 );
9782
9783 let req = Request::builder()
9789 .method("GET")
9790 .uri("/index.html")
9791 .header(
9792 http::header::IF_MODIFIED_SINCE,
9793 "Wed, 21 Oct 2000 07:28:00 GMT",
9794 )
9795 .body(AxumBody::empty())
9796 .unwrap();
9797 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
9798 assert_eq!(
9799 resp.status(),
9800 StatusCode::OK,
9801 "past If-Modified-Since should return 200 (file modified after it), got {}",
9802 resp.status()
9803 );
9804
9805 std::fs::remove_dir_all(&temp_dir).ok();
9807 }
9808
9809 #[tokio::test]
9810 async fn test_conditional_get_returns_304_static_mode() {
9811 run_conditional_get_returns_304(MountMode::Static).await;
9812 }
9813
9814 #[tokio::test]
9815 async fn test_conditional_get_returns_304_spa_mode() {
9816 run_conditional_get_returns_304(MountMode::Spa).await;
9817 }
9818
9819 #[allow(clippy::await_holding_lock)]
9820 #[tokio::test]
9821 async fn test_error_page_mapping_serves_custom_404() {
9822 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
9823 ServerRegistry::reset();
9824
9825 let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
9826 let errors_dir = temp_dir.join("errors");
9827 std::fs::create_dir_all(&errors_dir).unwrap();
9828 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
9829 std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
9830
9831 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
9832 let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
9833
9834 let registry = make_test_registry();
9835 let serve_dir = ServeDir::new(&canonical_dir)
9836 .precompressed_gzip()
9837 .precompressed_br()
9838 .append_index_html_on_directories(true);
9839
9840 let mut error_pages = std::collections::HashMap::new();
9841 error_pages.insert(404, canonical_404);
9842
9843 let mount = StaticMount {
9844 mount_path: "/".to_string(),
9845 mode: MountMode::Static,
9846 dir: canonical_dir.clone(),
9847 cache_control: "public, max-age=0".to_string(),
9848 error_pages,
9849 serve_dir,
9850 };
9851 registry.register_static_mount(mount).await.unwrap();
9852
9853 let state = make_test_state(registry);
9854
9855 let req = Request::builder()
9857 .method("GET")
9858 .uri("/missing.html")
9859 .body(AxumBody::empty())
9860 .unwrap();
9861 let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
9862 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9863 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9864 .await
9865 .unwrap();
9866 assert_eq!(&body[..], b"<h1>Custom 404</h1>");
9867
9868 let req = Request::builder()
9870 .method("GET")
9871 .uri("/index.html")
9872 .body(AxumBody::empty())
9873 .unwrap();
9874 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
9875 assert_eq!(resp.status(), StatusCode::OK);
9876 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9877 .await
9878 .unwrap();
9879 assert_eq!(&body[..], b"<h1>Home</h1>");
9880
9881 std::fs::remove_dir_all(&temp_dir).ok();
9883 }
9884
9885 #[tokio::test]
9886 async fn http_consumer_returns_body_and_code_on_stop() {
9887 use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
9888 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
9889 use tower::ServiceExt;
9890
9891 let set_body_step = CompiledStep::Process {
9893 kind_hint: camel_api::SpanKindHint::Internal,
9894 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
9895 ex.input.body = Body::Text("nope".into());
9896 Box::pin(async move { Ok(ex) })
9897 }),
9898 body_contract: None,
9899 lifecycle: None,
9900 label: None,
9901 to_uri: None,
9902 };
9903 let set_status_step = CompiledStep::Process {
9904 kind_hint: camel_api::SpanKindHint::Internal,
9905 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
9906 ex.input.set_header(
9907 "CamelHttpResponseCode",
9908 serde_json::Value::Number(409.into()),
9909 );
9910 Box::pin(async move { Ok(ex) })
9911 }),
9912 body_contract: None,
9913 lifecycle: None,
9914 label: None,
9915 to_uri: None,
9916 };
9917 let pipeline = compose_pipeline_with_handler(
9918 vec![set_body_step, set_status_step, CompiledStep::Stop],
9919 None,
9920 PipelineRuntimeCtx::compile_time(),
9921 );
9922
9923 let ex = Exchange::new(Message::default());
9924 let result = pipeline.oneshot(ex).await;
9925 assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
9926 let returned = result.unwrap();
9927 assert_eq!(returned.input.body.as_text(), Some("nope"));
9928 assert_eq!(
9929 returned
9930 .input
9931 .header("CamelHttpResponseCode")
9932 .and_then(|v| v.as_u64()),
9933 Some(409)
9934 );
9935 }
9936
9937 #[tokio::test]
9938 async fn http_consumer_returns_200_when_body_empty_on_stop() {
9939 use camel_api::{Exchange, Message};
9947 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
9948 use tower::ServiceExt;
9949
9950 let pipeline = compose_pipeline_with_handler(
9951 vec![CompiledStep::Stop],
9952 None,
9953 PipelineRuntimeCtx::compile_time(),
9954 );
9955 let ex = Exchange::new(Message::default());
9956 let result = pipeline.oneshot(ex).await;
9957 assert!(result.is_ok(), "Stop with empty body arrives as Ok");
9958 }
9961
9962 async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
9970 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9971 let port = listener.local_addr().unwrap().port();
9972 let registry = HttpRouteRegistry::new();
9973 tokio::spawn(run_axum_server(
9974 listener,
9975 registry.clone(),
9976 2 * 1024 * 1024,
9977 10 * 1024 * 1024,
9978 Arc::new(tokio::sync::Semaphore::new(1024)),
9979 test_rt(),
9980 "test-route".into(),
9981 ));
9982 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9984 (port, registry)
9985 }
9986
9987 fn spawn_responder(
9992 mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
9993 status: u16,
9994 body: String,
9995 ) -> tokio::task::JoinHandle<()> {
9996 tokio::spawn(async move {
9997 if let Some(envelope) = rx.recv().await {
9998 let _ = envelope.reply_tx.send(HttpReply {
9999 status,
10000 headers: vec![],
10001 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
10002 });
10003 }
10004 })
10005 }
10006
10007 #[tokio::test]
10008 async fn method_aware_dispatch_same_path_different_verbs() {
10009 let (port, registry) = spawn_test_server().await;
10010
10011 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10016 registry
10017 .register_rest_endpoint(
10018 "GET".into(),
10019 vec![PathSegment::Literal("users".into())],
10020 get_tx,
10021 )
10022 .await;
10023
10024 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10025 registry
10026 .register_rest_endpoint(
10027 "POST".into(),
10028 vec![PathSegment::Literal("users".into())],
10029 post_tx,
10030 )
10031 .await;
10032
10033 let get_handle = spawn_responder(get_rx, 200, "list".into());
10034 let post_handle = spawn_responder(post_rx, 201, "create".into());
10035
10036 let client = reqwest::Client::new();
10037
10038 let resp = client
10040 .get(format!("http://127.0.0.1:{port}/users"))
10041 .send()
10042 .await
10043 .unwrap();
10044 assert_eq!(resp.status().as_u16(), 200);
10045 let body = resp.text().await.unwrap();
10046 assert_eq!(body, "list");
10047
10048 let resp = client
10050 .post(format!("http://127.0.0.1:{port}/users"))
10051 .send()
10052 .await
10053 .unwrap();
10054 assert_eq!(resp.status().as_u16(), 201);
10055 let body = resp.text().await.unwrap();
10056 assert_eq!(body, "create");
10057
10058 let _ = tokio::join!(get_handle, post_handle);
10059 }
10060
10061 #[tokio::test]
10062 async fn method_aware_dispatch_templated_path_extracts_params() {
10063 let (port, registry) = spawn_test_server().await;
10064
10065 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10069 registry
10070 .register_rest_endpoint(
10071 "GET".into(),
10072 vec![
10073 PathSegment::Literal("users".into()),
10074 PathSegment::Param("id".into()),
10075 ],
10076 tx,
10077 )
10078 .await;
10079
10080 let handle = tokio::spawn(async move {
10083 if let Some(envelope) = rx.recv().await {
10084 let id = envelope.path_params.get("id").cloned().unwrap_or_default();
10085 let _ = envelope.reply_tx.send(HttpReply {
10086 status: 200,
10087 headers: vec![],
10088 body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
10089 });
10090 }
10091 });
10092
10093 let client = reqwest::Client::new();
10094 let resp = client
10095 .get(format!("http://127.0.0.1:{port}/users/42"))
10096 .send()
10097 .await
10098 .unwrap();
10099 assert_eq!(resp.status().as_u16(), 200);
10100 let body = resp.text().await.unwrap();
10101 assert_eq!(body, "id=42");
10102
10103 let _ = handle.await;
10104 }
10105
10106 #[tokio::test]
10107 async fn method_aware_dispatch_unmatched_method_falls_through() {
10108 let (port, _registry) = spawn_test_server().await;
10113
10114 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10116 _registry
10117 .register_rest_endpoint(
10118 "GET".into(),
10119 vec![PathSegment::Literal("users".into())],
10120 get_tx,
10121 )
10122 .await;
10123
10124 let drain = tokio::spawn(async move {
10127 let mut get_rx = get_rx;
10128 while get_rx.recv().await.is_some() {}
10129 });
10130
10131 let client = reqwest::Client::new();
10132 let resp = client
10133 .delete(format!("http://127.0.0.1:{port}/users"))
10134 .send()
10135 .await
10136 .unwrap();
10137 assert_eq!(resp.status().as_u16(), 404);
10138
10139 drop(drain);
10140 }
10141
10142 #[tokio::test]
10143 async fn regression_legacy_exact_api_route_still_works() {
10144 let (port, registry) = spawn_test_server().await;
10149
10150 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10151 registry.register_api_route("/legacy/path".into(), tx).await;
10152
10153 let handle = tokio::spawn(async move {
10154 if let Some(envelope) = rx.recv().await {
10155 let _ = envelope.reply_tx.send(HttpReply {
10156 status: 200,
10157 headers: vec![],
10158 body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
10159 });
10160 }
10161 });
10162
10163 let client = reqwest::Client::new();
10164 let resp = client
10165 .get(format!("http://127.0.0.1:{port}/legacy/path"))
10166 .send()
10167 .await
10168 .unwrap();
10169 assert_eq!(resp.status().as_u16(), 200);
10170 let body = resp.text().await.unwrap();
10171 assert_eq!(body, "legacy ok");
10172
10173 let _ = handle.await;
10174 }
10175
10176 #[allow(clippy::await_holding_lock)]
10177 #[tokio::test]
10178 async fn regression_static_mount_still_works() {
10179 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
10183 ServerRegistry::reset();
10184
10185 let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
10186 std::fs::create_dir_all(&temp_dir).unwrap();
10187 std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
10188 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
10189
10190 let registry = make_test_registry();
10191 let serve_dir = ServeDir::new(&canonical_dir)
10192 .precompressed_gzip()
10193 .precompressed_br()
10194 .append_index_html_on_directories(true);
10195 let mount = StaticMount {
10196 mount_path: "/".to_string(),
10197 mode: MountMode::Static,
10198 dir: canonical_dir.clone(),
10199 cache_control: "public, max-age=3600".to_string(),
10200 error_pages: std::collections::HashMap::new(),
10201 serve_dir,
10202 };
10203 registry.register_static_mount(mount).await.unwrap();
10204
10205 let state = make_test_state(registry);
10206 let req = Request::builder()
10207 .uri("/regress.txt")
10208 .body(AxumBody::empty())
10209 .unwrap();
10210 let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
10211 assert_eq!(resp.status(), StatusCode::OK);
10212 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10213 .await
10214 .unwrap();
10215 assert_eq!(&body[..], b"static works");
10216
10217 std::fs::remove_dir_all(&temp_dir).ok();
10218 }
10219
10220 #[tokio::test]
10229 async fn deregister_one_method_keeps_sibling_verbs() {
10230 let (port, registry) = spawn_test_server().await;
10234
10235 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10236 registry
10237 .register_rest_endpoint(
10238 "GET".into(),
10239 vec![PathSegment::Literal("users".into())],
10240 get_tx,
10241 )
10242 .await;
10243
10244 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10245 registry
10246 .register_rest_endpoint(
10247 "POST".into(),
10248 vec![PathSegment::Literal("users".into())],
10249 post_tx,
10250 )
10251 .await;
10252
10253 let drain = tokio::spawn(async move {
10255 let mut get_rx = get_rx;
10256 while get_rx.recv().await.is_some() {}
10257 });
10258
10259 registry.unregister_rest_endpoint("GET", "/users").await;
10261 drop(drain);
10262
10263 let post_handle = spawn_responder(post_rx, 201, "create".into());
10264
10265 let client = reqwest::Client::new();
10266 let resp = client
10268 .post(format!("http://127.0.0.1:{port}/users"))
10269 .send()
10270 .await
10271 .unwrap();
10272 assert_eq!(resp.status().as_u16(), 201);
10273 assert_eq!(resp.text().await.unwrap(), "create");
10274
10275 let _ = post_handle.await;
10276 }
10277
10278 #[tokio::test]
10279 async fn dispatch_exact_legacy_beats_rest_template() {
10280 let (port, registry) = spawn_test_server().await;
10285
10286 let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10288 registry
10289 .register_api_route("/api/users".into(), exact_tx)
10290 .await;
10291 let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
10292
10293 let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10295 registry
10296 .register_rest_endpoint(
10297 "GET".into(),
10298 vec![
10299 PathSegment::Literal("api".into()),
10300 PathSegment::Param("resource".into()),
10301 ],
10302 tpl_tx,
10303 )
10304 .await;
10305 let _tpl_drain = tokio::spawn(async move {
10311 let mut tpl_rx = tpl_rx;
10312 if let Some(env) = tpl_rx.recv().await {
10313 let _ = env.reply_tx.send(HttpReply {
10314 status: 200,
10315 headers: vec![],
10316 body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
10317 });
10318 }
10319 });
10320
10321 let client = reqwest::Client::new();
10322 let resp = client
10323 .get(format!("http://127.0.0.1:{port}/api/users"))
10324 .send()
10325 .await
10326 .unwrap();
10327 assert_eq!(resp.status().as_u16(), 200);
10328 assert_eq!(resp.text().await.unwrap(), "exact");
10330
10331 let _ = exact_handle.await;
10332 }
10333
10334 #[tokio::test]
10335 async fn ambiguous_rest_templates_return_500_not_silent_404() {
10336 let (port, registry) = spawn_test_server().await;
10341
10342 let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10343 registry
10344 .register_rest_endpoint(
10345 "GET".into(),
10346 vec![
10347 PathSegment::Literal("users".into()),
10348 PathSegment::Param("id".into()),
10349 ],
10350 a_tx,
10351 )
10352 .await;
10353
10354 let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10355 registry
10356 .register_rest_endpoint(
10357 "GET".into(),
10358 vec![
10359 PathSegment::Literal("users".into()),
10360 PathSegment::Param("name".into()),
10361 ],
10362 b_tx,
10363 )
10364 .await;
10365
10366 let client = reqwest::Client::new();
10367 let resp = client
10368 .get(format!("http://127.0.0.1:{port}/users/42"))
10369 .send()
10370 .await
10371 .unwrap();
10372 assert_eq!(resp.status().as_u16(), 500);
10374 }
10375
10376 #[test]
10377 fn from_uri_round_trips_templated_path_with_http_method() {
10378 use crate::UriConfig;
10384 let cfg =
10385 HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
10386 assert_eq!(cfg.host, "0.0.0.0");
10387 assert_eq!(cfg.port, 8080);
10388 assert_eq!(cfg.path, "/users/{id}");
10389 assert_eq!(cfg.method.as_deref(), Some("GET"));
10390
10391 let cfg_lc =
10393 HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
10394 assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
10395 assert_eq!(cfg_lc.path, "/orders");
10396 }
10397
10398 #[test]
10403 fn type_conversion_failed_maps_to_400() {
10404 let reply = pipeline_error_to_reply(
10405 CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
10406 "/api/users",
10407 );
10408 assert_eq!(reply.status, 400);
10409 let json_ct = reply
10411 .headers
10412 .iter()
10413 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
10414 .count();
10415 assert_eq!(json_ct, 1);
10416 let body = match &reply.body {
10418 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
10419 _ => panic!("expected bytes body"),
10420 };
10421 let parsed: serde_json::Value =
10422 serde_json::from_str(&body).expect("body must be valid JSON");
10423 assert_eq!(parsed["error"], "bad_request");
10424 assert_eq!(parsed["message"], "invalid JSON at line 1");
10425 }
10426
10427 #[test]
10428 fn other_error_still_maps_to_500() {
10429 let reply =
10430 pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
10431 assert_eq!(reply.status, 500);
10432 }
10433
10434 #[test]
10435 fn unauthenticated_maps_to_401() {
10436 let reply = pipeline_error_to_reply(
10437 CamelError::Unauthenticated("no token".to_string()),
10438 "/api/users",
10439 );
10440 assert_eq!(reply.status, 401);
10441 }
10442
10443 #[test]
10444 fn unauthorized_maps_to_403() {
10445 let reply = pipeline_error_to_reply(
10446 CamelError::Unauthorized("forbidden".to_string()),
10447 "/api/users",
10448 );
10449 assert_eq!(reply.status, 403);
10450 }
10451
10452 #[test]
10453 fn validation_error_maps_to_400() {
10454 let reply = pipeline_error_to_reply(
10455 CamelError::ValidationError("body does not match schema".to_string()),
10456 "/api/users",
10457 );
10458 assert_eq!(reply.status, 400);
10459 let json_ct = reply
10460 .headers
10461 .iter()
10462 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
10463 .count();
10464 assert_eq!(json_ct, 1);
10465 let body = match &reply.body {
10466 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
10467 _ => panic!("expected bytes body"),
10468 };
10469 let parsed: serde_json::Value =
10470 serde_json::from_str(&body).expect("body must be valid JSON");
10471 assert_eq!(parsed["error"], "validation_error");
10472 assert_eq!(parsed["message"], "body does not match schema");
10473 }
10474
10475 #[test]
10480 fn finalizer_maps_unsupported_media_type() {
10481 let reply = pipeline_error_to_reply(
10482 CamelError::UnsupportedMediaType {
10483 consumed: "text/plain".to_string(),
10484 declared: "application/json".to_string(),
10485 },
10486 "/x",
10487 );
10488 assert_eq!(reply.status, 415);
10489 let json_ct = reply
10490 .headers
10491 .iter()
10492 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
10493 .count();
10494 assert_eq!(json_ct, 1);
10495 let body = match &reply.body {
10496 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
10497 _ => panic!("expected bytes body"),
10498 };
10499 let parsed: serde_json::Value =
10500 serde_json::from_str(&body).expect("body must be valid JSON");
10501 assert_eq!(parsed["error"], "unsupported_media_type");
10502 assert_eq!(
10503 parsed["message"],
10504 "consumed text/plain, declared application/json"
10505 );
10506 }
10507
10508 #[test]
10509 fn finalizer_maps_not_acceptable() {
10510 let reply = pipeline_error_to_reply(
10511 CamelError::NotAcceptable {
10512 accept: "application/xml".to_string(),
10513 produced: "application/json".to_string(),
10514 },
10515 "/x",
10516 );
10517 assert_eq!(reply.status, 406);
10518 let json_ct = reply
10519 .headers
10520 .iter()
10521 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
10522 .count();
10523 assert_eq!(json_ct, 1);
10524 let body = match &reply.body {
10525 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
10526 _ => panic!("expected bytes body"),
10527 };
10528 let parsed: serde_json::Value =
10529 serde_json::from_str(&body).expect("body must be valid JSON");
10530 assert_eq!(parsed["error"], "not_acceptable");
10531 assert_eq!(
10532 parsed["message"],
10533 "accept application/xml, produced application/json"
10534 );
10535 }
10536
10537 #[test]
10538 fn json_error_reply_preserves_empty_message() {
10539 let reply = json_error_reply(400, "bad_request", "".to_string());
10540 assert_eq!(reply.status, 400);
10541 let json_ct = reply
10542 .headers
10543 .iter()
10544 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
10545 .count();
10546 assert_eq!(json_ct, 1);
10547 let body = match &reply.body {
10548 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
10549 _ => panic!("expected bytes body"),
10550 };
10551 let parsed: serde_json::Value =
10552 serde_json::from_str(&body).expect("body must be valid JSON");
10553 assert_eq!(parsed["error"], "bad_request");
10554 assert_eq!(parsed["message"], "");
10555 }
10556
10557 #[test]
10558 fn https_consumer_without_tls_cert_errors() {
10559 let endpoint = HttpEndpoint {
10560 uri: "https://0.0.0.0:8443/api".to_string(),
10561 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
10562 server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
10563 client: reqwest::Client::new(),
10564 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
10565 PINNED_CLIENT_TTL,
10566 PINNED_CLIENT_MAX_ENTRIES,
10567 )),
10568 http_config: HttpConfig::default(),
10569 };
10570 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
10571 let result = endpoint.create_consumer(rt);
10572 assert!(result.is_err(), "expected error for https without tls cert");
10573 if let Err(e) = result {
10574 let msg = e.to_string();
10575 assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
10576 }
10577 }
10578
10579 #[test]
10580 fn http_consumer_with_tls_config_errors() {
10581 let endpoint = HttpEndpoint {
10582 uri: "http://0.0.0.0:8080/api".to_string(),
10583 config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
10584 server_config: HttpServerConfig::from_uri(
10585 "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
10586 )
10587 .unwrap(),
10588 client: reqwest::Client::new(),
10589 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
10590 PINNED_CLIENT_TTL,
10591 PINNED_CLIENT_MAX_ENTRIES,
10592 )),
10593 http_config: HttpConfig::default(),
10594 };
10595 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
10596 let result = endpoint.create_consumer(rt);
10597 assert!(result.is_err(), "expected error for http with tls config");
10598 if let Err(e) = result {
10599 let msg = e.to_string();
10600 assert!(msg.contains("https"), "error must mention https: {msg}");
10601 }
10602 }
10603
10604 #[test]
10605 fn https_consumer_with_partial_tls_cert_only_errors() {
10606 let server_config =
10609 HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
10610 assert!(
10611 server_config.tls_config.is_none(),
10612 "partial tlsCert must not create ServerTlsConfig"
10613 );
10614 let endpoint = HttpEndpoint {
10615 uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
10616 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
10617 .unwrap(),
10618 server_config,
10619 client: reqwest::Client::new(),
10620 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
10621 PINNED_CLIENT_TTL,
10622 PINNED_CLIENT_MAX_ENTRIES,
10623 )),
10624 http_config: HttpConfig::default(),
10625 };
10626 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
10627 let result = endpoint.create_consumer(rt);
10628 assert!(
10629 result.is_err(),
10630 "must error: https:// requires both tlsCert and tlsKey"
10631 );
10632 }
10633
10634 #[test]
10635 fn load_tls_config_parses_valid_pem() {
10636 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
10638 use camel_component_api::test_support::tls;
10639 let (_, cert_pem, key_pem) = tls::gen_server_cert();
10640 let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
10641 let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
10642
10643 let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
10644 assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
10645 }
10646
10647 #[tokio::test(flavor = "multi_thread")]
10648 #[allow(clippy::await_holding_lock)]
10649 async fn consumer_tls_handshake_roundtrip() {
10650 use camel_component_api::test_support::tls;
10651 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
10652
10653 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
10655
10656 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
10658
10659 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
10661 let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
10662 let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
10663 let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
10664
10665 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10667 let port = probe.local_addr().unwrap().port();
10668 drop(probe);
10669
10670 ServerRegistry::reset();
10671
10672 let component = HttpComponent::new();
10674 let endpoint_ctx = NoOpComponentContext;
10675 let uri = format!(
10676 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
10677 cert_path.to_string_lossy(),
10678 key_path.to_string_lossy(),
10679 );
10680 let endpoint = component
10681 .create_endpoint(&uri, &endpoint_ctx)
10682 .expect("create TLS endpoint");
10683 let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
10684
10685 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
10687 let token = tokio_util::sync::CancellationToken::new();
10688 let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
10689 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10690
10691 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
10693
10694 let ca_bytes = std::fs::read(&ca_path).unwrap();
10696 let client = reqwest::Client::builder()
10697 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
10698 .build()
10699 .unwrap();
10700
10701 let send_fut = client
10702 .post(format!("https://localhost:{port}/test"))
10703 .body("ping")
10704 .send();
10705
10706 let (http_result, _) = tokio::join!(send_fut, async {
10708 if let Some(mut envelope) = rx.recv().await {
10709 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
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.expect("TLS handshake + request must succeed");
10717
10718 assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
10719 let body = resp.text().await.unwrap();
10720 assert_eq!(body, "pong");
10721
10722 token.cancel();
10723 }
10724
10725 #[tokio::test(flavor = "multi_thread")]
10726 #[allow(clippy::await_holding_lock)]
10727 async fn consumer_tls_rejects_client_without_ca() {
10728 use camel_component_api::test_support::tls;
10729 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
10730
10731 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
10732
10733 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
10735
10736 let (_, cert_pem, key_pem) = tls::gen_server_cert();
10737 let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
10738 let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
10739
10740 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10741 let port = probe.local_addr().unwrap().port();
10742 drop(probe);
10743
10744 ServerRegistry::reset();
10745
10746 let component = HttpComponent::new();
10748 let endpoint_ctx = NoOpComponentContext;
10749 let uri = format!(
10750 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
10751 cert_path.to_string_lossy(),
10752 key_path.to_string_lossy(),
10753 );
10754 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
10755 let mut consumer = endpoint.create_consumer(rt()).unwrap();
10756 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
10757 let token = tokio_util::sync::CancellationToken::new();
10758 let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
10759 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10760
10761 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
10762
10763 let client = reqwest::Client::builder().build().unwrap();
10765
10766 let result = client
10767 .get(format!("https://localhost:{port}/test"))
10768 .send()
10769 .await;
10770
10771 assert!(
10772 result.is_err(),
10773 "must reject without CA — proves real verification"
10774 );
10775
10776 token.cancel();
10777 }
10778
10779 #[test]
10780 fn server_config_partial_tls_cert_without_key() {
10781 let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
10783 assert!(cfg.tls_config.is_none());
10785 }
10786
10787 #[test]
10788 fn endpoint_uri_options_count_parity() {
10789 assert_eq!(
10791 HttpEndpointConfig::uri_options().len(),
10792 22,
10793 "HttpEndpointUriConfig #[uri_param] count drifted from parser"
10794 );
10795 }
10796
10797 fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
10798 pairs
10799 .iter()
10800 .map(|(k, v)| {
10801 (
10802 (*k).to_string(),
10803 serde_json::Value::String((*v).to_string()),
10804 )
10805 })
10806 .collect()
10807 }
10808
10809 #[test]
10810 fn response_emits_cache_control_via_pragma_warning() {
10811 let headers = make_headers(&[
10812 ("Cache-Control", "public, max-age=3600"),
10813 ("Via", "1.1 myproxy"),
10814 ("Pragma", "no-cache"),
10815 ("Warning", "199 misc"),
10816 ]);
10817 let selected = select_response_headers(&headers, None, None);
10818 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
10819 for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
10820 assert!(
10821 names.contains(&expected),
10822 "{expected} should pass through to the response"
10823 );
10824 }
10825 }
10826
10827 #[test]
10828 fn response_excludes_request_only_and_server_owned() {
10829 let headers = make_headers(&[
10830 ("User-Agent", "x"),
10831 ("Accept", "*/*"),
10832 ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
10833 ]);
10834 let selected = select_response_headers(&headers, None, None);
10835 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
10836 for excluded in ["User-Agent", "Accept", "Date"] {
10837 assert!(
10838 !names.contains(&excluded),
10839 "{excluded} should NOT appear in the response"
10840 );
10841 }
10842 }
10843
10844 #[test]
10845 fn response_re_derives_content_type() {
10846 let headers = make_headers(&[("Content-Type", "text/plain")]);
10847 let selected = select_response_headers(&headers, Some("application/json".into()), None);
10848 let ct_entries: Vec<&str> = selected
10849 .iter()
10850 .filter(|(k, _)| k == "Content-Type")
10851 .map(|(_, v)| v.as_str())
10852 .collect();
10853 assert_eq!(
10854 ct_entries,
10855 ["application/json"],
10856 "exactly one Content-Type entry, re-derived from user_content_type"
10857 );
10858 }
10859
10860 #[test]
10861 fn response_excludes_camel_headers() {
10862 let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
10863 let selected = select_response_headers(&headers, None, None);
10864 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
10865 assert!(
10866 !names.contains(&"CamelHttpPath"),
10867 "Camel-namespace headers must be excluded"
10868 );
10869 assert!(
10870 names.contains(&"Cache-Control"),
10871 "Cache-Control must pass through"
10872 );
10873 }
10874
10875 #[test]
10876 fn response_stringifies_scalar_header_values() {
10877 let mut headers = make_headers(&[("X-Label", "keep")]);
10878 headers.insert("X-Retries".to_string(), serde_json::json!(3));
10879 headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
10880 headers.insert("X-Enabled".to_string(), serde_json::json!(true));
10881 let selected = select_response_headers(&headers, None, None);
10882 let get = |name: &str| -> Option<&str> {
10883 selected
10884 .iter()
10885 .find(|(k, _)| k == name)
10886 .map(|(_, v)| v.as_str())
10887 };
10888 assert_eq!(
10889 get("X-Retries"),
10890 Some("3"),
10891 "integer header must be stringified"
10892 );
10893 assert_eq!(
10894 get("X-Ratio"),
10895 Some("3.5"),
10896 "float header must be stringified"
10897 );
10898 assert_eq!(
10899 get("X-Enabled"),
10900 Some("true"),
10901 "bool header must be stringified"
10902 );
10903 assert_eq!(
10904 get("X-Label"),
10905 Some("keep"),
10906 "string header must pass through"
10907 );
10908 }
10909
10910 #[test]
10911 fn response_drops_null_and_structured_header_values() {
10912 let mut headers = make_headers(&[("X-Keep", "yes")]);
10913 headers.insert("X-Null".to_string(), serde_json::Value::Null);
10914 headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
10915 headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
10916 let selected = select_response_headers(&headers, None, None);
10917 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
10918 for dropped in ["X-Null", "X-Obj", "X-Arr"] {
10919 assert!(
10920 !names.contains(&dropped),
10921 "{dropped} must not be emitted: no single-value form"
10922 );
10923 }
10924 assert!(names.contains(&"X-Keep"), "scalar headers must survive");
10925 }
10926
10927 #[test]
10928 fn response_stringifies_scalars_despite_excluded_names() {
10929 let mut headers = HashMap::new();
10933 headers.insert("Content-Length".to_string(), serde_json::json!(999));
10934 headers.insert("Date".to_string(), serde_json::json!(12345));
10935 let selected = select_response_headers(&headers, None, None);
10936 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
10937 assert!(
10938 !names.contains(&"Content-Length"),
10939 "content-length is re-derived by the server"
10940 );
10941 assert!(!names.contains(&"Date"), "date is server-owned");
10942 }
10943
10944 #[test]
10945 fn outbound_stringifies_scalar_header_values() {
10946 let mut headers = make_headers(&[("X-Label", "keep")]);
10947 headers.insert("X-Retries".to_string(), serde_json::json!(3));
10948 headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
10949 headers.insert("X-Enabled".to_string(), serde_json::json!(true));
10950 let outbound = select_outbound_headers(&headers, &[], &[]);
10951 let get = |name: &str| -> Option<String> {
10953 outbound
10954 .accepted
10955 .iter()
10956 .find(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
10957 .map(|(_, v)| v.to_str().unwrap().to_string())
10958 };
10959 assert_eq!(
10960 get("X-Retries").as_deref(),
10961 Some("3"),
10962 "integer header must be stringified"
10963 );
10964 assert_eq!(
10965 get("X-Ratio").as_deref(),
10966 Some("3.5"),
10967 "float header must be stringified"
10968 );
10969 assert_eq!(
10970 get("X-Enabled").as_deref(),
10971 Some("true"),
10972 "bool header must be stringified"
10973 );
10974 assert_eq!(
10975 get("X-Label").as_deref(),
10976 Some("keep"),
10977 "string header must pass through"
10978 );
10979 assert!(outbound.drops.is_empty(), "scalar headers must not drop");
10980 }
10981
10982 #[test]
10983 fn outbound_drops_null_and_structured_header_values() {
10984 let mut headers = make_headers(&[("X-Keep", "yes")]);
10985 headers.insert("X-Null".to_string(), serde_json::Value::Null);
10986 headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
10987 headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
10988 let outbound = select_outbound_headers(&headers, &[], &[]);
10989 let has = |name: &str| {
10990 outbound
10991 .accepted
10992 .iter()
10993 .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
10994 };
10995 assert!(has("X-Keep"), "scalar headers must survive");
10996 for (name, kind) in [("X-Null", "null"), ("X-Obj", "object"), ("X-Arr", "array")] {
10997 let dropped = outbound
10998 .drops
10999 .iter()
11000 .find(|d| d.name == name)
11001 .unwrap_or_else(|| panic!("{name} must have a drop record: {:?}", outbound.drops));
11002 assert_eq!(
11003 dropped.reason, "no scalar string form",
11004 "{name} drop reason must name the value kind absence"
11005 );
11006 assert_eq!(dropped.value_kind, Some(kind), "{name} kind recorded");
11007 }
11008 }
11009
11010 #[test]
11011 fn outbound_stringifies_scalars_despite_excluded_names() {
11012 let mut headers = HashMap::new();
11016 headers.insert("Transfer-Encoding".to_string(), serde_json::json!(7));
11017 headers.insert("Host".to_string(), serde_json::json!(12345));
11018 headers.insert("X-Ok".to_string(), serde_json::json!(7));
11019 let outbound = select_outbound_headers(&headers, &[], &[]);
11020 let has = |name: &str| {
11021 outbound
11022 .accepted
11023 .iter()
11024 .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
11025 };
11026 assert!(
11027 !has("Transfer-Encoding"),
11028 "hop-by-hop header must stay excluded"
11029 );
11030 assert!(!has("Host"), "host is destination-derived");
11031 assert!(has("X-Ok"), "non-excluded scalar must be stringified");
11032 assert!(
11033 outbound
11034 .drops
11035 .iter()
11036 .any(|d| d.name == "Transfer-Encoding" && d.reason == "outbound emission policy"),
11037 "policy drop must be recorded before coercion"
11038 );
11039 }
11040
11041 #[test]
11042 fn outbound_drops_invalid_names_values_and_skip_config() {
11043 let mut headers = make_headers(&[("X-Good", "fine")]);
11044 headers.insert("X Bad Name".to_string(), serde_json::json!("v"));
11045 headers.insert(
11046 "X-Control-Value".to_string(),
11047 serde_json::json!("line1\nline2"),
11048 );
11049 headers.insert("X-Secret".to_string(), serde_json::json!("s3cr3t"));
11050 headers.insert("CamelHttpQuery".to_string(), serde_json::json!("q=1"));
11051 let skip = vec!["x-secret".to_string()];
11052 let outbound = select_outbound_headers(&headers, &skip, &[]);
11053 let has = |name: &str| {
11054 outbound
11055 .accepted
11056 .iter()
11057 .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
11058 };
11059 assert!(has("X-Good"), "valid header must survive");
11060 assert!(!has("X Bad Name"), "invalid header name must drop");
11061 assert!(!has("X-Control-Value"), "control-char value must drop");
11062 assert!(!has("X-Secret"), "skipped header must drop");
11063 assert!(!has("CamelHttpQuery"), "Camel-namespace header must drop");
11064 let reason = |n: &str| {
11065 outbound
11066 .drops
11067 .iter()
11068 .find(|d| d.name == n)
11069 .map(|d| d.reason)
11070 };
11071 assert_eq!(reason("X Bad Name"), Some("invalid header name"));
11072 assert_eq!(reason("X-Control-Value"), Some("invalid header value"));
11073 assert_eq!(reason("X-Secret"), Some("skip_request_headers"));
11074 assert_eq!(reason("CamelHttpQuery"), Some("Camel namespace"));
11075 }
11076
11077 #[test]
11078 fn constructed_header_invalid_value_returns_drop_record() {
11079 let result = constructed_header("user-agent", "bad\r\ns3nt1nel");
11080 let Err(record) = result else {
11081 panic!("invalid value must produce a drop record");
11082 };
11083 assert_eq!(record.reason, "invalid header value");
11084 assert_eq!(record.name, "user-agent");
11085 assert!(record.value_kind.is_none());
11086 let debug = format!("{record:?}");
11087 assert!(
11088 !debug.contains("bad\r\n") && !debug.contains("s3nt1nel"),
11089 "drop record debug must not leak the value"
11090 );
11091 }
11092
11093 #[test]
11094 fn constructed_header_invalid_name_returns_drop_record() {
11095 let result = constructed_header("bad name", "ok");
11096 let Err(record) = result else {
11097 panic!("invalid name must produce a drop record");
11098 };
11099 assert_eq!(record.reason, "invalid header name");
11100 assert_eq!(record.name, "bad name");
11101 let debug = format!("{record:?}");
11102 assert!(
11103 !debug.contains("ok"),
11104 "drop record debug must not leak the value"
11105 );
11106 }
11107
11108 #[test]
11109 fn constructed_header_valid_pair_roundtrip() {
11110 let result = constructed_header("authorization", "Bearer abc123");
11111 let Ok((name, val)) = result else {
11112 panic!("valid pair must construct");
11113 };
11114 assert_eq!(name.as_str(), "authorization");
11115 let Ok(roundtrip) = val.to_str() else {
11116 panic!("valid value must roundtrip to str");
11117 };
11118 assert_eq!(roundtrip, "Bearer abc123");
11119 }
11120
11121 async fn start_host_capturing_destination() -> (
11133 String,
11134 Arc<std::sync::Mutex<Option<(String, String)>>>,
11135 tokio::task::JoinHandle<()>,
11136 ) {
11137 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11138 let port = listener.local_addr().unwrap().port();
11139 let url = format!("http://127.0.0.1:{port}");
11140 let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
11141 Arc::new(std::sync::Mutex::new(None));
11142 let captured_clone = Arc::clone(&captured);
11143 let handle = tokio::spawn(async move {
11144 use tokio::io::{AsyncReadExt, AsyncWriteExt};
11145 if let Ok((mut stream, _)) = listener.accept().await {
11146 let mut buf = vec![0u8; 16384];
11147 let n = stream.read(&mut buf).await.unwrap_or(0);
11148 let request = String::from_utf8_lossy(&buf[..n]).to_string();
11149 if request.contains("\r\n\r\n") {
11150 let request_line = request.lines().next().unwrap_or("").to_string();
11151 let host_value = request
11152 .lines()
11153 .find(|l| l.to_lowercase().starts_with("host:"))
11154 .and_then(|l| l.split_once(':'))
11155 .map(|(_, v)| v.trim().to_string())
11156 .unwrap_or_default();
11157 *captured_clone.lock().unwrap() = Some((host_value, request_line));
11158 }
11159 let body = r#"{"echo":"ok"}"#;
11160 let resp = format!(
11161 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
11162 body.len(),
11163 body
11164 );
11165 let _ = stream.write_all(resp.as_bytes()).await;
11166 }
11167 });
11168 (url, captured, handle)
11169 }
11170
11171 #[tokio::test]
11176 async fn bridge_proxy_outbound_host_matches_destination() {
11177 use tower::ServiceExt;
11178
11179 let (url, captured, _handle) = start_host_capturing_destination().await;
11180 let expected_host = url.strip_prefix("http://").unwrap();
11183
11184 let ctx = test_producer_ctx();
11185 let component = HttpComponent::new();
11186 let endpoint_ctx = NoOpComponentContext;
11187 let endpoint = component
11188 .create_endpoint(
11189 &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
11190 &endpoint_ctx,
11191 )
11192 .unwrap();
11193 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
11194
11195 let mut exchange = Exchange::new(Message::default());
11198 exchange.input.set_header("Host", "localhost");
11199 exchange.input.set_header("CamelHttpPath", "/foo");
11200
11201 let result = producer.oneshot(exchange).await;
11202 assert!(result.is_ok(), "producer call failed: {:?}", result);
11203
11204 tokio::time::sleep(Duration::from_millis(100)).await;
11205 let (host_value, request_line) = captured
11206 .lock()
11207 .unwrap()
11208 .take()
11209 .expect("destination capture mutex empty — producer did not reach the destination");
11210
11211 assert_ne!(
11212 host_value, "localhost",
11213 "bridge producer must not forward the exchange Host: localhost"
11214 );
11215 assert_eq!(
11216 host_value, expected_host,
11217 "Host must be derived from the destination authority (no scheme)"
11218 );
11219 assert!(
11220 !request_line.contains("/foo"),
11221 "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
11222 );
11223 }
11224
11225 #[tokio::test]
11230 async fn bridge_proxy_route_set_response_header_survives() {
11231 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
11232
11233 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11234 let port = listener.local_addr().unwrap().port();
11235 drop(listener);
11236
11237 let component = HttpComponent::new();
11238 let endpoint_ctx = NoOpComponentContext;
11239 let endpoint = component
11240 .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
11241 .unwrap();
11242 let mut consumer = endpoint.create_consumer(rt()).unwrap();
11243
11244 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
11245 let token = tokio_util::sync::CancellationToken::new();
11246 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
11247
11248 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
11249 tokio::time::sleep(Duration::from_millis(50)).await;
11250
11251 let client = reqwest::Client::new();
11252 let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
11253
11254 let (http_result, _) = tokio::join!(send_fut, async {
11258 if let Some(mut envelope) = rx.recv().await {
11259 envelope
11260 .exchange
11261 .input
11262 .set_header("Cache-Control", "public, max-age=3600");
11263 if let Some(reply_tx) = envelope.reply_tx {
11264 let _ = reply_tx.send(Ok(envelope.exchange));
11265 }
11266 }
11267 });
11268
11269 let resp = http_result.unwrap();
11270 assert_eq!(resp.status().as_u16(), 200);
11271
11272 let cache_control = resp.headers().get("cache-control");
11273 assert!(
11274 cache_control.is_some(),
11275 "Cache-Control header must survive to the wire response"
11276 );
11277 assert_eq!(
11278 cache_control.unwrap().to_str().unwrap(),
11279 "public, max-age=3600"
11280 );
11281
11282 token.cancel();
11283 }
11284
11285 use camel_api::security_policy::CredentialSource;
11304 use camel_auth::credential_source::extract_token_from_exchange;
11305 use camel_auth::native_auth::NativeCredentialStore;
11306 use camel_auth::{StaticTokenAuthenticator, TokenAuthenticator};
11307
11308 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 {
11316 let mut msg = Message::default();
11317 msg.set_header(
11318 "CamelHttpMethod",
11319 serde_json::Value::String(envelope.method.clone()),
11320 );
11321 msg.set_header(
11322 "CamelHttpPath",
11323 serde_json::Value::String(envelope.path.clone()),
11324 );
11325 msg.set_header(
11326 "CamelHttpQuery",
11327 serde_json::Value::String(envelope.query.clone()),
11328 );
11329 for (k, v) in &envelope.headers {
11330 if let Ok(val_str) = v.to_str() {
11331 msg.set_header(
11332 title_case_header(k.as_str()),
11333 serde_json::Value::String(val_str.to_string()),
11334 );
11335 }
11336 }
11337 Exchange::new(msg)
11338 }
11339
11340 async fn spawn_failing_auth_route(
11347 registry: &HttpRouteRegistry,
11348 path: &str,
11349 sources: Vec<CredentialSource>,
11350 ) {
11351 let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
11352 NativeCredentialStore::try_new(vec![]).unwrap(),
11353 ));
11354 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11355 registry.register_api_route(path.to_string(), tx).await;
11356 let path_owned = path.to_string();
11357 tokio::spawn(async move {
11358 while let Some(envelope) = rx.recv().await {
11359 let exchange = envelope_to_exchange(&envelope);
11360 let reply_tx = envelope.reply_tx;
11361 let result: Result<(), CamelError> = async {
11362 let token = extract_token_from_exchange(&exchange, &sources)
11363 .map(|extracted| extracted.token)
11364 .ok_or_else(|| {
11365 CamelError::Unauthenticated("no credential in any source".into())
11366 })?;
11367 authenticator.authenticate_bearer(&token).await?;
11368 Ok(())
11369 }
11370 .await;
11371 let reply = match result {
11372 Ok(()) => HttpReply {
11373 status: 200,
11374 headers: vec![],
11375 body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
11376 },
11377 Err(e) => pipeline_error_to_reply(e, &path_owned),
11378 };
11379 let _ = reply_tx.send(reply);
11380 }
11381 });
11382 }
11383
11384 fn captured_logs_contain(needle: &str) -> bool {
11388 let buf = tracing_test::internal::global_buf().lock().unwrap();
11389 String::from_utf8_lossy(&buf).contains(needle)
11390 }
11391
11392 #[tracing_test::traced_test]
11393 #[tokio::test]
11394 async fn error_context_redacts_query_sentinel() {
11395 let (port, registry) = spawn_test_server().await;
11396 spawn_failing_auth_route(
11397 ®istry,
11398 "/secure-query",
11399 vec![CredentialSource::QueryParam {
11400 param: "token".to_string(),
11401 }],
11402 )
11403 .await;
11404
11405 let client = reqwest::Client::new();
11406 let resp = client
11407 .get(format!(
11409 "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
11410 ))
11411 .send()
11412 .await
11413 .unwrap();
11414
11415 assert_eq!(resp.status().as_u16(), 401);
11416 let body = resp.text().await.unwrap();
11417 assert_eq!(body, "Unauthorized");
11418 assert!(
11419 !body.contains(SENTINEL_QRY_42),
11420 "reply body must not contain the query credential"
11421 );
11422 assert!(
11423 !captured_logs_contain(SENTINEL_QRY_42),
11424 "no tracing record during request handling may render the query credential"
11425 );
11426 assert!(
11430 captured_logs_contain("Authentication failed"),
11431 "positive control: the failed-auth warn! must be captured by the test subscriber"
11432 );
11433 }
11434
11435 #[tracing_test::traced_test]
11436 #[tokio::test]
11437 async fn error_context_redacts_cookie_sentinel() {
11438 let (port, registry) = spawn_test_server().await;
11439 spawn_failing_auth_route(
11440 ®istry,
11441 "/secure-cookie",
11442 vec![CredentialSource::Cookie {
11443 name: "session".to_string(),
11444 }],
11445 )
11446 .await;
11447
11448 let client = reqwest::Client::new();
11449 let resp = client
11450 .get(format!("http://127.0.0.1:{port}/secure-cookie"))
11451 .header("Cookie", format!("session={SENTINEL_CKY_7}"))
11452 .send()
11453 .await
11454 .unwrap();
11455
11456 assert_eq!(resp.status().as_u16(), 401);
11457 let body = resp.text().await.unwrap();
11458 assert_eq!(body, "Unauthorized");
11459 assert!(
11460 !body.contains(SENTINEL_CKY_7),
11461 "reply body must not contain the cookie credential"
11462 );
11463 assert!(
11464 !captured_logs_contain(SENTINEL_CKY_7),
11465 "no tracing record during request handling may render the cookie credential"
11466 );
11467 }
11468
11469 #[tracing_test::traced_test]
11470 #[tokio::test]
11471 async fn error_reply_no_credential_value() {
11472 let (port, registry) = spawn_test_server().await;
11473 spawn_failing_auth_route(
11474 ®istry,
11475 "/secure-bad",
11476 vec![CredentialSource::Cookie {
11477 name: "session".to_string(),
11478 }],
11479 )
11480 .await;
11481
11482 let client = reqwest::Client::new();
11483 let resp = client
11484 .get(format!("http://127.0.0.1:{port}/secure-bad"))
11485 .header("Cookie", format!("session={SENTINEL_BAD_1}"))
11486 .send()
11487 .await
11488 .unwrap();
11489
11490 assert_eq!(resp.status().as_u16(), 401);
11491 let body = resp.text().await.unwrap();
11492 assert_eq!(body, "Unauthorized");
11493 assert!(
11494 !body.contains(SENTINEL_BAD_1),
11495 "reply body must not contain the credential value"
11496 );
11497 assert!(
11498 !captured_logs_contain(SENTINEL_BAD_1),
11499 "error logs must not render the credential value"
11500 );
11501 }
11502
11503 async fn spawn_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
11517 use tokio::io::AsyncWriteExt;
11518
11519 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
11520 .await
11521 .expect("bind ephemeral 127.0.0.1 listener");
11522 let port = listener.local_addr().expect("local addr").port();
11523 let base_url = format!("http://localhost:{port}");
11524 let handle = tokio::spawn(async move {
11525 while let Ok((mut conn, _)) = listener.accept().await {
11526 let _ = conn
11527 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
11528 .await;
11529 let _ = conn.shutdown().await;
11530 }
11531 });
11532 (base_url, handle)
11533 }
11534
11535 async fn spawn_tls_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
11542 use tokio::io::AsyncWriteExt;
11543
11544 let (_ca_pem, cert_pem, key_pem) =
11545 camel_component_api::test_support::tls::gen_server_cert();
11546 let certs: Vec<_> = rustls_pemfile::certs(&mut cert_pem.as_bytes())
11547 .collect::<Result<_, _>>()
11548 .expect("parse server cert pem");
11549 let key = rustls_pemfile::private_key(&mut key_pem.as_bytes())
11550 .expect("parse server key pem")
11551 .expect("server key present");
11552 let provider =
11555 std::sync::Arc::new(tokio_rustls::rustls::crypto::aws_lc_rs::default_provider());
11556 let tls_cfg = tokio_rustls::rustls::ServerConfig::builder_with_provider(provider)
11557 .with_safe_default_protocol_versions()
11558 .expect("safe default protocol versions")
11559 .with_no_client_auth()
11560 .with_single_cert(certs, key)
11561 .expect("build rustls server config");
11562 let acceptor = tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(tls_cfg));
11563
11564 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
11565 .await
11566 .expect("bind ephemeral 127.0.0.1 listener");
11567 let port = listener.local_addr().expect("local addr").port();
11568 let base_url = format!("https://localhost:{port}");
11569 let handle = tokio::spawn(async move {
11570 while let Ok((conn, _)) = listener.accept().await {
11571 let acceptor = acceptor.clone();
11572 tokio::spawn(async move {
11573 if let Ok(mut tls) = acceptor.accept(conn).await {
11574 let _ = tls
11575 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
11576 .await;
11577 let _ = tls.shutdown().await;
11578 }
11579 });
11580 }
11581 });
11582 (base_url, handle)
11583 }
11584
11585 fn responder_port(base_url: &str) -> u16 {
11589 url::Url::parse(base_url)
11590 .expect("responder base URL parses")
11591 .port()
11592 .expect("responder base URL carries an explicit port")
11593 }
11594
11595 fn endpoint_with_shared_cache(
11600 base_url: &str,
11601 pinned_cache: &Arc<PinnedClientCache>,
11602 ) -> HttpEndpoint {
11603 let uri = format!("{base_url}?allowInternal=true");
11604 HttpEndpoint {
11605 uri: uri.clone(),
11606 config: HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses"),
11607 server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
11608 client: reqwest::Client::new(),
11609 pinned_cache: Arc::clone(pinned_cache),
11610 http_config: HttpConfig::default(),
11611 }
11612 }
11613
11614 #[tokio::test]
11615 async fn producers_share_endpoint_cache() {
11616 use tower::ServiceExt;
11617
11618 let (base_url, _handle) = spawn_multi_accept_200().await;
11619 let pinned_cache = Arc::new(PinnedClientCache::new(
11620 PINNED_CLIENT_TTL,
11621 PINNED_CLIENT_MAX_ENTRIES,
11622 ));
11623
11624 let ctx = test_producer_ctx();
11625 let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
11626 let producer_a = endpoint.create_producer(rt(), &ctx);
11627 let producer_b = endpoint.create_producer(rt(), &ctx);
11628
11629 for producer in [producer_a, producer_b] {
11632 let producer = producer.expect("create producer");
11633 let exchange = Exchange::new(Message::default());
11634 let reply = producer.oneshot(exchange).await;
11635 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
11636 }
11637
11638 assert_eq!(
11639 pinned_cache.build_count(),
11640 1,
11641 "both producers must hit the same shared cache entry; a second \
11642 build means sharing is broken"
11643 );
11644 }
11645
11646 #[tokio::test]
11647 async fn producer_repeated_hostname_requests_build_one_client() {
11648 use tower::ServiceExt;
11649
11650 let (base_url, _handle) = spawn_multi_accept_200().await;
11651 let pinned_cache = Arc::new(PinnedClientCache::new(
11652 PINNED_CLIENT_TTL,
11653 PINNED_CLIENT_MAX_ENTRIES,
11654 ));
11655 let ctx = test_producer_ctx();
11656 let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
11657 let producer = endpoint
11658 .create_producer(rt(), &ctx)
11659 .expect("create producer");
11660
11661 for i in 0..2 {
11664 let exchange = Exchange::new(Message::default());
11665 let reply = producer.clone().oneshot(exchange).await;
11666 assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
11667 }
11668
11669 assert_eq!(
11670 pinned_cache.build_count(),
11671 1,
11672 "repeated hostname requests must reuse the one pinned client; \
11673 0 builds means the producer bypassed the cache, more than 1 \
11674 means the entry was dropped"
11675 );
11676 }
11677
11678 #[tokio::test]
11679 async fn ip_literal_request_never_enters_cache() {
11680 use tower::ServiceExt;
11681
11682 let (base_url, _handle) = spawn_multi_accept_200().await;
11683 let pinned_cache = Arc::new(PinnedClientCache::new(
11684 PINNED_CLIENT_TTL,
11685 PINNED_CLIENT_MAX_ENTRIES,
11686 ));
11687
11688 let ctx = test_producer_ctx();
11689 let destination = format!("http://127.0.0.1:{}/ping", responder_port(&base_url));
11690 let endpoint = endpoint_with_shared_cache(&destination, &pinned_cache);
11691 let producer = endpoint
11692 .create_producer(rt(), &ctx)
11693 .expect("create producer");
11694
11695 let exchange = Exchange::new(Message::default());
11696 let reply = producer.oneshot(exchange).await;
11697 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
11698
11699 assert_eq!(
11700 pinned_cache.build_count(),
11701 0,
11702 "an IP-literal URL must use the shared unpinned client and \
11703 never enter the pinned cache"
11704 );
11705 }
11706
11707 #[tokio::test]
11708 async fn test_component_endpoints_share_pinned_cache() {
11709 use tower::ServiceExt;
11710
11711 let component = HttpComponent::new();
11712 let (base_url, _handle) = spawn_multi_accept_200().await;
11713 let baseline = component.pinned_cache.build_count();
11714
11715 let ctx = test_producer_ctx();
11716 let endpoint_ctx = NoOpComponentContext;
11717 for uri in [
11718 format!("{base_url}/a?allowInternal=true&k=a"),
11719 format!("{base_url}/b?allowInternal=true&k=b"),
11720 ] {
11721 let endpoint = component
11722 .create_endpoint(&uri, &endpoint_ctx)
11723 .expect("create endpoint");
11724 let producer = endpoint
11725 .create_producer(rt(), &ctx)
11726 .expect("create producer");
11727 let exchange = Exchange::new(Message::default());
11728 let reply = producer.oneshot(exchange).await;
11729 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
11730 }
11731
11732 assert_eq!(
11733 component.pinned_cache.build_count() - baseline,
11734 1,
11735 "endpoints created by one component must share its pinned cache; \
11736 0 builds means the endpoints bypassed it, more than 1 means \
11737 per-endpoint caches came back"
11738 );
11739 }
11740
11741 #[tokio::test]
11742 async fn test_dynamic_resolution_sequence_hits_shared_cache() {
11743 use tower::ServiceExt;
11744
11745 let component = HttpComponent::new();
11746 let (base_url, _handle) = spawn_multi_accept_200().await;
11747 let baseline = component.pinned_cache.build_count();
11748
11749 let ctx = test_producer_ctx();
11750 let endpoint_ctx = NoOpComponentContext;
11751 for i in 0..3 {
11752 let endpoint = component
11753 .create_endpoint(
11754 &format!("{base_url}/r{i}?allowInternal=true&k={i}"),
11755 &endpoint_ctx,
11756 )
11757 .expect("create endpoint");
11758 let producer = endpoint
11759 .create_producer(rt(), &ctx)
11760 .expect("create producer");
11761 let exchange = Exchange::new(Message::default());
11762 let reply = producer.oneshot(exchange).await;
11763 assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
11764 }
11765
11766 assert_eq!(
11767 component.pinned_cache.build_count() - baseline,
11768 1,
11769 "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
11770 must reuse the component's one pinned cache entry; 0 builds \
11771 means the endpoints bypassed it, more than 1 means \
11772 per-endpoint caches came back"
11773 );
11774 }
11775
11776 #[tokio::test]
11784 async fn test_https_component_endpoints_share_pinned_cache_behaviorally() {
11785 use tower::ServiceExt;
11786
11787 let http_config = HttpConfig {
11788 tls: Some(crate::config::TlsConfig {
11789 enabled: true,
11790 insecure: true,
11791 ..Default::default()
11792 }),
11793 ..Default::default()
11794 };
11795 let component = HttpsComponent::with_config(http_config);
11796 let (base_url, _handle) = spawn_tls_multi_accept_200().await;
11797 let baseline = component.pinned_cache.build_count();
11798
11799 let ctx = test_producer_ctx();
11800 let endpoint_ctx = NoOpComponentContext;
11801 for uri in [
11802 format!("{base_url}/a?allowInternal=true&k=a"),
11803 format!("{base_url}/b?allowInternal=true&k=b"),
11804 ] {
11805 let endpoint = component
11806 .create_endpoint(&uri, &endpoint_ctx)
11807 .expect("create https endpoint");
11808 let producer = endpoint
11809 .create_producer(rt(), &ctx)
11810 .expect("create producer");
11811 let exchange = Exchange::new(Message::default());
11812 let reply = producer.oneshot(exchange).await;
11813 assert!(reply.is_ok(), "https request failed: {reply:?}");
11814 }
11815
11816 assert_eq!(
11817 component.pinned_cache.build_count() - baseline,
11818 1,
11819 "endpoints of one HttpsComponent must share its pinned cache over \
11820 real https requests; 0 builds means the endpoints bypassed it \
11821 (per-endpoint cache regression), more than 1 means \
11822 per-endpoint caches came back"
11823 );
11824 }
11825
11826 #[test]
11827 fn test_https_component_owns_distinct_cache() {
11828 let http = HttpComponent::new();
11829 let https = HttpsComponent::new();
11830
11831 assert!(
11832 !Arc::ptr_eq(&http.pinned_cache, &https.pinned_cache),
11833 "http and https components must each own their own pinned cache"
11834 );
11835
11836 let endpoint_ctx = NoOpComponentContext;
11837 let _ = http
11838 .create_endpoint("http://localhost:1/?allowInternal=true", &endpoint_ctx)
11839 .expect("http endpoint");
11840 let _ = https
11841 .create_endpoint("https://localhost:1/?allowInternal=true", &endpoint_ctx)
11842 .expect("https endpoint");
11843
11844 assert_eq!(
11845 http.pinned_cache.build_count(),
11846 0,
11847 "endpoint creation must not build a pinned client"
11848 );
11849 assert_eq!(
11850 https.pinned_cache.build_count(),
11851 0,
11852 "endpoint creation must not build a pinned client"
11853 );
11854 }
11855
11856 #[test]
11857 fn test_component_constructor_builds_one_unpinned_client() {
11858 let baseline = build_client_call_count();
11859
11860 let _http = HttpComponent::new();
11861 assert_eq!(
11862 build_client_call_count() - baseline,
11863 1,
11864 "HttpComponent::new() must build exactly one shared unpinned client"
11865 );
11866
11867 let _https = HttpsComponent::new();
11868 assert_eq!(
11869 build_client_call_count() - baseline,
11870 2,
11871 "HttpsComponent::new() must build exactly one more shared unpinned client"
11872 );
11873 }
11874
11875 #[test]
11876 fn test_component_endpoints_share_unpinned_client() {
11877 let component = HttpComponent::new();
11878 let baseline = build_client_call_count();
11879
11880 let endpoint_ctx = NoOpComponentContext;
11881 for uri in [
11882 "http://localhost:1/a?allowInternal=true",
11883 "http://localhost:1/b?allowInternal=true",
11884 ] {
11885 let _endpoint = component
11886 .create_endpoint(uri, &endpoint_ctx)
11887 .expect("create endpoint");
11888 }
11889
11890 assert_eq!(
11891 build_client_call_count() - baseline,
11892 0,
11893 "create_endpoint must clone the component's shared unpinned client, \
11894 never build a fresh one"
11895 );
11896 }
11897
11898 #[test]
11899 fn test_dynamic_resolution_adds_no_unpinned_client_builds() {
11900 let component = HttpComponent::new();
11901 let baseline = build_client_call_count();
11902
11903 let ctx = test_producer_ctx();
11904 let endpoint_ctx = NoOpComponentContext;
11905 for i in 0..3 {
11906 let endpoint = component
11907 .create_endpoint(
11908 &format!("http://localhost:1/r{i}?allowInternal=true&k={i}"),
11909 &endpoint_ctx,
11910 )
11911 .expect("create endpoint");
11912 let _producer = endpoint
11913 .create_producer(rt(), &ctx)
11914 .expect("create producer");
11915 }
11916
11917 assert_eq!(
11918 build_client_call_count() - baseline,
11919 0,
11920 "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
11921 must reuse the component's shared unpinned client and build \
11922 no additional clients"
11923 );
11924 }
11925}