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 {
2895 const MAX_URL_LOG_LEN: usize = 256;
2896 match url::Url::parse(raw) {
2897 Ok(mut u) => {
2898 if !u.username().is_empty() || u.password().is_some() {
2899 let _ = u.set_username("***");
2900 let _ = u.set_password(None);
2901 }
2902 if u.query().is_some() {
2903 u.set_query(None);
2904 let mut s = u.to_string();
2906 if let Some(stripped) = s.strip_suffix('?') {
2907 s = stripped.to_string();
2908 }
2909 s.push_str("?[redacted]");
2910 truncate_utf8_safe(&mut s, MAX_URL_LOG_LEN);
2911 return s;
2912 }
2913 let mut s = u.to_string();
2914 truncate_utf8_safe(&mut s, MAX_URL_LOG_LEN);
2915 s
2916 }
2917 Err(_) => {
2918 if let Some(start) = raw.find("//").map(|idx| idx + 2) {
2922 let end = raw[start..]
2923 .find(['/', '?', '#'])
2924 .map_or(raw.len(), |offset| start + offset);
2925 if raw[start..end].contains('@') {
2926 return "[redacted]".to_string();
2927 }
2928 }
2929 let mut s = raw.to_string();
2930 if let Some(query_start) = raw.find('?') {
2931 s.truncate(query_start);
2932 s.push_str("?[redacted]");
2933 }
2934 truncate_utf8_safe(&mut s, MAX_URL_LOG_LEN);
2935 s
2936 }
2937 }
2938}
2939
2940fn truncate_utf8_safe(s: &mut String, max: usize) {
2944 if s.len() <= max {
2945 return;
2946 }
2947 let mut cut = max;
2948 while !s.is_char_boundary(cut) {
2949 cut -= 1;
2950 }
2951 s.truncate(cut);
2952}
2953
2954const MAX_ERROR_RESPONSE_BODY_BYTES: usize = 4096;
2959
2960fn truncate_error_body(body: &[u8]) -> String {
2961 if body.len() <= MAX_ERROR_RESPONSE_BODY_BYTES {
2962 String::from_utf8_lossy(body).into_owned()
2963 } else {
2964 let mut s = String::from_utf8_lossy(&body[..MAX_ERROR_RESPONSE_BODY_BYTES]).into_owned();
2965 s.push_str("...[truncated]");
2966 s
2967 }
2968}
2969
2970impl HttpProducer {
2971 fn is_entity_enclosing(method: &str) -> bool {
2976 matches!(method, "POST" | "PUT" | "PATCH")
2977 }
2978}
2979
2980impl Service<Exchange> for HttpProducer {
2981 type Response = Exchange;
2982 type Error = CamelError;
2983 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
2984
2985 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
2986 Poll::Ready(Ok(()))
2987 }
2988
2989 fn call(&mut self, exchange: Exchange) -> Self::Future {
2990 let config = self.config.clone();
2991 let shared_client = self.client.clone();
2992 let pinned_cache = std::sync::Arc::clone(&self.pinned_cache);
2993 let http_config = self.http_config.clone();
2994 let component_metrics = self.runtime.component_metrics();
2995
2996 Box::pin(async move {
2997 let mut exchange = exchange;
2998 let outcome = async {
2999 let method_str = HttpProducer::resolve_method(&exchange, &config);
3000 let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
3005 let url = HttpProducer::resolve_url(&exchange, &config)?;
3006
3007 ssrf::validate_url_for_ssrf(&url, &config)?;
3009
3010 let resolved =
3018 ssrf::resolve_initial_url_for_ssrf(&url, config.allow_internal).await?;
3019 let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
3020 pinned_cache
3021 .get_or_build(host.as_str(), addrs, || {
3022 build_client(&http_config, Some((host.as_str(), addrs)))
3023 })
3024 .await
3025 } else {
3026 shared_client.clone()
3027 };
3028
3029 debug!(
3030 correlation_id = %exchange.correlation_id(),
3031 method = %method_str,
3032 url = %redact_url_for_diagnostics(&url),
3033 "HTTP request"
3034 );
3035
3036 let method = method_str.parse::<reqwest::Method>().map_err(|e| {
3037 CamelError::ProcessorError(format!(
3038 "Invalid HTTP method '{}': {}",
3039 method_str, e
3040 ))
3041 })?;
3042
3043 let mut collected_headers: Vec<(
3045 reqwest::header::HeaderName,
3046 reqwest::header::HeaderValue,
3047 )> = Vec::new();
3048
3049 if let Some(user_agent) = &config.user_agent
3050 && !config.bridge_endpoint
3051 {
3052 match constructed_header("user-agent", user_agent) {
3053 Ok((_, val)) => {
3054 collected_headers.push((reqwest::header::USER_AGENT, val));
3055 }
3056 Err(drop) => debug!(
3057 correlation_id = %exchange.correlation_id(),
3058 header = %drop.name,
3059 "outbound header dropped: {}",
3060 drop.reason
3061 ),
3062 }
3063 }
3064
3065 #[cfg(feature = "otel")]
3067 let should_inject_otel = !config.bridge_endpoint;
3068 #[cfg(feature = "otel")]
3069 if should_inject_otel {
3070 let mut otel_headers = HashMap::new();
3071 camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
3072 for (k, v) in otel_headers {
3073 match constructed_header(&k, &v) {
3074 Ok((name, val)) => collected_headers.push((name, val)),
3075 Err(drop) => debug!(
3076 correlation_id = %exchange.correlation_id(),
3077 header = %drop.name,
3078 "outbound header dropped: {}",
3079 drop.reason
3080 ),
3081 }
3082 }
3083 }
3084
3085 let conn_tokens = header_policy::connection_tokens(
3086 exchange
3087 .input
3088 .headers
3089 .iter()
3090 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3091 .filter_map(|(_, v)| v.as_str()),
3092 );
3093
3094 let outbound = select_outbound_headers(
3095 &exchange.input.headers,
3096 &config.skip_request_headers,
3097 &conn_tokens,
3098 );
3099 for drop in &outbound.drops {
3100 if let Some(value_kind) = drop.value_kind {
3101 debug!(
3102 correlation_id = %exchange.correlation_id(),
3103 header = %drop.name,
3104 value_kind = value_kind,
3105 "outbound header dropped: {}",
3106 drop.reason
3107 );
3108 } else {
3109 debug!(
3110 correlation_id = %exchange.correlation_id(),
3111 header = %drop.name,
3112 "outbound header dropped: {}",
3113 drop.reason
3114 );
3115 }
3116 }
3117 collected_headers.extend(outbound.accepted);
3118
3119 if !config.bridge_endpoint {
3121 match &config.auth {
3122 HttpAuth::None => {}
3123 HttpAuth::Basic { username, password } => {
3124 use base64::Engine;
3125 let credentials = format!("{username}:{password}");
3127 let encoded =
3128 base64::engine::general_purpose::STANDARD.encode(credentials);
3129 match constructed_header("authorization", &format!("Basic {encoded}")) {
3132 Ok((_, val)) => {
3133 collected_headers.push((reqwest::header::AUTHORIZATION, val));
3134 }
3135 Err(drop) => debug!(
3136 correlation_id = %exchange.correlation_id(),
3137 header = %drop.name,
3138 "outbound header dropped: {}",
3139 drop.reason
3140 ),
3141 }
3142 }
3143 HttpAuth::Bearer { token } => {
3144 let bearer = format!("Bearer {token}");
3146 match constructed_header("authorization", &bearer) {
3147 Ok((_, val)) => {
3148 collected_headers.push((reqwest::header::AUTHORIZATION, val));
3149 }
3150 Err(drop) => debug!(
3151 correlation_id = %exchange.correlation_id(),
3152 header = %drop.name,
3153 "outbound header dropped: {}",
3154 drop.reason
3155 ),
3156 }
3157 }
3158 }
3159
3160 if config.connection_close {
3161 collected_headers.push((
3162 reqwest::header::CONNECTION,
3163 reqwest::header::HeaderValue::from_static("close"),
3164 ));
3165 }
3166 }
3167
3168 let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
3170 let materialized_body: Option<Vec<u8>> = if is_stream_body {
3171 if suppress_body {
3172 std::mem::take(&mut exchange.input.body);
3180 tracing::warn!(
3182 correlation_id = %exchange.correlation_id(),
3183 method = %method_str,
3184 "dropping request body for non-entity-enclosing HTTP method"
3185 );
3186 }
3187 None } else {
3189 let body = std::mem::take(&mut exchange.input.body);
3190 let bytes = body.into_bytes(config.max_body_size).await?;
3191 if bytes.is_empty() {
3192 None
3194 } else if suppress_body {
3195 tracing::warn!(
3197 correlation_id = %exchange.correlation_id(),
3198 method = %method_str,
3199 "dropping request body for non-entity-enclosing HTTP method"
3200 );
3201 None
3202 } else {
3203 Some(bytes.to_vec())
3204 }
3205 };
3206
3207 let response = if config.follow_redirects && !is_stream_body {
3208 ssrf::send_with_ssrf_safe_redirects(
3214 &client,
3215 &shared_client,
3216 &pinned_cache,
3217 &http_config,
3218 &config,
3219 method,
3220 &url,
3221 collected_headers,
3222 materialized_body,
3223 config.max_redirects,
3224 config.response_timeout,
3225 )
3226 .await?
3227 } else {
3228 let mut request = client.request(method, &url);
3230
3231 if let Some(timeout) = config.response_timeout {
3232 request = request.timeout(timeout);
3233 }
3234
3235 for (name, value) in &collected_headers {
3236 request = request.header(name, value);
3237 }
3238
3239 if is_stream_body {
3240 if let Body::Stream(ref s) = exchange.input.body {
3241 let mut stream_lock = s.stream.lock().await;
3242 if let Some(stream) = stream_lock.take() {
3243 request = request.body(reqwest::Body::wrap_stream(stream));
3244 } else {
3245 return Err(CamelError::AlreadyConsumed);
3246 }
3247 }
3248 } else if let Some(ref body_bytes) = materialized_body {
3249 request = request.body(body_bytes.clone());
3250 }
3251
3252 request.send().await.map_err(|e| {
3253 CamelError::ProcessorError(format!("HTTP request failed: {e}"))
3254 })?
3255 };
3256
3257 let status_code = response.status().as_u16();
3258 let status_text = response
3259 .status()
3260 .canonical_reason()
3261 .unwrap_or("Unknown")
3262 .to_string();
3263
3264 for (key, value) in response.headers() {
3265 if config
3266 .skip_response_headers
3267 .iter()
3268 .any(|h| h.eq_ignore_ascii_case(key.as_str()))
3269 {
3270 continue;
3271 }
3272 if let Ok(val_str) = value.to_str() {
3273 exchange.input.set_header(
3274 title_case_header(key.as_str()),
3275 serde_json::Value::String(val_str.to_string()),
3276 );
3277 }
3278 }
3279
3280 exchange.input.set_header(
3281 "CamelHttpResponseCode",
3282 serde_json::Value::Number(status_code.into()),
3283 );
3284 exchange.input.set_header(
3285 "CamelHttpResponseText",
3286 serde_json::Value::String(status_text.clone()),
3287 );
3288
3289 let read_timeout = Duration::from_millis(config.read_timeout_ms);
3291 let response_body = tokio::time::timeout(read_timeout, async {
3292 if let Some(content_len) = response.content_length()
3294 && content_len > config.max_response_bytes as u64
3295 {
3296 return Err(CamelError::ProcessorError(format!(
3297 "Response body too large: {} bytes exceeds limit of {} bytes",
3298 content_len, config.max_response_bytes
3299 )));
3300 }
3301 use futures::TryStreamExt;
3303 let mut stream = response.bytes_stream();
3304 let mut total: usize = 0;
3305 let mut collected = Vec::new();
3306 while let Some(chunk) = stream.try_next().await.map_err(|e| {
3307 CamelError::ProcessorError(format!("Failed to read response body: {e}"))
3308 })? {
3309 total += chunk.len();
3310 if total > config.max_response_bytes {
3311 return Err(CamelError::ProcessorError(format!(
3312 "Response body too large: {} bytes exceeds limit of {} bytes",
3313 total, config.max_response_bytes
3314 )));
3315 }
3316 collected.push(chunk);
3317 }
3318 let mut result = bytes::BytesMut::with_capacity(total);
3319 for chunk in collected {
3320 result.extend_from_slice(&chunk);
3321 }
3322 Ok::<bytes::Bytes, CamelError>(result.freeze())
3323 })
3324 .await
3325 .map_err(|_| {
3326 CamelError::ProcessorError(format!(
3327 "Read timeout after {}ms",
3328 config.read_timeout_ms
3329 ))
3330 })??;
3331
3332 if config.throw_exception_on_failure
3333 && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
3334 {
3335 return Err(CamelError::HttpOperationFailed {
3336 method: method_str,
3337 url: redact_url_for_diagnostics(&url),
3340 status_code,
3341 status_text,
3342 response_body: Some(truncate_error_body(&response_body)),
3343 });
3344 }
3345
3346 if !response_body.is_empty() {
3347 exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
3348 }
3349
3350 debug!(
3351 correlation_id = %exchange.correlation_id(),
3352 status = status_code,
3353 url = %redact_url_for_diagnostics(&url),
3354 "HTTP response"
3355 );
3356 Ok(exchange)
3357 }
3358 .await;
3359 component_metrics.observe("http", "request", outcome.is_err());
3366 outcome
3367 })
3368 }
3369}
3370
3371#[cfg(test)]
3381pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
3382
3383#[cfg(test)]
3391pub(crate) fn lock_registry_test_mutex() -> std::sync::MutexGuard<'static, ()> {
3392 REGISTRY_TEST_MUTEX
3393 .lock()
3394 .unwrap_or_else(|poisoned| poisoned.into_inner())
3395}
3396
3397fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
3407 match e {
3408 CamelError::Unauthenticated(msg) => {
3409 tracing::warn!(error = %msg, path = %path, "Authentication failed");
3410 HttpReply {
3411 status: 401,
3412 headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
3413 body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
3414 }
3415 }
3416 CamelError::Unauthorized(msg) => {
3417 tracing::warn!(error = %msg, path = %path, "Authorization failed");
3418 HttpReply {
3419 status: 403,
3420 headers: vec![],
3421 body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
3422 }
3423 }
3424 CamelError::TypeConversionFailed(msg) => {
3425 tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
3426 json_error_reply(400, "bad_request", msg)
3427 }
3428 CamelError::ValidationError(msg) => {
3429 tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
3430 json_error_reply(400, "validation_error", msg)
3431 }
3432 CamelError::ConsumerStopping => {
3433 tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
3434 HttpReply {
3435 status: 503,
3436 headers: vec![],
3437 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
3438 }
3439 }
3440 CamelError::UnsupportedMediaType { consumed, declared } => {
3441 tracing::warn!(error = %consumed, declared = %declared, path = %path, "Unsupported media type (bad request)");
3442 json_error_reply(
3443 415,
3444 "unsupported_media_type",
3445 format!("consumed {consumed}, declared {declared}"),
3446 )
3447 }
3448 CamelError::NotAcceptable { accept, produced } => {
3449 tracing::warn!(error = %accept, produced = %produced, path = %path, "Not acceptable (bad request)");
3450 json_error_reply(
3451 406,
3452 "not_acceptable",
3453 format!("accept {accept}, produced {produced}"),
3454 )
3455 }
3456 e => {
3457 tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
3459 HttpReply {
3460 status: 500,
3461 headers: vec![],
3462 body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
3463 }
3464 }
3465 }
3466}
3467
3468fn json_error_reply(status: u16, code: &str, message: String) -> HttpReply {
3476 let body = serde_json::to_string(&serde_json::json!({
3477 "error": code,
3478 "message": message,
3479 }))
3480 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
3482 status,
3483 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
3484 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
3485 }
3486}
3487
3488const fn json_value_kind(v: &serde_json::Value) -> &'static str {
3492 match v {
3493 serde_json::Value::Null => "null",
3494 serde_json::Value::Bool(_) => "bool",
3495 serde_json::Value::Number(_) => "number",
3496 serde_json::Value::String(_) => "string",
3497 serde_json::Value::Array(_) => "array",
3498 serde_json::Value::Object(_) => "object",
3499 }
3500}
3501
3502fn scalar_string_form(v: &serde_json::Value) -> Option<String> {
3507 match v {
3508 serde_json::Value::String(s) => Some(s.clone()),
3509 serde_json::Value::Number(n) => Some(n.to_string()),
3510 serde_json::Value::Bool(b) => Some(b.to_string()),
3511 _ => None,
3512 }
3513}
3514
3515fn select_response_headers(
3530 headers: &HashMap<String, serde_json::Value>,
3531 user_content_type: Option<String>,
3532 inferred_content_type: Option<String>,
3533) -> Vec<(String, String)> {
3534 let conn_tokens = header_policy::connection_tokens(
3535 headers
3536 .iter()
3537 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3538 .filter_map(|(_, v)| v.as_str()),
3539 );
3540 let mut selected: Vec<(String, String)> = Vec::new();
3541 for (k, v) in headers {
3542 if k.starts_with("Camel") {
3543 debug!(header = %k, "reply header dropped: Camel namespace");
3544 continue;
3545 }
3546 if header_policy::excluded_response(k, &conn_tokens) {
3547 debug!(header = %k, "reply header dropped: emission policy");
3548 continue;
3549 }
3550 match scalar_string_form(v) {
3551 Some(s) => selected.push((k.clone(), s)),
3552 None => debug!(
3553 header = %k,
3554 value_kind = json_value_kind(v),
3555 "reply header dropped: no scalar string form"
3556 ),
3557 }
3558 }
3559 if let Some(ct) = user_content_type.or(inferred_content_type) {
3560 selected.push(("Content-Type".to_string(), ct));
3561 }
3562 selected
3563}
3564
3565#[derive(Debug)]
3570struct OutboundHeaderDrop<'a> {
3571 name: &'a str,
3572 reason: &'static str,
3573 value_kind: Option<&'static str>,
3574}
3575
3576struct OutboundHeaderSelection<'a> {
3579 accepted: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>,
3580 drops: Vec<OutboundHeaderDrop<'a>>,
3581}
3582
3583fn select_outbound_headers<'a>(
3597 headers: &'a HashMap<String, serde_json::Value>,
3598 skip_request_headers: &[String],
3599 conn_tokens: &[String],
3600) -> OutboundHeaderSelection<'a> {
3601 let mut accepted = Vec::new();
3602 let mut drops = Vec::new();
3603 for (key, value) in headers {
3604 if key.starts_with("Camel") {
3605 drops.push(OutboundHeaderDrop {
3606 name: key,
3607 reason: "Camel namespace",
3608 value_kind: None,
3609 });
3610 continue;
3611 }
3612 if skip_request_headers
3613 .iter()
3614 .any(|h| h.eq_ignore_ascii_case(key))
3615 {
3616 drops.push(OutboundHeaderDrop {
3617 name: key,
3618 reason: "skip_request_headers",
3619 value_kind: None,
3620 });
3621 continue;
3622 }
3623 if header_policy::excluded_outbound(key, conn_tokens) {
3624 drops.push(OutboundHeaderDrop {
3625 name: key,
3626 reason: "outbound emission policy",
3627 value_kind: None,
3628 });
3629 continue;
3630 }
3631 let Some(val_str) = scalar_string_form(value) else {
3632 drops.push(OutboundHeaderDrop {
3633 name: key,
3634 reason: "no scalar string form",
3635 value_kind: Some(json_value_kind(value)),
3636 });
3637 continue;
3638 };
3639 match constructed_header(key, &val_str) {
3640 Ok((name, val)) => accepted.push((name, val)),
3641 Err(drop) => drops.push(drop),
3642 }
3643 }
3644 OutboundHeaderSelection { accepted, drops }
3645}
3646
3647fn constructed_header<'a>(
3652 name: &'a str,
3653 value: &str,
3654) -> Result<(reqwest::header::HeaderName, reqwest::header::HeaderValue), OutboundHeaderDrop<'a>> {
3655 let header_name = match reqwest::header::HeaderName::from_bytes(name.as_bytes()) {
3656 Ok(header_name) => header_name,
3657 Err(_) => {
3658 return Err(OutboundHeaderDrop {
3659 name,
3660 reason: "invalid header name",
3661 value_kind: None,
3662 });
3663 }
3664 };
3665 let header_value = match reqwest::header::HeaderValue::from_str(value) {
3666 Ok(header_value) => header_value,
3667 Err(_) => {
3668 return Err(OutboundHeaderDrop {
3669 name,
3670 reason: "invalid header value",
3671 value_kind: None,
3672 });
3673 }
3674 };
3675 Ok((header_name, header_value))
3676}
3677
3678#[cfg(test)]
3679mod tests {
3680 use camel_component_api::test_support::NoopRuntimeObservability;
3681
3682 fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3686 std::sync::Arc::new(NoopRuntimeObservability)
3687 }
3688 fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3689 std::sync::Arc::new(NoopRuntimeObservability)
3690 }
3691 fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3692 std::sync::Arc::new(NoopRuntimeObservability)
3693 }
3694
3695 use super::*;
3696 use crate::rest_match::PathSegment;
3697 use camel_component_api::{Message, NoOpComponentContext};
3698 use std::sync::Arc;
3699 use std::time::Duration;
3700
3701 fn test_producer_ctx() -> ProducerContext {
3702 ProducerContext::new()
3703 }
3704
3705 #[test]
3710 fn redact_url_masks_userinfo_and_query() {
3711 let redacted =
3712 redact_url_for_diagnostics("http://user:secretpass@internal.example/api?token=abc123");
3713 assert!(
3714 !redacted.contains("secretpass"),
3715 "password must be masked: {redacted}"
3716 );
3717 assert!(
3718 !redacted.contains("token=abc123"),
3719 "query must be masked: {redacted}"
3720 );
3721 assert!(
3722 !redacted.contains("user@"),
3723 "username must be masked: {redacted}"
3724 );
3725 assert!(
3726 redacted.contains("internal.example"),
3727 "host stays visible: {redacted}"
3728 );
3729 assert!(redacted.contains("[redacted]"), "query marked: {redacted}");
3730 }
3731
3732 #[test]
3733 fn redact_url_keeps_clean_urls_visible() {
3734 let redacted = redact_url_for_diagnostics("https://api.example.com/v1/items");
3735 assert_eq!(redacted, "https://api.example.com/v1/items");
3736 }
3737
3738 #[test]
3739 fn redact_url_masks_password_only_userinfo() {
3740 let redacted = redact_url_for_diagnostics("http://:pwsecret@host.example/");
3741 assert!(
3742 !redacted.contains("pwsecret"),
3743 "password-only userinfo leaked: {redacted}"
3744 );
3745 assert_eq!(redacted, "http://***@host.example/");
3746
3747 let redacted = redact_url_for_diagnostics("http://user:pw2@host.example/api");
3748 assert!(!redacted.contains("pw2"), "password leaked: {redacted}");
3749 assert_eq!(redacted, "http://***@host.example/api");
3750
3751 let redacted = redact_url_for_diagnostics("http://host.example/api");
3752 assert_eq!(redacted, "http://host.example/api");
3753 }
3754
3755 #[test]
3756 fn redact_url_truncates_unparseable() {
3757 let long = "x".repeat(1000);
3758 let redacted = redact_url_for_diagnostics(&long);
3759 assert_eq!(redacted.len(), 256, "unparseable URL must be truncated");
3760 }
3761
3762 #[test]
3763 fn redact_url_suppresses_unparseable_authority_credentials() {
3764 let fixtures = [
3765 "http://u:secretpw@/x",
3766 "http://u:secretpw@host:99999/x",
3767 "http://u:secretpw@host:99999",
3768 "//u:secretpw@h/x",
3769 ];
3770 for fixture in fixtures {
3771 assert!(
3772 url::Url::parse(fixture).is_err(),
3773 "fixture must be unparseable: {fixture}"
3774 );
3775 let redacted = redact_url_for_diagnostics(fixture);
3776 assert_eq!(
3777 redacted, "[redacted]",
3778 "credential-bearing authority must be suppressed: {fixture}"
3779 );
3780 }
3781 }
3782
3783 #[test]
3784 fn redact_url_bd_repro_never_leaks_credentials() {
3785 let redacted = redact_url_for_diagnostics("http://user:pa%ss@host/path");
3786 assert!(
3787 !redacted.contains("user:pa%ss"),
3788 "bd rc-2i5c5 repro leaked userinfo: {redacted}"
3789 );
3790 assert!(
3791 !redacted.contains("pa%ss"),
3792 "bd rc-2i5c5 repro leaked password: {redacted}"
3793 );
3794 }
3795
3796 #[test]
3797 fn redact_url_unparseable_query_redacted_short_and_long() {
3798 let short = "http://host:99999/path?token=shortsecret";
3799 assert!(
3800 url::Url::parse(short).is_err(),
3801 "fixture must be unparseable: {short}"
3802 );
3803 let redacted = redact_url_for_diagnostics(short);
3804 assert_eq!(
3805 redacted, "http://host:99999/path?[redacted]",
3806 "short unparseable query must end with the suffix: {redacted}"
3807 );
3808
3809 let mut long = String::from("http://host:99999/");
3810 long.push_str(&"a".repeat(300));
3811 long.push_str("?token=longsecret");
3812 assert!(
3813 url::Url::parse(&long).is_err(),
3814 "fixture must be unparseable: {long}"
3815 );
3816 let redacted = redact_url_for_diagnostics(&long);
3817 assert!(
3818 !redacted.contains("longsecret"),
3819 "long unparseable query leaked a query byte: {redacted}"
3820 );
3821 assert!(
3822 redacted.len() <= 256,
3823 "long unparseable query must be capped: {} bytes",
3824 redacted.len()
3825 );
3826 }
3827
3828 #[test]
3829 fn redact_url_unparseable_utf8_straddle_no_panic() {
3830 let fixture = format!("a{}", "é".repeat(200));
3831 let redacted = redact_url_for_diagnostics(&fixture);
3832 assert!(
3833 redacted.len() <= 256,
3834 "straddle fixture must be capped: {} bytes",
3835 redacted.len()
3836 );
3837 assert!(
3838 redacted.len() >= 253,
3839 "straddle fixture must not over-truncate: {} bytes",
3840 redacted.len()
3841 );
3842 assert!(
3843 fixture.is_char_boundary(redacted.len()),
3844 "cut must land on a UTF-8 char boundary: {} bytes",
3845 redacted.len()
3846 );
3847 }
3848
3849 #[test]
3850 fn redact_url_at_sign_outside_authority_window_visible() {
3851 let at_sign_in_path = "http://host:99999/x@y";
3852 assert!(
3853 url::Url::parse(at_sign_in_path).is_err(),
3854 "fixture must be unparseable: {at_sign_in_path}"
3855 );
3856 assert_eq!(
3857 redact_url_for_diagnostics(at_sign_in_path),
3858 at_sign_in_path,
3859 "at-sign in path must not be suppressed"
3860 );
3861 assert_eq!(
3863 redact_url_for_diagnostics("mailto:user@example.com"),
3864 "mailto:user@example.com",
3865 "at-sign in mailto must round-trip byte-identically"
3866 );
3867 }
3868
3869 #[test]
3870 fn truncate_error_body_caps_attacker_body() {
3871 let big = vec![b'A'; 10 * 1024 * 1024];
3872 let truncated = truncate_error_body(&big);
3873 assert!(
3874 truncated.len() <= MAX_ERROR_RESPONSE_BODY_BYTES + 20,
3875 "body must be capped near {} bytes, got {}",
3876 MAX_ERROR_RESPONSE_BODY_BYTES,
3877 truncated.len()
3878 );
3879 assert!(truncated.ends_with("...[truncated]"));
3880 }
3881
3882 #[test]
3883 fn truncate_error_body_keeps_small_body() {
3884 assert_eq!(truncate_error_body(b"boom"), "boom");
3885 }
3886
3887 #[test]
3888 fn test_http_config_defaults() {
3889 let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
3890 assert_eq!(config.base_url, "http://localhost:8080/api");
3891 assert!(config.http_method.is_none());
3892 assert!(config.throw_exception_on_failure);
3893 assert_eq!(config.ok_status_code_range, (200, 299));
3894 assert!(config.response_timeout.is_none());
3895 assert!(matches!(config.auth, HttpAuth::None));
3896 assert!(!config.bridge_endpoint);
3897 assert!(!config.connection_close);
3898 }
3899
3900 #[test]
3901 fn test_http_config_scheme() {
3902 assert_eq!(HttpEndpointConfig::scheme(), "http");
3904 }
3905
3906 #[test]
3907 fn test_http_config_from_components() {
3908 let components = camel_component_api::UriComponents {
3910 scheme: "https".to_string(),
3911 path: "//api.example.com/v1".to_string(),
3912 params: std::collections::HashMap::from([(
3913 "httpMethod".to_string(),
3914 "POST".to_string(),
3915 )]),
3916 raw_query: None,
3917 };
3918 let config = HttpEndpointConfig::from_components(components).unwrap();
3919 assert_eq!(config.base_url, "https://api.example.com/v1");
3920 assert_eq!(config.http_method, Some("POST".to_string()));
3921 }
3922
3923 #[test]
3924 fn test_http_config_with_options() {
3925 let config = HttpEndpointConfig::from_uri(
3926 "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
3927 ).unwrap();
3928 assert_eq!(config.base_url, "https://api.example.com/v1");
3929 assert_eq!(config.http_method, Some("PUT".to_string()));
3930 assert!(!config.throw_exception_on_failure);
3931 assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
3932 }
3933
3934 #[test]
3935 fn test_http_endpoint_config_auth_and_headers_options() {
3936 let config = HttpEndpointConfig::from_uri(
3937 "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
3938 )
3939 .unwrap();
3940
3941 assert!(matches!(
3942 config.auth,
3943 HttpAuth::Basic { username, password } if username == "u" && password == "p"
3944 ));
3945 assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
3946 assert!(config.bridge_endpoint);
3947 assert!(config.connection_close);
3948 assert_eq!(
3949 config.skip_request_headers,
3950 vec!["authorization".to_string(), "x-secret".to_string()]
3951 );
3952 assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
3953 }
3954
3955 #[test]
3956 fn test_http_endpoint_config_bearer_auth() {
3957 let config = HttpEndpointConfig::from_uri(
3958 "http://localhost/api?authMethod=Bearer&authBearerToken=t",
3959 )
3960 .unwrap();
3961 assert!(matches!(
3962 config.auth,
3963 HttpAuth::Bearer { token } if token == "t"
3964 ));
3965 }
3966
3967 #[test]
3968 fn rejects_cookie_handling_inmemory() {
3969 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
3970 match result {
3971 Err(CamelError::InvalidUri(msg)) => {
3972 assert!(
3973 msg.contains("cookieHandling is not supported"),
3974 "expected rejection message, got: {msg}"
3975 );
3976 }
3977 other => panic!("expected InvalidUri error, got: {other:?}"),
3978 }
3979 }
3980
3981 #[test]
3982 fn rejects_cookie_handling_disabled() {
3983 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
3984 match result {
3985 Err(CamelError::InvalidUri(msg)) => {
3986 assert!(
3987 msg.contains("cookieHandling is not supported"),
3988 "expected rejection message, got: {msg}"
3989 );
3990 }
3991 other => panic!("expected InvalidUri error, got: {other:?}"),
3992 }
3993 }
3994
3995 #[test]
3996 fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
3997 let config = HttpConfig::default()
3998 .with_response_timeout_ms(999)
3999 .with_allow_internal(true)
4000 .with_blocked_hosts(vec!["evil.com".to_string()])
4001 .with_max_body_size(12345);
4002 let endpoint =
4003 HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
4004 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
4005 assert!(endpoint.allow_internal);
4006 assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
4007 assert_eq!(endpoint.max_body_size, 12345);
4008 }
4009
4010 #[test]
4011 fn test_from_uri_with_defaults_uri_overrides_config() {
4012 let config = HttpConfig::default()
4013 .with_response_timeout_ms(999)
4014 .with_allow_internal(true)
4015 .with_blocked_hosts(vec!["evil.com".to_string()])
4016 .with_max_body_size(12345);
4017 let endpoint = HttpEndpointConfig::from_uri_with_defaults(
4018 "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
4019 &config,
4020 )
4021 .unwrap();
4022 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
4023 assert!(!endpoint.allow_internal);
4024 assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
4025 assert_eq!(endpoint.max_body_size, 99);
4026 }
4027
4028 #[test]
4029 fn test_http_config_ok_status_range() {
4030 let config =
4031 HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
4032 assert_eq!(config.ok_status_code_range, (200, 204));
4033 }
4034
4035 #[test]
4036 fn test_http_config_wrong_scheme() {
4037 let result = HttpEndpointConfig::from_uri("file:/tmp");
4038 assert!(result.is_err());
4039 }
4040
4041 #[test]
4042 fn test_http_component_scheme() {
4043 let component = HttpComponent::new();
4044 assert_eq!(component.scheme(), "http");
4045 }
4046
4047 #[test]
4048 fn test_https_component_scheme() {
4049 let component = HttpsComponent::new();
4050 assert_eq!(component.scheme(), "https");
4051 }
4052
4053 #[test]
4054 fn test_http_endpoint_creates_consumer() {
4055 let component = HttpComponent::new();
4056 let ctx = NoOpComponentContext;
4057 let endpoint = component
4058 .create_endpoint("http://0.0.0.0:19100/test", &ctx)
4059 .unwrap();
4060 assert!(endpoint.create_consumer(rt()).is_ok());
4061 }
4062
4063 #[test]
4064 fn test_https_endpoint_creates_consumer_errors_without_tls() {
4065 let component = HttpsComponent::new();
4066 let ctx = NoOpComponentContext;
4067 let endpoint = component
4068 .create_endpoint("https://0.0.0.0:8443/test", &ctx)
4069 .unwrap();
4070 assert!(endpoint.create_consumer(rt()).is_err());
4072 }
4073
4074 #[test]
4075 fn test_http_endpoint_creates_producer() {
4076 let ctx = test_producer_ctx();
4077 let component = HttpComponent::new();
4078 let endpoint_ctx = NoOpComponentContext;
4079 let endpoint = component
4080 .create_endpoint("http://localhost/api", &endpoint_ctx)
4081 .unwrap();
4082 assert!(endpoint.create_producer(rt(), &ctx).is_ok());
4083 }
4084
4085 #[tokio::test]
4090 async fn test_producer_with_token_provider() {
4091 use camel_auth::oauth2::TokenProvider;
4092 use tower::ServiceExt;
4093
4094 let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
4095 Arc::new(std::sync::Mutex::new(None));
4096 let captured_clone = Arc::clone(&captured_auth);
4097
4098 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4099 let port = listener.local_addr().unwrap().port();
4100
4101 let _handle = tokio::spawn(async move {
4102 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4103 if let Ok((mut stream, _)) = listener.accept().await {
4104 let mut buf = vec![0u8; 8192];
4105 let n = stream.read(&mut buf).await.unwrap_or(0);
4106 let request = String::from_utf8_lossy(&buf[..n]).to_string();
4107 let auth = request
4108 .lines()
4109 .find(|l| l.to_lowercase().starts_with("authorization:"))
4110 .map(|l| {
4111 l.split(':')
4112 .nth(1)
4113 .map(|s| s.trim().to_string())
4114 .unwrap_or_default()
4115 });
4116 *captured_clone.lock().unwrap() = auth;
4117 let body = r#"{"echo":"ok"}"#;
4118 let resp = format!(
4119 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
4120 body.len(),
4121 body
4122 );
4123 let _ = stream.write_all(resp.as_bytes()).await;
4124 }
4125 });
4126
4127 #[derive(Debug)]
4128 struct StaticProvider;
4129 #[async_trait::async_trait]
4130 impl TokenProvider for StaticProvider {
4131 async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
4132 Ok("injected-token".into())
4133 }
4134 }
4135
4136 let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
4137 let ctx = test_producer_ctx();
4138 let component = HttpComponent::new();
4139 let endpoint_ctx = NoOpComponentContext;
4140 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
4141 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4142
4143 let exchange = Exchange::new(Message::new("hello"));
4144
4145 let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
4146 let mut layered = layer.layer(producer);
4147 let result = layered.ready().await.unwrap().call(exchange).await;
4148 assert!(result.is_ok(), "producer call failed: {:?}", result);
4149
4150 tokio::time::sleep(Duration::from_millis(100)).await;
4151 let auth = captured_auth.lock().unwrap().take();
4152 assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
4153 }
4154
4155 async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
4156 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4157 let addr = listener.local_addr().unwrap();
4158 let url = format!("http://127.0.0.1:{}", addr.port());
4159
4160 let handle = tokio::spawn(async move {
4161 loop {
4162 if let Ok((mut stream, _)) = listener.accept().await {
4163 tokio::spawn(async move {
4164 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4165 let mut buf = vec![0u8; 4096];
4166 let n = stream.read(&mut buf).await.unwrap_or(0);
4167 let request = String::from_utf8_lossy(&buf[..n]).to_string();
4168
4169 let method = request.split_whitespace().next().unwrap_or("GET");
4170
4171 let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
4172 let response = format!(
4173 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
4174 body.len(),
4175 body
4176 );
4177 let _ = stream.write_all(response.as_bytes()).await;
4178 });
4179 }
4180 }
4181 });
4182
4183 (url, handle)
4184 }
4185
4186 async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
4187 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4188 let addr = listener.local_addr().unwrap();
4189 let url = format!("http://127.0.0.1:{}", addr.port());
4190
4191 let handle = tokio::spawn(async move {
4192 loop {
4193 if let Ok((mut stream, _)) = listener.accept().await {
4194 let status = status;
4195 tokio::spawn(async move {
4196 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4197 let mut buf = vec![0u8; 4096];
4198 let _ = stream.read(&mut buf).await;
4199
4200 let status_text = match status {
4201 404 => "Not Found",
4202 500 => "Internal Server Error",
4203 _ => "Error",
4204 };
4205 let body = "error body";
4206 let response = format!(
4207 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
4208 status,
4209 status_text,
4210 body.len(),
4211 body
4212 );
4213 let _ = stream.write_all(response.as_bytes()).await;
4214 });
4215 }
4216 }
4217 });
4218
4219 (url, handle)
4220 }
4221
4222 async fn start_request_capturing_server() -> (
4223 String,
4224 Arc<std::sync::Mutex<Option<String>>>,
4225 tokio::task::JoinHandle<()>,
4226 ) {
4227 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4228 let port = listener.local_addr().unwrap().port();
4229 let url = format!("http://127.0.0.1:{port}");
4230 let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
4231 let captured_clone = Arc::clone(&captured);
4232 let handle = tokio::spawn(async move {
4233 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4234 if let Ok((mut stream, _)) = listener.accept().await {
4235 let mut buf = vec![0u8; 16384];
4236 let n = stream.read(&mut buf).await.unwrap_or(0);
4237 let request = String::from_utf8_lossy(&buf[..n]).to_string();
4238 if request.contains("\r\n\r\n") {
4239 *captured_clone.lock().unwrap() = Some(request);
4240 }
4241 let body = r#"{"echo":"ok"}"#;
4242 let resp = format!(
4243 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
4244 body.len(),
4245 body
4246 );
4247 let _ = stream.write_all(resp.as_bytes()).await;
4248 }
4249 });
4250 (url, captured, handle)
4251 }
4252
4253 #[tokio::test]
4254 async fn test_http_producer_get_request() {
4255 use tower::ServiceExt;
4256
4257 let (url, _handle) = start_test_server().await;
4258 let ctx = test_producer_ctx();
4259
4260 let component = HttpComponent::new();
4261 let endpoint_ctx = NoOpComponentContext;
4262 let endpoint = component
4263 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4264 .unwrap();
4265 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4266
4267 let exchange = Exchange::new(Message::default());
4268 let result = producer.oneshot(exchange).await.unwrap();
4269
4270 let status = result
4271 .input
4272 .header("CamelHttpResponseCode")
4273 .and_then(|v| v.as_u64())
4274 .unwrap();
4275 assert_eq!(status, 200);
4276
4277 assert!(!result.input.body.is_empty());
4278 }
4279
4280 #[tokio::test]
4281 async fn producer_excludes_host_and_framing() {
4282 use tower::ServiceExt;
4283
4284 let (url, captured, _handle) = start_request_capturing_server().await;
4285 let ctx = test_producer_ctx();
4286 let component = HttpComponent::new();
4287 let endpoint_ctx = NoOpComponentContext;
4288 let endpoint = component
4289 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4290 .unwrap();
4291 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4292
4293 let mut exchange = Exchange::new(Message::default());
4294 exchange.input.set_header("Host", "localhost");
4295 exchange.input.set_header("Content-Length", "42");
4296 exchange.input.set_header("Connection", "keep-alive");
4297 exchange.input.set_header("Upgrade", "h2c");
4298
4299 let result = producer.oneshot(exchange).await;
4300 assert!(result.is_ok(), "producer call failed: {:?}", result);
4301
4302 tokio::time::sleep(Duration::from_millis(100)).await;
4303 let request = captured
4304 .lock()
4305 .unwrap()
4306 .take()
4307 .expect("no outbound request captured");
4308 let lower = request.to_ascii_lowercase();
4309 assert!(
4310 !lower.contains("\r\nhost: localhost"),
4311 "forwarded Host: localhost must be stripped\n{request}"
4312 );
4313 assert!(
4314 !lower.contains("content-length: 42"),
4315 "exchange Content-Length must not be copied\n{request}"
4316 );
4317 assert!(
4318 !lower.lines().any(|l| l.starts_with("connection:")),
4319 "Connection header must not be forwarded\n{request}"
4320 );
4321 assert!(
4322 !lower.lines().any(|l| l.starts_with("upgrade:")),
4323 "Upgrade header must not be forwarded\n{request}"
4324 );
4325 let host_header = lower
4326 .lines()
4327 .find(|l| l.starts_with("host:"))
4328 .map(|l| l.split_once(':').map(|(_, v)| v).unwrap_or("").trim())
4329 .expect("outbound Host header must be set by reqwest");
4330 assert!(
4331 host_header.starts_with("127.0.0.1:"),
4332 "outbound Host '{host_header}' must match the capture-server address"
4333 );
4334 }
4335
4336 #[tokio::test]
4337 async fn producer_forwards_request_only_headers() {
4338 use tower::ServiceExt;
4339
4340 let (url, captured, _handle) = start_request_capturing_server().await;
4341 let ctx = test_producer_ctx();
4342 let component = HttpComponent::new();
4343 let endpoint_ctx = NoOpComponentContext;
4344 let endpoint = component
4345 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4346 .unwrap();
4347 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4348
4349 let mut exchange = Exchange::new(Message::default());
4350 exchange.input.set_header("Accept", "application/json");
4351 exchange.input.set_header("User-Agent", "myclient/1.0");
4352
4353 let result = producer.oneshot(exchange).await;
4354 assert!(result.is_ok(), "producer call failed: {:?}", result);
4355
4356 tokio::time::sleep(Duration::from_millis(100)).await;
4357 let request = captured
4358 .lock()
4359 .unwrap()
4360 .take()
4361 .expect("no outbound request captured");
4362 let lower = request.to_ascii_lowercase();
4363 assert!(
4364 lower.contains("accept: application/json"),
4365 "request-only Accept header must be forwarded\n{request}"
4366 );
4367 assert!(
4368 lower.contains("user-agent: myclient/1.0"),
4369 "request-only User-Agent header must be forwarded\n{request}"
4370 );
4371 }
4372
4373 fn endpoint_with_config_overrides(
4382 base_url: &str,
4383 user_agent: Option<String>,
4384 auth: HttpAuth,
4385 ) -> HttpEndpoint {
4386 let uri = format!("{base_url}/api/test?allowInternal=true");
4387 let mut config =
4388 HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses");
4389 config.user_agent = user_agent;
4390 config.auth = auth;
4391 HttpEndpoint {
4392 uri: uri.clone(),
4393 config,
4394 server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
4395 client: reqwest::Client::new(),
4396 pinned_cache: Arc::new(PinnedClientCache::new(
4397 PINNED_CLIENT_TTL,
4398 PINNED_CLIENT_MAX_ENTRIES,
4399 )),
4400 http_config: HttpConfig::default(),
4401 }
4402 }
4403
4404 #[tracing_test::traced_test]
4409 #[tokio::test]
4410 async fn producer_invalid_configured_headers_surfaced() {
4411 use tower::ServiceExt;
4412
4413 let (bad_url, bad_captured, _bad_handle) = start_request_capturing_server().await;
4414 let (ok_url, ok_captured, _ok_handle) = start_request_capturing_server().await;
4415 let ctx = test_producer_ctx();
4416
4417 let bad_producer = endpoint_with_config_overrides(
4418 &bad_url,
4419 Some("bad\r\nua".to_string()),
4420 HttpAuth::Bearer {
4421 token: "tok\r\nen".to_string(),
4422 },
4423 )
4424 .create_producer(rt(), &ctx)
4425 .unwrap();
4426 let ok_producer = endpoint_with_config_overrides(
4427 &ok_url,
4428 Some("httpsweep-ok/1".to_string()),
4429 HttpAuth::Bearer {
4430 token: "valid-token".to_string(),
4431 },
4432 )
4433 .create_producer(rt(), &ctx)
4434 .unwrap();
4435
4436 let bad_exchange = Exchange::new(Message::default());
4437 let ok_exchange = Exchange::new(Message::default());
4438 let bad_cid = bad_exchange.correlation_id().to_string();
4439 let ok_cid = ok_exchange.correlation_id().to_string();
4440
4441 let bad_result = bad_producer.oneshot(bad_exchange).await;
4442 assert!(
4443 bad_result.is_ok(),
4444 "invalid-config producer call failed: {bad_result:?}"
4445 );
4446 let ok_result = ok_producer.oneshot(ok_exchange).await;
4447 assert!(
4448 ok_result.is_ok(),
4449 "valid-config producer call failed: {ok_result:?}"
4450 );
4451
4452 tokio::time::sleep(Duration::from_millis(100)).await;
4453 let bad_request = bad_captured
4454 .lock()
4455 .unwrap()
4456 .take()
4457 .expect("no outbound request captured");
4458 let ok_request = ok_captured
4459 .lock()
4460 .unwrap()
4461 .take()
4462 .expect("no outbound request captured");
4463
4464 let bad_lower = bad_request.to_ascii_lowercase();
4467 assert!(
4468 !bad_lower.lines().any(|l| l.starts_with("authorization:")),
4469 "invalid Bearer token must not reach the wire\n{bad_request}"
4470 );
4471 assert!(
4472 !bad_request.contains("bad\r\nua"),
4473 "invalid configured user-agent must not reach the wire\n{bad_request}"
4474 );
4475
4476 logs_assert(|lines: &[&str]| {
4477 let drops: Vec<&&str> = lines
4478 .iter()
4479 .filter(|l| {
4480 l.contains("outbound header dropped")
4481 && l.contains(&format!("correlation_id={bad_cid}"))
4482 })
4483 .collect();
4484 if drops.len() != 2 {
4485 return Err(format!(
4486 "expected exactly 2 drop records for {bad_cid}, found {}",
4487 drops.len()
4488 ));
4489 }
4490 let has_ua = drops.iter().any(|l| l.contains("header=user-agent"));
4491 let has_auth = drops.iter().any(|l| l.contains("header=authorization"));
4492 let reason_ok = drops
4493 .iter()
4494 .all(|l| l.contains("outbound header dropped: invalid header value"));
4495 match (has_ua, has_auth, reason_ok) {
4496 (true, true, true) => Ok(()),
4497 _ => Err(format!(
4498 "drop records mismatched: user-agent={has_ua} \
4499 authorization={has_auth} reason-ok={reason_ok}"
4500 )),
4501 }
4502 });
4503 logs_assert(|lines: &[&str]| {
4504 if lines
4505 .iter()
4506 .any(|l| l.contains("bad\r\nua") || l.contains("tok\r\nen"))
4507 {
4508 Err("sentinel CRLF values leaked into logs".to_string())
4509 } else {
4510 Ok(())
4511 }
4512 });
4513
4514 let ok_lower = ok_request.to_ascii_lowercase();
4517 assert!(
4518 ok_lower.contains("user-agent: httpsweep-ok/1"),
4519 "valid configured user-agent must reach the wire\n{ok_request}"
4520 );
4521 assert!(
4522 ok_lower.contains("authorization: bearer valid-token"),
4523 "valid Bearer token must reach the wire\n{ok_request}"
4524 );
4525 logs_assert(|lines: &[&str]| {
4526 let hits = lines
4527 .iter()
4528 .filter(|l| {
4529 l.contains("outbound header dropped")
4530 && l.contains(&format!("correlation_id={ok_cid}"))
4531 })
4532 .count();
4533 match hits {
4534 0 => Ok(()),
4535 n => Err(format!("expected no drop records for {ok_cid}, found {n}")),
4536 }
4537 });
4538 }
4539
4540 #[tokio::test]
4541 async fn producer_honours_skip_request_headers() {
4542 use tower::ServiceExt;
4543
4544 let (url, captured, _handle) = start_request_capturing_server().await;
4545 let ctx = test_producer_ctx();
4546 let component = HttpComponent::new();
4547 let endpoint_ctx = NoOpComponentContext;
4548 let endpoint = component
4549 .create_endpoint(
4550 &format!("{url}/api/test?allowInternal=true&skipRequestHeaders=Authorization"),
4551 &endpoint_ctx,
4552 )
4553 .unwrap();
4554 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4555
4556 let mut exchange = Exchange::new(Message::default());
4557 exchange.input.set_header("Authorization", "Bearer x");
4558
4559 let result = producer.oneshot(exchange).await;
4560 assert!(result.is_ok(), "producer call failed: {:?}", result);
4561
4562 tokio::time::sleep(Duration::from_millis(100)).await;
4563 let request = captured
4564 .lock()
4565 .unwrap()
4566 .take()
4567 .expect("no outbound request captured");
4568 assert!(
4569 !request.to_ascii_lowercase().contains("authorization"),
4570 "Authorization must be stripped by skipRequestHeaders\n{request}"
4571 );
4572 }
4573
4574 #[tokio::test]
4575 async fn producer_stringifies_scalar_header_values_on_wire() {
4576 use tower::ServiceExt;
4577
4578 let (url, captured, _handle) = start_request_capturing_server().await;
4579 let ctx = test_producer_ctx();
4580 let component = HttpComponent::new();
4581 let endpoint_ctx = NoOpComponentContext;
4582 let endpoint = component
4583 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4584 .unwrap();
4585 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4586
4587 let mut exchange = Exchange::new(Message::default());
4588 exchange.input.set_header("X-Retries", serde_json::json!(3));
4589 exchange
4590 .input
4591 .set_header("X-Enabled", serde_json::json!(true));
4592 exchange
4593 .input
4594 .set_header("X-Obj", serde_json::json!({"a": 1}));
4595
4596 let result = producer.oneshot(exchange).await;
4597 assert!(result.is_ok(), "producer call failed: {:?}", result);
4598
4599 tokio::time::sleep(Duration::from_millis(100)).await;
4600 let request = captured
4601 .lock()
4602 .unwrap()
4603 .take()
4604 .expect("no outbound request captured");
4605 let lower = request.to_ascii_lowercase();
4606 assert!(
4607 lower.contains("x-retries: 3"),
4608 "numeric header must reach the wire stringified\n{request}"
4609 );
4610 assert!(
4611 lower.contains("x-enabled: true"),
4612 "bool header must reach the wire stringified\n{request}"
4613 );
4614 assert!(
4615 !lower.contains("x-obj:"),
4616 "object header has no single-value form and must not reach the wire\n{request}"
4617 );
4618 }
4619
4620 #[tokio::test]
4621 async fn test_http_producer_post_with_body() {
4622 use tower::ServiceExt;
4623
4624 let (url, _handle) = start_test_server().await;
4625 let ctx = test_producer_ctx();
4626
4627 let component = HttpComponent::new();
4628 let endpoint_ctx = NoOpComponentContext;
4629 let endpoint = component
4630 .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
4631 .unwrap();
4632 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4633
4634 let exchange = Exchange::new(Message::new("request body"));
4635 let result = producer.oneshot(exchange).await.unwrap();
4636
4637 let status = result
4638 .input
4639 .header("CamelHttpResponseCode")
4640 .and_then(|v| v.as_u64())
4641 .unwrap();
4642 assert_eq!(status, 200);
4643 }
4644
4645 #[tokio::test]
4646 async fn test_http_producer_method_from_header() {
4647 use tower::ServiceExt;
4648
4649 let (url, _handle) = start_test_server().await;
4650 let ctx = test_producer_ctx();
4651
4652 let component = HttpComponent::new();
4653 let endpoint_ctx = NoOpComponentContext;
4654 let endpoint = component
4655 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4656 .unwrap();
4657 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4658
4659 let mut exchange = Exchange::new(Message::default());
4660 exchange.input.set_header(
4661 "CamelHttpMethod",
4662 serde_json::Value::String("DELETE".to_string()),
4663 );
4664
4665 let result = producer.oneshot(exchange).await.unwrap();
4666 let status = result
4667 .input
4668 .header("CamelHttpResponseCode")
4669 .and_then(|v| v.as_u64())
4670 .unwrap();
4671 assert_eq!(status, 200);
4672 }
4673
4674 #[tokio::test]
4675 async fn test_http_producer_forced_method() {
4676 use tower::ServiceExt;
4677
4678 let (url, _handle) = start_test_server().await;
4679 let ctx = test_producer_ctx();
4680
4681 let component = HttpComponent::new();
4682 let endpoint_ctx = NoOpComponentContext;
4683 let endpoint = component
4684 .create_endpoint(
4685 &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
4686 &endpoint_ctx,
4687 )
4688 .unwrap();
4689 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4690
4691 let exchange = Exchange::new(Message::default());
4692 let result = producer.oneshot(exchange).await.unwrap();
4693
4694 let status = result
4695 .input
4696 .header("CamelHttpResponseCode")
4697 .and_then(|v| v.as_u64())
4698 .unwrap();
4699 assert_eq!(status, 200);
4700 }
4701
4702 #[tokio::test]
4703 async fn test_http_producer_throw_exception_on_failure() {
4704 use tower::ServiceExt;
4705
4706 let (url, _handle) = start_status_server(404).await;
4707 let ctx = test_producer_ctx();
4708
4709 let component = HttpComponent::new();
4710 let endpoint_ctx = NoOpComponentContext;
4711 let endpoint = component
4712 .create_endpoint(
4713 &format!("{url}/not-found?allowInternal=true"),
4714 &endpoint_ctx,
4715 )
4716 .unwrap();
4717 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4718
4719 let exchange = Exchange::new(Message::default());
4720 let result = producer.oneshot(exchange).await;
4721 assert!(result.is_err());
4722
4723 match result.unwrap_err() {
4724 CamelError::HttpOperationFailed { status_code, .. } => {
4725 assert_eq!(status_code, 404);
4726 }
4727 e => panic!("Expected HttpOperationFailed, got: {e}"),
4728 }
4729 }
4730
4731 #[tokio::test]
4732 async fn test_http_producer_no_throw_on_failure() {
4733 use tower::ServiceExt;
4734
4735 let (url, _handle) = start_status_server(500).await;
4736 let ctx = test_producer_ctx();
4737
4738 let component = HttpComponent::new();
4739 let endpoint_ctx = NoOpComponentContext;
4740 let endpoint = component
4741 .create_endpoint(
4742 &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
4743 &endpoint_ctx,
4744 )
4745 .unwrap();
4746 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4747
4748 let exchange = Exchange::new(Message::default());
4749 let result = producer.oneshot(exchange).await.unwrap();
4750
4751 let status = result
4752 .input
4753 .header("CamelHttpResponseCode")
4754 .and_then(|v| v.as_u64())
4755 .unwrap();
4756 assert_eq!(status, 500);
4757 }
4758
4759 #[tokio::test]
4760 async fn test_http_producer_uri_override() {
4761 use tower::ServiceExt;
4762
4763 let (url, _handle) = start_test_server().await;
4764 let ctx = test_producer_ctx();
4765
4766 let component = HttpComponent::new();
4767 let endpoint_ctx = NoOpComponentContext;
4768 let endpoint = component
4769 .create_endpoint(
4770 "http://localhost:1/does-not-exist?allowInternal=true",
4771 &endpoint_ctx,
4772 )
4773 .unwrap();
4774 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4775
4776 let mut exchange = Exchange::new(Message::default());
4777 exchange.input.set_header(
4778 "CamelHttpUri",
4779 serde_json::Value::String(format!("{url}/api")),
4780 );
4781
4782 let result = producer.oneshot(exchange).await.unwrap();
4783 let status = result
4784 .input
4785 .header("CamelHttpResponseCode")
4786 .and_then(|v| v.as_u64())
4787 .unwrap();
4788 assert_eq!(status, 200);
4789 }
4790
4791 #[tokio::test]
4792 async fn test_http_producer_response_headers_mapped() {
4793 use tower::ServiceExt;
4794
4795 let (url, _handle) = start_test_server().await;
4796 let ctx = test_producer_ctx();
4797
4798 let component = HttpComponent::new();
4799 let endpoint_ctx = NoOpComponentContext;
4800 let endpoint = component
4801 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4802 .unwrap();
4803 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4804
4805 let exchange = Exchange::new(Message::default());
4806 let result = producer.oneshot(exchange).await.unwrap();
4807
4808 assert!(
4809 result.input.header("Content-Type").is_some(),
4810 "Response should have Content-Type header"
4811 );
4812 assert!(result.input.header("CamelHttpResponseText").is_some());
4813 }
4814
4815 async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
4820 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4821 let addr = listener.local_addr().unwrap();
4822 let url = format!("http://127.0.0.1:{}", addr.port());
4823
4824 let handle = tokio::spawn(async move {
4825 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4826 loop {
4827 if let Ok((mut stream, _)) = listener.accept().await {
4828 tokio::spawn(async move {
4829 let mut buf = vec![0u8; 4096];
4830 let n = stream.read(&mut buf).await.unwrap_or(0);
4831 let request = String::from_utf8_lossy(&buf[..n]).to_string();
4832
4833 if request.contains("GET /final") {
4835 let body = r#"{"status":"final"}"#;
4836 let response = format!(
4837 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
4838 body.len(),
4839 body
4840 );
4841 let _ = stream.write_all(response.as_bytes()).await;
4842 } else {
4843 let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
4848 let _ = stream.write_all(response.as_bytes()).await;
4849 }
4850 });
4851 }
4852 }
4853 });
4854
4855 (url, handle)
4856 }
4857
4858 struct CapturedRequest {
4859 method: String,
4860 path: String,
4861 body: Vec<u8>,
4862 content_length: Option<String>,
4863 transfer_encoding: Option<String>,
4864 }
4865
4866 async fn capture_request(stream: &mut tokio::net::TcpStream) -> Option<CapturedRequest> {
4872 use tokio::io::AsyncReadExt;
4873
4874 let mut buf: Vec<u8> = Vec::new();
4876 let mut chunk = [0u8; 4096];
4877 let head_end: usize;
4878 loop {
4879 let n = stream.read(&mut chunk).await.unwrap_or(0);
4880 if n == 0 {
4881 return None;
4882 }
4883 buf.extend_from_slice(&chunk[..n]);
4884 if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
4885 head_end = pos + 4;
4886 break;
4887 }
4888 }
4889
4890 let head = String::from_utf8_lossy(&buf[..head_end]);
4892 let mut lines = head.split("\r\n");
4893 let request_line = lines.next().unwrap_or("");
4894 let mut parts = request_line.split_whitespace();
4895 let method = parts.next().unwrap_or("").to_string();
4896 let path = parts.next().unwrap_or("").to_string();
4897
4898 let mut content_length: Option<String> = None;
4899 let mut transfer_encoding: Option<String> = None;
4900 for line in lines {
4901 if let Some((name, value)) = line.split_once(':') {
4902 let name = name.trim().to_ascii_lowercase();
4903 let value = value.trim().to_string();
4904 if name == "content-length" {
4905 content_length = Some(value);
4906 } else if name == "transfer-encoding" {
4907 transfer_encoding = Some(value);
4908 }
4909 }
4910 }
4911
4912 let body_len: usize = content_length
4914 .as_deref()
4915 .and_then(|v| v.parse::<usize>().ok())
4916 .unwrap_or(0);
4917
4918 let mut body: Vec<u8> = buf[head_end..].to_vec();
4919 while body.len() < body_len {
4920 let n = stream.read(&mut chunk).await.unwrap_or(0);
4921 if n == 0 {
4922 break;
4923 }
4924 body.extend_from_slice(&chunk[..n]);
4925 }
4926 body.truncate(body_len);
4927
4928 Some(CapturedRequest {
4929 method,
4930 path,
4931 body,
4932 content_length,
4933 transfer_encoding,
4934 })
4935 }
4936
4937 async fn start_capture_server() -> (
4942 String,
4943 tokio::task::JoinHandle<()>,
4944 Arc<Mutex<Vec<CapturedRequest>>>,
4945 ) {
4946 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4947 let addr = listener.local_addr().unwrap();
4948 let url = format!("http://127.0.0.1:{}", addr.port());
4949
4950 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
4951 let captured_for_return = Arc::clone(&captured);
4952
4953 let handle = tokio::spawn(async move {
4954 use tokio::io::AsyncWriteExt;
4955 loop {
4956 if let Ok((mut stream, _)) = listener.accept().await {
4957 let captured = Arc::clone(&captured);
4958 tokio::spawn(async move {
4959 let Some(req) = capture_request(&mut stream).await else {
4960 return;
4961 };
4962 captured.lock().unwrap().push(req);
4963
4964 let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
4967 let _ = stream.write_all(response.as_bytes()).await;
4968 });
4969 }
4970 }
4971 });
4972
4973 (url, handle, captured_for_return)
4974 }
4975
4976 async fn start_redirect_capture_server() -> (
4982 String,
4983 tokio::task::JoinHandle<()>,
4984 Arc<Mutex<Vec<CapturedRequest>>>,
4985 ) {
4986 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4987 let addr = listener.local_addr().unwrap();
4988 let url = format!("http://127.0.0.1:{}", addr.port());
4989
4990 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
4991 let captured_for_return = Arc::clone(&captured);
4992
4993 let handle = tokio::spawn(async move {
4994 use tokio::io::AsyncWriteExt;
4995 loop {
4996 if let Ok((mut stream, _)) = listener.accept().await {
4997 let captured = Arc::clone(&captured);
4998 tokio::spawn(async move {
4999 let Some(req) = capture_request(&mut stream).await else {
5000 return;
5001 };
5002 let path = req.path.clone();
5003 captured.lock().unwrap().push(req);
5004
5005 let (status_line, location) = match path.as_str() {
5006 "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
5007 "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
5008 "/final" => ("HTTP/1.1 200 OK", None),
5009 _ => ("HTTP/1.1 404 Not Found", None),
5010 };
5011
5012 let response = match location {
5013 Some(loc) => format!(
5017 "{status_line}\r\nLocation: {loc}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
5018 ),
5019 None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
5020 };
5021 let _ = stream.write_all(response.as_bytes()).await;
5022 });
5023 }
5024 }
5025 });
5026
5027 (url, handle, captured_for_return)
5028 }
5029
5030 #[tokio::test]
5031 async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
5032 use tower::ServiceExt;
5033
5034 let (url, _handle, captured) = start_capture_server().await;
5035 let ctx = test_producer_ctx();
5036
5037 let component = HttpComponent::with_config(HttpConfig::default());
5038 let endpoint_ctx = NoOpComponentContext;
5039 let endpoint = component
5040 .create_endpoint(
5041 &format!("{url}?httpMethod=GET&allowInternal=true"),
5042 &endpoint_ctx,
5043 )
5044 .unwrap();
5045 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5046
5047 let mut exchange = Exchange::new(Message::default());
5048 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5049
5050 let result = producer.oneshot(exchange).await.unwrap();
5051
5052 let status = result
5053 .input
5054 .header("CamelHttpResponseCode")
5055 .and_then(|v| v.as_u64())
5056 .unwrap();
5057 assert_eq!(status, 200);
5058
5059 let captured = captured.lock().unwrap();
5060 assert_eq!(captured.len(), 1, "expected exactly one captured request");
5061 let req = &captured[0];
5062 assert_eq!(req.method, "GET");
5063 assert_eq!(req.path, "/");
5066 assert!(req.body.is_empty(), "GET must not carry a body");
5067 assert!(
5068 req.content_length.is_none(),
5069 "suppressed request must not carry Content-Length"
5070 );
5071 assert!(
5072 req.transfer_encoding.is_none(),
5073 "suppressed request must not carry Transfer-Encoding"
5074 );
5075
5076 assert!(
5078 result.input.body.is_empty(),
5079 "exchange body must be consumed"
5080 );
5081 }
5082
5083 #[tokio::test]
5084 async fn test_head_with_body_suppressed_via_header() {
5085 use tower::ServiceExt;
5086
5087 let (url, _handle, captured) = start_capture_server().await;
5088 let ctx = test_producer_ctx();
5089
5090 let component = HttpComponent::with_config(HttpConfig::default());
5091 let endpoint_ctx = NoOpComponentContext;
5092 let endpoint = component
5093 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5094 .unwrap();
5095 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5096
5097 let mut exchange = Exchange::new(Message::default());
5098 exchange.input.set_header(
5099 "CamelHttpMethod",
5100 serde_json::Value::String("HEAD".to_string()),
5101 );
5102 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5103
5104 let result = producer.oneshot(exchange).await.unwrap();
5105 let status = result
5106 .input
5107 .header("CamelHttpResponseCode")
5108 .and_then(|v| v.as_u64())
5109 .unwrap();
5110 assert_eq!(status, 200);
5111
5112 let captured = captured.lock().unwrap();
5113 assert_eq!(captured.len(), 1);
5114 let req = &captured[0];
5115 assert_eq!(req.method, "HEAD");
5116 assert!(req.body.is_empty(), "HEAD must not carry a body");
5117 }
5118
5119 #[tokio::test]
5120 async fn test_delete_options_trace_with_body_suppressed() {
5121 use tower::ServiceExt;
5122
5123 let (url, _handle, captured) = start_capture_server().await;
5124 let ctx = test_producer_ctx();
5125 let component = HttpComponent::with_config(HttpConfig::default());
5126 let endpoint_ctx = NoOpComponentContext;
5127
5128 for method in ["DELETE", "OPTIONS", "TRACE"] {
5129 let endpoint = component
5130 .create_endpoint(
5131 &format!("{url}?httpMethod={method}&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!(status, 200, "method {method} should succeed");
5147 }
5148
5149 let captured = captured.lock().unwrap();
5150 assert_eq!(captured.len(), 3, "expected three captured requests");
5151 for method in ["DELETE", "OPTIONS", "TRACE"] {
5152 let req = captured
5153 .iter()
5154 .find(|r| r.method == method)
5155 .unwrap_or_else(|| panic!("missing captured request for {method}"));
5156 assert!(req.body.is_empty(), "{} must not carry a body", req.method);
5157 }
5158 }
5159
5160 #[tokio::test]
5161 async fn test_post_put_patch_with_body_still_sent() {
5162 use tower::ServiceExt;
5163
5164 let (url, _handle, captured) = start_capture_server().await;
5165 let ctx = test_producer_ctx();
5166 let component = HttpComponent::with_config(HttpConfig::default());
5167 let endpoint_ctx = NoOpComponentContext;
5168
5169 for method in ["POST", "PUT", "PATCH"] {
5170 let endpoint = component
5171 .create_endpoint(
5172 &format!("{url}?httpMethod={method}&allowInternal=true"),
5173 &endpoint_ctx,
5174 )
5175 .unwrap();
5176 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5177
5178 let payload = format!("body-for-{method}");
5179 let mut exchange = Exchange::new(Message::default());
5180 exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
5181
5182 let result = producer.oneshot(exchange).await.unwrap();
5183 let status = result
5184 .input
5185 .header("CamelHttpResponseCode")
5186 .and_then(|v| v.as_u64())
5187 .unwrap();
5188 assert_eq!(status, 200, "method {method} should succeed");
5189 }
5190
5191 let captured = captured.lock().unwrap();
5192 assert_eq!(captured.len(), 3, "expected three captured requests");
5193 for method in ["POST", "PUT", "PATCH"] {
5194 let req = captured
5195 .iter()
5196 .find(|r| r.method == method)
5197 .unwrap_or_else(|| panic!("missing captured request for {method}"));
5198 let expected = format!("body-for-{method}");
5199 assert!(!req.body.is_empty(), "{method} must still carry its body");
5200 assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
5201 }
5202 }
5203
5204 #[tokio::test]
5208 async fn test_stream_body_under_get_not_attached() {
5209 use tower::ServiceExt;
5210
5211 let (url, _handle, captured) = start_capture_server().await;
5212 let ctx = test_producer_ctx();
5213
5214 let component = HttpComponent::with_config(HttpConfig::default());
5215 let endpoint_ctx = NoOpComponentContext;
5216 let endpoint = component
5217 .create_endpoint(
5218 &format!("{url}?httpMethod=GET&allowInternal=true"),
5219 &endpoint_ctx,
5220 )
5221 .unwrap();
5222 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5223
5224 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
5225 vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
5226 let stream = Box::pin(futures::stream::iter(chunks));
5227 let mut exchange = Exchange::new(Message::default());
5228 exchange.input.body = Body::Stream(StreamBody {
5229 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
5230 metadata: StreamMetadata::default(),
5231 });
5232
5233 let result = producer.oneshot(exchange).await.unwrap();
5234
5235 let status = result
5236 .input
5237 .header("CamelHttpResponseCode")
5238 .and_then(|v| v.as_u64())
5239 .unwrap();
5240 assert_eq!(status, 200);
5241
5242 let captured = captured.lock().unwrap();
5243 assert_eq!(captured.len(), 1, "expected exactly one captured request");
5244 assert!(
5245 captured[0].body.is_empty(),
5246 "GET must not carry a stream body"
5247 );
5248 assert!(
5249 captured[0].transfer_encoding.is_none(),
5250 "suppressed request must not carry Transfer-Encoding"
5251 );
5252 assert!(
5253 captured[0].content_length.is_none(),
5254 "suppressed request must not carry Content-Length"
5255 );
5256 assert!(
5257 result.input.body.is_empty(),
5258 "exchange body must be consumed to Empty, not left as a stream"
5259 );
5260 }
5261
5262 #[tokio::test]
5266 async fn test_redirect_hops_never_replay_suppressed_body() {
5267 use tower::ServiceExt;
5268
5269 let (url, _handle, captured) = start_redirect_capture_server().await;
5270 let ctx = test_producer_ctx();
5271
5272 let component =
5273 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5274 let endpoint_ctx = NoOpComponentContext;
5275
5276 for path in ["/hop307", "/hop308"] {
5277 let endpoint = component
5278 .create_endpoint(
5279 &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
5280 &endpoint_ctx,
5281 )
5282 .unwrap();
5283 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5284
5285 let mut exchange = Exchange::new(Message::default());
5286 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5287
5288 let result = producer.oneshot(exchange).await.unwrap();
5289 let status = result
5290 .input
5291 .header("CamelHttpResponseCode")
5292 .and_then(|v| v.as_u64())
5293 .unwrap();
5294 assert_eq!(
5295 status, 200,
5296 "redirect chain for {path} should end at /final"
5297 );
5298 }
5299
5300 let captured = captured.lock().unwrap();
5302 assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
5303 for req in captured.iter() {
5304 assert!(
5305 req.body.is_empty(),
5306 "hop {} {} must not carry a body",
5307 req.method,
5308 req.path
5309 );
5310 }
5311 }
5312
5313 #[tracing_test::traced_test]
5322 #[tokio::test]
5323 async fn test_suppressed_body_logs_exactly_one_warn() {
5324 use tower::ServiceExt;
5325
5326 let (url, _handle, _captured) = start_capture_server().await;
5327 let ctx = test_producer_ctx();
5328
5329 let component = HttpComponent::with_config(HttpConfig::default());
5330 let endpoint_ctx = NoOpComponentContext;
5331 let endpoint = component
5332 .create_endpoint(
5333 &format!("{url}?httpMethod=GET&allowInternal=true"),
5334 &endpoint_ctx,
5335 )
5336 .unwrap();
5337 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5338
5339 let mut exchange = Exchange::new(Message::default());
5340 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5341 let correlation_id = exchange.correlation_id().to_string();
5342
5343 let result = producer.oneshot(exchange).await.unwrap();
5344 let status = result
5345 .input
5346 .header("CamelHttpResponseCode")
5347 .and_then(|v| v.as_u64())
5348 .unwrap();
5349 assert_eq!(status, 200);
5350
5351 logs_assert(|lines: &[&str]| {
5352 let hits = lines
5353 .iter()
5354 .filter(|l| {
5355 l.contains("dropping request body")
5356 && l.contains("method=GET")
5357 && l.contains(&format!("correlation_id={correlation_id}"))
5358 })
5359 .count();
5360 match hits {
5361 1 => Ok(()),
5362 n => Err(format!("expected exactly one body-drop warn, found {n}")),
5363 }
5364 });
5365 }
5366
5367 #[tracing_test::traced_test]
5368 #[tokio::test]
5369 async fn test_empty_body_get_emits_no_warn() {
5370 use tower::ServiceExt;
5371
5372 let (url, _handle, _captured) = start_capture_server().await;
5373 let ctx = test_producer_ctx();
5374
5375 let component = HttpComponent::with_config(HttpConfig::default());
5376 let endpoint_ctx = NoOpComponentContext;
5377 let endpoint = component
5378 .create_endpoint(
5379 &format!("{url}?httpMethod=GET&allowInternal=true"),
5380 &endpoint_ctx,
5381 )
5382 .unwrap();
5383 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5384
5385 let exchange = Exchange::new(Message::default());
5386 let result = producer.oneshot(exchange).await.unwrap();
5387 let status = result
5388 .input
5389 .header("CamelHttpResponseCode")
5390 .and_then(|v| v.as_u64())
5391 .unwrap();
5392 assert_eq!(status, 200);
5393
5394 logs_assert(|lines: &[&str]| {
5395 let hits = lines
5396 .iter()
5397 .filter(|l| l.contains("dropping request body"))
5398 .count();
5399 match hits {
5400 0 => Ok(()),
5401 n => Err(format!("expected no body-drop warn, found {n}")),
5402 }
5403 });
5404 }
5405
5406 #[tokio::test]
5407 async fn test_follow_redirects_false_does_not_follow() {
5408 use tower::ServiceExt;
5409
5410 let (url, _handle) = start_redirect_server().await;
5411 let ctx = test_producer_ctx();
5412
5413 let component =
5414 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
5415 let endpoint_ctx = NoOpComponentContext;
5416 let endpoint = component
5417 .create_endpoint(
5418 &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
5419 &endpoint_ctx,
5420 )
5421 .unwrap();
5422 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5423
5424 let exchange = Exchange::new(Message::default());
5425 let result = producer.oneshot(exchange).await.unwrap();
5426
5427 let status = result
5429 .input
5430 .header("CamelHttpResponseCode")
5431 .and_then(|v| v.as_u64())
5432 .unwrap();
5433 assert_eq!(
5434 status, 302,
5435 "Should NOT follow redirect when followRedirects=false"
5436 );
5437 }
5438
5439 #[tokio::test]
5440 async fn test_follow_redirects_true_follows_redirect() {
5441 use tower::ServiceExt;
5442
5443 let (url, _handle) = start_redirect_server().await;
5444 let ctx = test_producer_ctx();
5445
5446 let component =
5447 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5448 let endpoint_ctx = NoOpComponentContext;
5449 let endpoint = component
5450 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5451 .unwrap();
5452 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5453
5454 let exchange = Exchange::new(Message::default());
5455 let result = producer.oneshot(exchange).await.unwrap();
5456
5457 let status = result
5459 .input
5460 .header("CamelHttpResponseCode")
5461 .and_then(|v| v.as_u64())
5462 .unwrap();
5463 assert_eq!(
5464 status, 200,
5465 "Should follow redirect when followRedirects=true"
5466 );
5467 }
5468
5469 #[tokio::test]
5472 async fn test_redirect_to_private_ip_is_ssrf_blocked() {
5473 use tower::ServiceExt;
5474
5475 let (url, _handle) = start_redirect_server().await;
5477 let ctx = test_producer_ctx();
5478
5479 let component =
5480 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5481 let endpoint_ctx = NoOpComponentContext;
5482 let endpoint = component
5483 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5484 .unwrap();
5485 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5486
5487 let exchange = Exchange::new(Message::default());
5488 let result = producer.oneshot(exchange).await;
5489
5490 assert!(
5492 result.is_ok(),
5493 "Redirect should succeed with allowInternal=true, got: {:?}",
5494 result
5495 );
5496 let exchange = result.unwrap();
5497 let status = exchange
5498 .input
5499 .header("CamelHttpResponseCode")
5500 .and_then(|v| v.as_u64())
5501 .unwrap();
5502 assert_eq!(status, 200, "Should follow redirect to /final");
5503 }
5504
5505 #[tokio::test]
5507 async fn test_redirect_to_private_ip_allowed_when_configured() {
5508 use tower::ServiceExt;
5509
5510 let (url, _handle) = start_redirect_server().await;
5512 let ctx = test_producer_ctx();
5513
5514 let component =
5515 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5516 let endpoint_ctx = NoOpComponentContext;
5517 let endpoint = component
5518 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5519 .unwrap();
5520 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5521
5522 let exchange = Exchange::new(Message::default());
5523 let result = producer.oneshot(exchange).await.unwrap();
5524
5525 let status = result
5526 .input
5527 .header("CamelHttpResponseCode")
5528 .and_then(|v| v.as_u64())
5529 .unwrap();
5530 assert_eq!(
5531 status, 200,
5532 "Should follow redirect to private IP when allowInternal=true"
5533 );
5534 }
5535
5536 #[tokio::test]
5539 async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
5540 use tower::ServiceExt;
5541
5542 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5544 let addr = listener.local_addr().unwrap();
5545 let url = format!("http://127.0.0.1:{}", addr.port());
5546
5547 let handle = tokio::spawn(async move {
5548 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5549 loop {
5550 if let Ok((mut stream, _)) = listener.accept().await {
5551 tokio::spawn(async move {
5552 let mut buf = vec![0u8; 4096];
5553 let _ = stream.read(&mut buf).await;
5554 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";
5556 let _ = stream.write_all(response.as_bytes()).await;
5557 });
5558 }
5559 }
5560 });
5561
5562 let ctx = test_producer_ctx();
5563 let component =
5564 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5565 let endpoint_ctx = NoOpComponentContext;
5566 let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
5568 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5569
5570 let exchange = Exchange::new(Message::default());
5571 let result = producer.oneshot(exchange).await;
5572
5573 assert!(
5575 result.is_err(),
5576 "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
5577 );
5578 let err = result.unwrap_err().to_string();
5579 assert!(
5580 err.contains("blocked IP")
5581 || err.contains("private IP")
5582 || err.contains("SSRF")
5583 || err.contains("not allowed"),
5584 "Error should mention SSRF/IP blocking, got: {err}"
5585 );
5586
5587 handle.abort();
5588 }
5589
5590 #[tokio::test]
5592 async fn test_too_many_redirects_returns_error() {
5593 use tower::ServiceExt;
5594
5595 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5597 let addr = listener.local_addr().unwrap();
5598 let url = format!("http://127.0.0.1:{}", addr.port());
5599
5600 let handle = tokio::spawn(async move {
5601 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5602 loop {
5603 if let Ok((mut stream, _)) = listener.accept().await {
5604 tokio::spawn(async move {
5605 let mut buf = vec![0u8; 4096];
5606 let _ = stream.read(&mut buf).await;
5607 let response = "HTTP/1.1 302 Found\r\nLocation: /loop\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
5612 let _ = stream.write_all(response.as_bytes()).await;
5613 });
5614 }
5615 }
5616 });
5617
5618 let ctx = test_producer_ctx();
5619 let component =
5620 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5621 let endpoint_ctx = NoOpComponentContext;
5622 let endpoint = component
5623 .create_endpoint(
5624 &format!("{url}?allowInternal=true&maxRedirects=2"),
5625 &endpoint_ctx,
5626 )
5627 .unwrap();
5628 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5629
5630 let exchange = Exchange::new(Message::default());
5631 let result = producer.oneshot(exchange).await;
5632
5633 match result {
5641 Err(e) => {
5642 let msg = e.to_string();
5644 assert!(
5645 msg.contains("HTTP operation failed") || msg.contains("302"),
5646 "expected redirect-after-exhaustion error, got: {msg}"
5647 );
5648 }
5649 Ok(ex) => {
5650 let response_code = ex
5651 .input
5652 .header("CamelHttpResponseCode")
5653 .and_then(|v| v.as_u64());
5654 assert_eq!(
5655 response_code,
5656 Some(302),
5657 "expected 302 after exhausting redirects"
5658 );
5659 }
5660 }
5661
5662 handle.abort();
5663 }
5664
5665 #[tokio::test]
5666 async fn test_query_params_forwarded_to_http_request() {
5667 use tower::ServiceExt;
5668
5669 let (url, _handle) = start_test_server().await;
5670 let ctx = test_producer_ctx();
5671
5672 let component = HttpComponent::new();
5673 let endpoint_ctx = NoOpComponentContext;
5674 let endpoint = component
5676 .create_endpoint(
5677 &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
5678 &endpoint_ctx,
5679 )
5680 .unwrap();
5681 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5682
5683 let exchange = Exchange::new(Message::default());
5684 let result = producer.oneshot(exchange).await.unwrap();
5685
5686 let status = result
5689 .input
5690 .header("CamelHttpResponseCode")
5691 .and_then(|v| v.as_u64())
5692 .unwrap();
5693 assert_eq!(status, 200);
5694 }
5695
5696 #[test]
5697 fn test_non_camel_query_params_are_forwarded() {
5698 let config = HttpEndpointConfig::from_uri(
5701 "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
5702 )
5703 .unwrap();
5704
5705 assert_eq!(
5708 config.raw_query.as_deref(),
5709 Some("apiKey=secret123&httpMethod=GET&token=abc456")
5710 );
5711 assert!(config.query_params.is_empty());
5712 }
5713
5714 #[test]
5715 fn test_authored_query_bytes_survive_resolve_url() {
5716 let config =
5717 HttpEndpointConfig::from_uri("http://example.com/api?q=hello%20world&tag=a+b").unwrap();
5718 let exchange = Exchange::new(Message::default());
5719
5720 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
5721
5722 assert!(url.contains("q=hello%20world"), "url was: {url}");
5725 assert!(url.contains("tag=a+b"), "url was: {url}");
5726 }
5727
5728 async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
5733 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5734 let addr = listener.local_addr().unwrap();
5735 let url = format!("http://127.0.0.1:{}", addr.port());
5736
5737 let handle = tokio::spawn(async move {
5738 loop {
5739 if let Ok((mut stream, _)) = listener.accept().await {
5740 let delay = delay_ms;
5741 tokio::spawn(async move {
5742 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5743 let mut buf = vec![0u8; 4096];
5744 let _ = stream.read(&mut buf).await;
5745 let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
5747 let _ = stream.write_all(headers.as_bytes()).await;
5748 tokio::time::sleep(Duration::from_millis(delay)).await;
5750 let body = r#"{"status":"slow"}"#;
5751 let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
5752 let _ = stream.write_all(chunk.as_bytes()).await;
5753 });
5754 }
5755 }
5756 });
5757
5758 (url, handle)
5759 }
5760
5761 #[tokio::test]
5762 async fn test_http_producer_timeout() {
5763 use tower::ServiceExt;
5764
5765 let (url, _handle) = start_slow_server(500).await;
5767 let ctx = test_producer_ctx();
5768
5769 let component = HttpComponent::with_config(
5770 HttpConfig::default()
5771 .with_read_timeout_ms(100)
5772 .with_response_timeout_ms(30_000), );
5774 let endpoint_ctx = NoOpComponentContext;
5775 let endpoint = component
5776 .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
5777 .unwrap();
5778 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5779
5780 let exchange = Exchange::new(Message::default());
5781 let result = producer.oneshot(exchange).await;
5782
5783 assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
5784 let err = result.unwrap_err().to_string();
5785 assert!(
5786 err.contains("Read timeout") || err.contains("timeout"),
5787 "Error should mention timeout, got: {}",
5788 err
5789 );
5790 }
5791
5792 #[tokio::test]
5793 async fn test_http_producer_no_timeout_when_fast() {
5794 use tower::ServiceExt;
5795
5796 let (url, _handle) = start_test_server().await;
5797 let ctx = test_producer_ctx();
5798
5799 let component =
5800 HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
5801 let endpoint_ctx = NoOpComponentContext;
5802 let endpoint = component
5803 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5804 .unwrap();
5805 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5806
5807 let exchange = Exchange::new(Message::default());
5808 let result = producer.oneshot(exchange).await.unwrap();
5809
5810 let status = result
5811 .input
5812 .header("CamelHttpResponseCode")
5813 .and_then(|v| v.as_u64())
5814 .unwrap();
5815 assert_eq!(status, 200);
5816 }
5817
5818 #[tokio::test]
5823 async fn test_http_producer_blocks_metadata_endpoint() {
5824 use tower::ServiceExt;
5825
5826 let ctx = test_producer_ctx();
5827 let component = HttpComponent::new();
5828 let endpoint_ctx = NoOpComponentContext;
5829 let endpoint = component
5830 .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
5831 .unwrap();
5832 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5833
5834 let mut exchange = Exchange::new(Message::default());
5835 exchange.input.set_header(
5836 "CamelHttpUri",
5837 serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
5838 );
5839
5840 let result = producer.oneshot(exchange).await;
5841 assert!(result.is_err(), "Should block AWS metadata endpoint");
5842
5843 let err = result.unwrap_err();
5844 assert!(
5845 err.to_string().contains("Private IP"),
5846 "Error should mention private IP blocking, got: {}",
5847 err
5848 );
5849 }
5850
5851 #[test]
5852 fn test_ssrf_config_defaults() {
5853 let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
5854 assert!(
5855 !config.allow_internal,
5856 "Private IPs should be blocked by default"
5857 );
5858 assert!(
5859 config.blocked_hosts.is_empty(),
5860 "Blocked hosts should be empty by default"
5861 );
5862 }
5863
5864 #[test]
5865 fn test_ssrf_config_allow_internal() {
5866 let config =
5867 HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
5868 assert!(
5869 config.allow_internal,
5870 "Private IPs should be allowed when explicitly set"
5871 );
5872 }
5873
5874 #[test]
5875 fn test_ssrf_config_blocked_hosts() {
5876 let config = HttpEndpointConfig::from_uri(
5877 "http://example.com/api?blockedHosts=evil.com,malware.net",
5878 )
5879 .unwrap();
5880 assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
5881 }
5882
5883 #[tokio::test]
5884 async fn test_http_producer_blocks_localhost() {
5885 use tower::ServiceExt;
5886
5887 let ctx = test_producer_ctx();
5888 let component = HttpComponent::new();
5889 let endpoint_ctx = NoOpComponentContext;
5890 let endpoint = component
5891 .create_endpoint("http://example.com/api", &endpoint_ctx)
5892 .unwrap();
5893 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5894
5895 let mut exchange = Exchange::new(Message::default());
5896 exchange.input.set_header(
5897 "CamelHttpUri",
5898 serde_json::Value::String("http://localhost:8080/internal".to_string()),
5899 );
5900
5901 let result = producer.oneshot(exchange).await;
5902 assert!(result.is_err(), "Should block localhost");
5903 }
5904
5905 #[tokio::test]
5906 async fn test_http_producer_blocks_loopback_ip() {
5907 use tower::ServiceExt;
5908
5909 let ctx = test_producer_ctx();
5910 let component = HttpComponent::new();
5911 let endpoint_ctx = NoOpComponentContext;
5912 let endpoint = component
5913 .create_endpoint("http://example.com/api", &endpoint_ctx)
5914 .unwrap();
5915 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5916
5917 let mut exchange = Exchange::new(Message::default());
5918 exchange.input.set_header(
5919 "CamelHttpUri",
5920 serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
5921 );
5922
5923 let result = producer.oneshot(exchange).await;
5924 assert!(result.is_err(), "Should block loopback IP");
5925 }
5926
5927 #[tokio::test]
5928 async fn test_http_producer_allows_private_ip_when_enabled() {
5929 use tower::ServiceExt;
5930
5931 let ctx = test_producer_ctx();
5932 let component = HttpComponent::new();
5933 let endpoint_ctx = NoOpComponentContext;
5934 let endpoint = component
5937 .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
5938 .unwrap();
5939 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5940
5941 let exchange = Exchange::new(Message::default());
5942
5943 let result = producer.oneshot(exchange).await;
5946 if let Err(ref e) = result {
5948 let err_str = e.to_string();
5949 assert!(
5950 !err_str.contains("Private IP") && !err_str.contains("not allowed"),
5951 "Should not be SSRF error, got: {}",
5952 err_str
5953 );
5954 }
5955 }
5956
5957 #[test]
5962 fn test_http_server_config_parse() {
5963 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
5964 assert_eq!(cfg.host, "0.0.0.0");
5965 assert_eq!(cfg.port, 8080);
5966 assert_eq!(cfg.path, "/orders");
5967 assert_eq!(cfg.max_inflight_requests, 1024);
5968 }
5969
5970 #[test]
5971 fn test_http_server_config_scheme() {
5972 assert_eq!(HttpServerConfig::scheme(), "http");
5974 }
5975
5976 #[test]
5977 fn test_http_server_config_from_components() {
5978 let components = camel_component_api::UriComponents {
5980 scheme: "https".to_string(),
5981 path: "//0.0.0.0:8443/api".to_string(),
5982 params: std::collections::HashMap::from([
5983 ("maxRequestBody".to_string(), "5242880".to_string()),
5984 ("maxInflightRequests".to_string(), "7".to_string()),
5985 ]),
5986 raw_query: None,
5987 };
5988 let cfg = HttpServerConfig::from_components(components).unwrap();
5989 assert_eq!(cfg.host, "0.0.0.0");
5990 assert_eq!(cfg.port, 8443);
5991 assert_eq!(cfg.path, "/api");
5992 assert_eq!(cfg.max_request_body, 5242880);
5993 assert_eq!(cfg.max_inflight_requests, 7);
5994 }
5995
5996 #[test]
5997 fn test_http_server_config_default_path() {
5998 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
5999 assert_eq!(cfg.path, "/");
6000 }
6001
6002 #[test]
6003 fn test_http_server_config_wrong_scheme() {
6004 assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
6005 }
6006
6007 #[test]
6008 fn test_http_server_config_invalid_port() {
6009 assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
6010 }
6011
6012 #[test]
6013 fn test_http_server_config_default_port_by_scheme() {
6014 let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
6016 assert_eq!(cfg_http.port, 80);
6017
6018 let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
6020 assert_eq!(cfg_https.port, 443);
6021 }
6022
6023 #[test]
6024 fn test_request_envelope_and_reply_are_send() {
6025 fn assert_send<T: Send>() {}
6026 assert_send::<RequestEnvelope>();
6027 assert_send::<HttpReply>();
6028 }
6029
6030 #[test]
6035 fn test_server_registry_global_is_singleton() {
6036 let r1 = ServerRegistry::global();
6037 let r2 = ServerRegistry::global();
6038 assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
6039 }
6040
6041 #[allow(clippy::await_holding_lock)]
6042 #[tokio::test]
6043 async fn test_concurrent_get_or_spawn_returns_same_registry() {
6044 let _guard = lock_registry_test_mutex();
6045 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6046 let port = listener.local_addr().unwrap().port();
6047 drop(listener);
6048
6049 let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
6050 Arc::new(std::sync::Mutex::new(Vec::new()));
6051
6052 let mut handles = Vec::new();
6053 for _ in 0..4 {
6054 let results = results.clone();
6055 handles.push(tokio::spawn(async move {
6056 let registry = ServerRegistry::global()
6057 .get_or_spawn(
6058 "127.0.0.1",
6059 port,
6060 2 * 1024 * 1024,
6061 10 * 1024 * 1024,
6062 1024,
6063 test_rt(),
6064 "test-route".into(),
6065 None,
6066 )
6067 .await
6068 .unwrap();
6069 results.lock().unwrap().push(registry);
6070 }));
6071 }
6072
6073 for h in handles {
6074 h.await.unwrap();
6075 }
6076
6077 let registries = results.lock().unwrap();
6078 assert_eq!(registries.len(), 4);
6079 for i in 1..registries.len() {
6080 assert!(
6081 Arc::ptr_eq(®istries[0].inner, ®istries[i].inner),
6082 "all concurrent callers should get same route registry"
6083 );
6084 }
6085 }
6086
6087 #[test]
6088 fn test_server_registry_distinguishes_host_and_port() {
6089 let _guard = lock_registry_test_mutex();
6090 let rt = tokio::runtime::Runtime::new().expect("runtime");
6091 rt.block_on(async {
6092 let registry = ServerRegistry::global();
6093 let d1 = registry
6097 .get_or_spawn(
6098 "127.0.0.1",
6099 0,
6100 1024 * 1024,
6101 10 * 1024 * 1024,
6102 1024,
6103 test_rt(),
6104 "test-route-1".into(),
6105 None,
6106 )
6107 .await;
6108 let d2 = registry
6109 .get_or_spawn(
6110 "0.0.0.0",
6111 0,
6112 1024 * 1024,
6113 10 * 1024 * 1024,
6114 1024,
6115 test_rt(),
6116 "test-route-2".into(),
6117 None,
6118 )
6119 .await;
6120 assert!(d1.is_ok());
6121 assert!(d2.is_ok());
6122 assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
6123 });
6124 }
6125
6126 #[allow(clippy::await_holding_lock)]
6127 #[tokio::test]
6128 async fn test_shared_server_max_request_body_policy_is_deterministic() {
6129 let _guard = lock_registry_test_mutex();
6130 let registry = ServerRegistry::global();
6131 let d1 = registry
6133 .get_or_spawn(
6134 "127.0.0.1",
6135 9991,
6136 1024 * 1024,
6137 10 * 1024 * 1024,
6138 1024,
6139 test_rt(),
6140 "test-route".into(),
6141 None,
6142 )
6143 .await;
6144 assert!(d1.is_ok());
6145
6146 let d2 = registry
6149 .get_or_spawn(
6150 "127.0.0.1",
6151 9991,
6152 2 * 1024 * 1024,
6153 10 * 1024 * 1024,
6154 1024,
6155 test_rt(),
6156 "test-route-2".into(),
6157 None,
6158 )
6159 .await;
6160 assert!(d2.is_err());
6161 let err = d2.unwrap_err();
6162 assert!(
6163 err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
6164 "Expected incompatible maxRequestBody error, got: {}",
6165 err
6166 );
6167 }
6168
6169 #[test]
6170 fn test_server_registry_reset_clears_entries() {
6171 let _guard = lock_registry_test_mutex();
6172 let rt = tokio::runtime::Runtime::new().expect("runtime");
6173 rt.block_on(async {
6174 let d1 = ServerRegistry::global()
6176 .get_or_spawn(
6177 "127.0.0.1",
6178 9992,
6179 1024 * 1024,
6180 10 * 1024 * 1024,
6181 1024,
6182 test_rt(),
6183 "test-route".into(),
6184 None,
6185 )
6186 .await;
6187 assert!(d1.is_ok());
6188
6189 let guard = ServerRegistry::global().inner.lock().expect("lock");
6191 assert!(guard.entries.contains_key(&("127.0.0.1".to_string(), 9992)));
6192 drop(guard);
6193
6194 ServerRegistry::reset();
6196
6197 let guard = ServerRegistry::global().inner.lock().expect("lock");
6199 assert!(
6200 guard.entries.is_empty(),
6201 "registry should be empty after reset, has {} entries",
6202 guard.entries.len()
6203 );
6204 });
6205 }
6206
6207 #[allow(clippy::await_holding_lock)]
6208 #[tokio::test]
6209 async fn registry_rejects_tls_on_plain_port() {
6210 let _guard = lock_registry_test_mutex();
6215 ServerRegistry::reset();
6216 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
6217
6218 let _r1 = ServerRegistry::global()
6220 .get_or_spawn(
6221 "127.0.0.1",
6222 0,
6223 1024,
6224 1024,
6225 16,
6226 Arc::clone(&rt),
6227 "route-1".into(),
6228 None, )
6230 .await;
6231
6232 let result = ServerRegistry::global()
6234 .get_or_spawn(
6235 "127.0.0.1",
6236 0,
6237 1024,
6238 1024,
6239 16,
6240 Arc::clone(&rt),
6241 "route-2".into(),
6242 Some(crate::config::ServerTlsConfig {
6243 cert_path: "/x.pem".into(),
6244 key_path: "/y.pem".into(),
6245 }),
6246 )
6247 .await;
6248 assert!(result.is_err(), "must reject TLS on plain port");
6249 }
6250
6251 #[allow(clippy::await_holding_lock)]
6256 #[tokio::test]
6257 async fn test_unregister_last_http_route_keeps_server_alive() {
6258 let _guard = lock_registry_test_mutex();
6259 ServerRegistry::reset();
6260 let registry = ServerRegistry::global();
6261
6262 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6263 let port = listener.local_addr().unwrap().port();
6264 drop(listener); let rt = test_rt();
6266
6267 let _r1 = registry
6270 .get_or_spawn(
6271 "127.0.0.1",
6272 port,
6273 1024 * 1024,
6274 10 * 1024 * 1024,
6275 16,
6276 rt.clone(),
6277 "test-route-1".into(),
6278 None,
6279 )
6280 .await
6281 .unwrap();
6282 let _r2 = registry
6283 .get_or_spawn(
6284 "127.0.0.1",
6285 port,
6286 1024 * 1024,
6287 10 * 1024 * 1024,
6288 16,
6289 rt,
6290 "test-route-2".into(),
6291 None,
6292 )
6293 .await
6294 .unwrap();
6295
6296 let key = ("127.0.0.1".to_string(), port);
6297 let cell = {
6298 let guard = registry.inner.lock().expect("lock");
6299 guard.entries.get(&key).expect("entry should exist").clone()
6300 };
6301
6302 registry.unregister("127.0.0.1", port).await;
6304 {
6305 let handle = cell
6306 .get()
6307 .expect("handle should still exist after first unregister");
6308 assert!(
6309 !handle.monitor_task.is_finished(),
6310 "monitor task should still be alive after first unregister"
6311 );
6312 }
6313
6314 registry.unregister("127.0.0.1", port).await;
6316 tokio::time::sleep(Duration::from_millis(20)).await;
6317 {
6318 let handle = cell
6319 .get()
6320 .expect("handle should still exist after last unregister");
6321 assert!(
6322 !handle.monitor_task.is_finished(),
6323 "monitor task should still be alive — server is process-lifetime"
6324 );
6325 }
6326
6327 {
6329 let guard = registry.inner.lock().expect("lock");
6330 assert!(
6331 guard.entries.contains_key(&key),
6332 "entry should remain in registry — server kept alive for restart"
6333 );
6334 }
6335 }
6336
6337 async fn clone_fixture_listener() -> (
6346 tokio::net::TcpListener,
6347 std::net::TcpListener,
6348 std::net::SocketAddr,
6349 ) {
6350 let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind std listener");
6351 let probe = l.try_clone().expect("clone probe");
6352 l.set_nonblocking(true).expect("set_nonblocking");
6353 let listener = tokio::net::TcpListener::from_std(l).expect("from_std");
6354 let addr = listener.local_addr().expect("local_addr");
6355 (listener, probe, addr)
6356 }
6357
6358 fn staged_limits() -> (usize, usize, usize) {
6360 (1024 * 1024, 10 * 1024 * 1024, 1024)
6361 }
6362
6363 #[allow(clippy::await_holding_lock)]
6364 #[tokio::test]
6365 async fn staged_listener_first_spawn_serves_without_second_bind() {
6366 let _guard = lock_registry_test_mutex();
6367 ServerRegistry::reset();
6368 let registry = ServerRegistry::global();
6369 let (listener, _probe, addr) = clone_fixture_listener().await;
6370 let port = addr.port();
6371 registry
6372 .stage_listener(listener)
6373 .await
6374 .expect("stage listener");
6375
6376 let (max_req, max_res, max_inflight) = staged_limits();
6377 let routes = registry
6378 .get_or_spawn(
6379 "127.0.0.1",
6380 port,
6381 max_req,
6382 max_res,
6383 max_inflight,
6384 test_rt(),
6385 "staged-first-spawn".into(),
6386 None,
6387 )
6388 .await
6389 .expect("spawn from staged listener must succeed");
6390
6391 assert_eq!(
6392 registry.bound_addr("127.0.0.1", port),
6393 Some(addr),
6394 "served socket must be the staged listener's addr"
6395 );
6396 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__staged_probe__"))
6399 .await
6400 .expect("http request against staged listener must connect");
6401 assert!(
6402 resp.status().as_u16() >= 200,
6403 "any status proves the staged socket serves"
6404 );
6405 drop(routes);
6406 }
6407
6408 #[allow(clippy::await_holding_lock)]
6409 #[tokio::test]
6410 async fn staged_entry_reused_by_second_caller() {
6411 let _guard = lock_registry_test_mutex();
6412 ServerRegistry::reset();
6413 let registry = ServerRegistry::global();
6414 let (listener, _probe, addr) = clone_fixture_listener().await;
6415 let port = addr.port();
6416 registry
6417 .stage_listener(listener)
6418 .await
6419 .expect("stage listener");
6420
6421 let (max_req, max_res, max_inflight) = staged_limits();
6422 let first = registry
6423 .get_or_spawn(
6424 "127.0.0.1",
6425 port,
6426 max_req,
6427 max_res,
6428 max_inflight,
6429 test_rt(),
6430 "staged-reuse-1".into(),
6431 None,
6432 )
6433 .await
6434 .expect("first spawn from staged listener");
6435 let second = registry
6436 .get_or_spawn(
6437 "127.0.0.1",
6438 port,
6439 max_req,
6440 max_res,
6441 max_inflight,
6442 test_rt(),
6443 "staged-reuse-2".into(),
6444 None,
6445 )
6446 .await
6447 .expect("second caller must reuse the entry");
6448 assert_eq!(
6449 registry.bound_addr("127.0.0.1", port),
6450 Some(addr),
6451 "entry reused — bound addr unchanged, no second bind"
6452 );
6453 drop(first);
6454 drop(second);
6455 }
6456
6457 #[allow(clippy::await_holding_lock)]
6458 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6459 async fn staged_race_two_callers_single_resolver() {
6460 let _guard = lock_registry_test_mutex();
6461 ServerRegistry::reset();
6462 let registry = ServerRegistry::global();
6463 let (listener, _probe, addr) = clone_fixture_listener().await;
6464 let port = addr.port();
6465 registry
6466 .stage_listener(listener)
6467 .await
6468 .expect("stage listener");
6469
6470 let (max_req, max_res, max_inflight) = staged_limits();
6475 let (first, second) = tokio::join!(
6476 registry.get_or_spawn(
6477 "127.0.0.1",
6478 port,
6479 max_req,
6480 max_res,
6481 max_inflight,
6482 test_rt(),
6483 "staged-race-1".into(),
6484 None,
6485 ),
6486 registry.get_or_spawn(
6487 "127.0.0.1",
6488 port,
6489 max_req,
6490 max_res,
6491 max_inflight,
6492 test_rt(),
6493 "staged-race-2".into(),
6494 None,
6495 ),
6496 );
6497 let first = first.expect("first racing caller must succeed");
6498 let second = second.expect("second racing caller must succeed");
6499 assert_eq!(
6500 registry.bound_addr("127.0.0.1", port),
6501 Some(addr),
6502 "single entry must be served from the staged socket — no EADDRINUSE path"
6503 );
6504 drop(first);
6505 drop(second);
6506 }
6507
6508 #[allow(clippy::await_holding_lock)]
6509 #[tokio::test]
6510 async fn unstaged_spawn_binds_legacy() {
6511 let _guard = lock_registry_test_mutex();
6512 ServerRegistry::reset();
6513 let registry = ServerRegistry::global();
6514 let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe bind");
6516 let port = probe.local_addr().expect("local addr").port();
6517 drop(probe);
6518
6519 let (max_req, max_res, max_inflight) = staged_limits();
6520 registry
6521 .get_or_spawn(
6522 "127.0.0.1",
6523 port,
6524 max_req,
6525 max_res,
6526 max_inflight,
6527 test_rt(),
6528 "legacy-bind".into(),
6529 None,
6530 )
6531 .await
6532 .expect("legacy bind spawn");
6533 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__legacy_probe__"))
6534 .await
6535 .expect("connect to freshly bound port must succeed");
6536 assert!(resp.status().as_u16() >= 200);
6537 assert_eq!(
6538 registry.bound_addr("127.0.0.1", port),
6539 Some(std::net::SocketAddr::from(([127, 0, 0, 1], port))),
6540 "bound addr must be the legacy bound (host, port)"
6541 );
6542 }
6543
6544 #[allow(clippy::await_holding_lock)]
6545 #[tokio::test]
6546 async fn wrong_host_staged_port_fails_deterministically() {
6547 let _guard = lock_registry_test_mutex();
6548 ServerRegistry::reset();
6549 let registry = ServerRegistry::global();
6550 let (listener, _probe, addr) = clone_fixture_listener().await;
6551 let port = addr.port();
6552 registry
6553 .stage_listener(listener)
6554 .await
6555 .expect("stage listener under 127.0.0.1");
6556
6557 let (max_req, max_res, max_inflight) = staged_limits();
6558 let err = registry
6559 .get_or_spawn(
6560 "localhost",
6561 port,
6562 max_req,
6563 max_res,
6564 max_inflight,
6565 test_rt(),
6566 "conflict-probe".into(),
6567 None,
6568 )
6569 .await
6570 .expect_err("wrong host on staged port must fail deterministically");
6571 assert!(
6572 err.to_string().contains("staged listener conflict on port"),
6573 "unexpected error: {err}"
6574 );
6575
6576 registry
6578 .get_or_spawn(
6579 "127.0.0.1",
6580 port,
6581 max_req,
6582 max_res,
6583 max_inflight,
6584 test_rt(),
6585 "conflict-after".into(),
6586 None,
6587 )
6588 .await
6589 .expect("correct host must serve the staged listener");
6590 assert_eq!(
6591 registry.bound_addr("127.0.0.1", port),
6592 Some(addr),
6593 "staged slot must be untouched by the conflicting call"
6594 );
6595 }
6596
6597 #[allow(clippy::await_holding_lock)]
6598 #[tokio::test]
6599 async fn duplicate_stage_same_key_rejected() {
6600 let _guard = lock_registry_test_mutex();
6601 ServerRegistry::reset();
6602 let registry = ServerRegistry::global();
6603 let (listener, probe, addr) = clone_fixture_listener().await;
6604 registry
6605 .stage_listener(listener)
6606 .await
6607 .expect("stage listener A");
6608
6609 let dup = probe.try_clone().expect("clone2");
6611 dup.set_nonblocking(true).expect("set_nonblocking2");
6612 let b = tokio::net::TcpListener::from_std(dup).expect("from_std2");
6613
6614 let err = registry
6615 .stage_listener(b)
6616 .await
6617 .expect_err("duplicate stage must be rejected");
6618 assert!(
6619 err.to_string().contains("listener already staged"),
6620 "unexpected error: {err}"
6621 );
6622
6623 let (max_req, max_res, max_inflight) = staged_limits();
6624 registry
6625 .get_or_spawn(
6626 "127.0.0.1",
6627 addr.port(),
6628 max_req,
6629 max_res,
6630 max_inflight,
6631 test_rt(),
6632 "dup-stage-after".into(),
6633 None,
6634 )
6635 .await
6636 .expect("spawn from first staged listener");
6637 assert_eq!(
6638 registry.bound_addr("127.0.0.1", addr.port()),
6639 Some(addr),
6640 "first staged listener retained"
6641 );
6642 }
6643
6644 #[allow(clippy::await_holding_lock)]
6645 #[tokio::test]
6646 async fn distinct_keys_stage_independently() {
6647 let _guard = lock_registry_test_mutex();
6648 ServerRegistry::reset();
6649 let registry = ServerRegistry::global();
6650 let (l1, _p1, addr1) = clone_fixture_listener().await;
6651 let (l2, _p2, addr2) = clone_fixture_listener().await;
6652 registry.stage_listener(l1).await.expect("stage P1");
6653 registry.stage_listener(l2).await.expect("stage P2");
6654
6655 let (max_req, max_res, max_inflight) = staged_limits();
6656 registry
6657 .get_or_spawn(
6658 "127.0.0.1",
6659 addr1.port(),
6660 max_req,
6661 max_res,
6662 max_inflight,
6663 test_rt(),
6664 "distinct-1".into(),
6665 None,
6666 )
6667 .await
6668 .expect("spawn P1");
6669 registry
6670 .get_or_spawn(
6671 "127.0.0.1",
6672 addr2.port(),
6673 max_req,
6674 max_res,
6675 max_inflight,
6676 test_rt(),
6677 "distinct-2".into(),
6678 None,
6679 )
6680 .await
6681 .expect("spawn P2");
6682 assert_eq!(
6683 registry.bound_addr("127.0.0.1", addr1.port()),
6684 Some(addr1),
6685 "P1 bound addr must be its own listener"
6686 );
6687 assert_eq!(
6688 registry.bound_addr("127.0.0.1", addr2.port()),
6689 Some(addr2),
6690 "P2 bound addr must be its own listener"
6691 );
6692 let r1 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr1.port()))
6693 .await
6694 .expect("connect P1");
6695 assert!(r1.status().as_u16() >= 200);
6696 let r2 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr2.port()))
6697 .await
6698 .expect("connect P2");
6699 assert!(r2.status().as_u16() >= 200);
6700 }
6701
6702 #[allow(clippy::await_holding_lock)]
6703 #[tokio::test]
6704 async fn tls_prebound_listener_served() {
6705 use camel_component_api::test_support::tls;
6706
6707 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
6710
6711 let _guard = lock_registry_test_mutex();
6712 ServerRegistry::reset();
6713 let registry = ServerRegistry::global();
6714 let (listener, _probe, addr) = clone_fixture_listener().await;
6715 let port = addr.port();
6716
6717 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
6718 let cert_path = tls::write_pem_tmp("http-staged-tls-cert.pem", &cert_pem);
6719 let key_path = tls::write_pem_tmp("http-staged-tls-key.pem", &key_pem);
6720 let ca_path = tls::write_pem_tmp("http-staged-tls-ca.pem", &ca_pem);
6721
6722 let (max_req, max_res, max_inflight) = staged_limits();
6723 let routes = registry
6724 .get_or_spawn_with_listener(
6725 listener,
6726 max_req,
6727 max_res,
6728 max_inflight,
6729 test_rt(),
6730 "staged-tls".into(),
6731 Some(crate::config::ServerTlsConfig {
6732 cert_path: cert_path.to_string_lossy().into_owned(),
6733 key_path: key_path.to_string_lossy().into_owned(),
6734 }),
6735 )
6736 .await
6737 .expect("spawn TLS server from pre-bound listener");
6738
6739 let ca_bytes = std::fs::read(&ca_path).expect("read ca pem");
6742 let client = reqwest::Client::builder()
6743 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).expect("parse ca pem"))
6744 .build()
6745 .expect("build tls client");
6746
6747 let resp = client
6748 .get(format!("https://127.0.0.1:{port}/__staged_tls__"))
6749 .send()
6750 .await
6751 .expect("TLS handshake + request must succeed");
6752 assert_eq!(resp.status().as_u16(), 404, "unknown path 404s through TLS");
6753 assert_eq!(
6754 registry.bound_addr("127.0.0.1", port),
6755 Some(addr),
6756 "bound addr equals the pre-bound listener addr"
6757 );
6758 drop(routes);
6759 }
6760
6761 #[allow(clippy::await_holding_lock)]
6762 #[tokio::test]
6763 async fn with_listener_direct_spawn_keyed_by_actual_addr() {
6764 let _guard = lock_registry_test_mutex();
6765 ServerRegistry::reset();
6766 let registry = ServerRegistry::global();
6767 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
6768 .await
6769 .expect("bind un-staged listener");
6770 let addr = listener.local_addr().expect("local addr");
6771 let port = addr.port();
6772
6773 let (max_req, max_res, max_inflight) = staged_limits();
6774 registry
6775 .get_or_spawn_with_listener(
6776 listener,
6777 max_req,
6778 max_res,
6779 max_inflight,
6780 test_rt(),
6781 "with-listener".into(),
6782 None,
6783 )
6784 .await
6785 .expect("direct spawn from un-staged listener");
6786 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__with_listener__"))
6787 .await
6788 .expect("connect on actual port");
6789 assert!(resp.status().as_u16() >= 200);
6790 assert_eq!(
6791 registry.bound_addr("127.0.0.1", port),
6792 Some(addr),
6793 "registry key is the listener's actual port"
6794 );
6795
6796 registry
6797 .get_or_spawn(
6798 "127.0.0.1",
6799 port,
6800 max_req,
6801 max_res,
6802 max_inflight,
6803 test_rt(),
6804 "with-listener-reuse".into(),
6805 None,
6806 )
6807 .await
6808 .expect("legacy caller must reuse the entry");
6809 assert_eq!(
6810 registry.bound_addr("127.0.0.1", port),
6811 Some(addr),
6812 "entry reused — no second bind"
6813 );
6814 }
6815
6816 #[tokio::test]
6821 async fn test_dispatch_handler_returns_404_for_unknown_path() {
6822 let registry = HttpRouteRegistry::new();
6823 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6825 let port = listener.local_addr().unwrap().port();
6826 tokio::spawn(run_axum_server(
6827 listener,
6828 registry,
6829 2 * 1024 * 1024,
6830 10 * 1024 * 1024,
6831 Arc::new(tokio::sync::Semaphore::new(1024)),
6832 test_rt(),
6833 "test-route".into(),
6834 ));
6835
6836 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
6838
6839 let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
6840 .await
6841 .unwrap();
6842 assert_eq!(resp.status().as_u16(), 404);
6843 }
6844
6845 #[tokio::test]
6850 async fn test_http_consumer_start_registers_path() {
6851 use camel_component_api::ConsumerContext;
6852
6853 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6855 let port = listener.local_addr().unwrap().port();
6856 drop(listener); let consumer_cfg = HttpServerConfig {
6859 scheme: "http".to_string(),
6860 host: "127.0.0.1".to_string(),
6861 port,
6862 path: "/ping".to_string(),
6863 max_request_body: 2 * 1024 * 1024,
6864 max_response_body: 10 * 1024 * 1024,
6865 max_inflight_requests: 1024,
6866 method: None,
6867 tls_config: None,
6868 };
6869 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6870
6871 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6872 let token = tokio_util::sync::CancellationToken::new();
6873 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6874
6875 tokio::spawn(async move {
6876 consumer.start(ctx).await.unwrap();
6877 });
6878
6879 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6880
6881 let client = reqwest::Client::new();
6882 let resp_future = client
6883 .post(format!("http://127.0.0.1:{port}/ping"))
6884 .body("hello world")
6885 .send();
6886
6887 let (http_result, _) = tokio::join!(resp_future, async {
6888 if let Some(mut envelope) = rx.recv().await {
6889 envelope.exchange.input.set_header(
6891 "CamelHttpResponseCode",
6892 serde_json::Value::Number(201.into()),
6893 );
6894 if let Some(reply_tx) = envelope.reply_tx {
6895 let _ = reply_tx.send(Ok(envelope.exchange));
6896 }
6897 }
6898 });
6899
6900 let resp = http_result.unwrap();
6901 assert_eq!(resp.status().as_u16(), 201);
6902
6903 token.cancel();
6904 }
6905
6906 #[test]
6910 fn test_envelope_channel_capacity_follows_max_inflight() {
6911 assert_eq!(envelope_channel_capacity(0), 1);
6912 assert_eq!(envelope_channel_capacity(1), 1);
6913 assert_eq!(envelope_channel_capacity(7), 7);
6914 assert_eq!(envelope_channel_capacity(64), 64);
6915 assert_eq!(envelope_channel_capacity(1024), 1024);
6916 }
6917
6918 #[tokio::test]
6922 async fn test_http_consumer_start_with_zero_max_inflight_rejects_503() {
6923 use camel_component_api::ConsumerContext;
6924
6925 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6926 let port = listener.local_addr().unwrap().port();
6927 drop(listener);
6928
6929 let consumer_cfg = HttpServerConfig {
6930 scheme: "http".to_string(),
6931 host: "127.0.0.1".to_string(),
6932 port,
6933 path: "/ping".to_string(),
6934 max_request_body: 2 * 1024 * 1024,
6935 max_response_body: 10 * 1024 * 1024,
6936 max_inflight_requests: 0,
6937 method: None,
6938 tls_config: None,
6939 };
6940 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6941
6942 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6943 let token = tokio_util::sync::CancellationToken::new();
6944 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6945
6946 let start_handle = tokio::spawn(async move {
6947 consumer.start(ctx).await.unwrap();
6948 });
6949
6950 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6951
6952 let client = reqwest::Client::new();
6953 let resp = client
6954 .post(format!("http://127.0.0.1:{port}/ping"))
6955 .body("hello world")
6956 .send()
6957 .await
6958 .unwrap();
6959 assert_eq!(resp.status().as_u16(), 503);
6960
6961 token.cancel();
6962 let _ = start_handle.await;
6963 }
6964
6965 #[test]
6968 fn test_http_consumer_startup_mode_is_explicit() {
6969 use camel_component_api::ConsumerStartupMode;
6970 let consumer_cfg = HttpServerConfig {
6971 scheme: "http".to_string(),
6972 host: "127.0.0.1".to_string(),
6973 port: 0,
6974 path: "/x".to_string(),
6975 max_request_body: 2 * 1024 * 1024,
6976 max_response_body: 10 * 1024 * 1024,
6977 max_inflight_requests: 1024,
6978 method: None,
6979 tls_config: None,
6980 };
6981 let consumer = HttpConsumer::new(consumer_cfg, test_rt());
6982 assert_eq!(
6983 consumer.startup_mode(),
6984 ConsumerStartupMode::Explicit,
6985 "HttpConsumer must opt into Explicit startup"
6986 );
6987 }
6988
6989 #[allow(clippy::await_holding_lock)]
6995 #[tokio::test]
6996 async fn test_http_consumer_emits_mark_ready_after_bind() {
6997 use camel_component_api::{ConsumerContext, StartupSignal};
6998
6999 let _guard = lock_registry_test_mutex();
7000
7001 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7002 let port = listener.local_addr().unwrap().port();
7003 drop(listener);
7004
7005 let consumer_cfg = HttpServerConfig {
7006 scheme: "http".to_string(),
7007 host: "127.0.0.1".to_string(),
7008 port,
7009 path: "/ready-probe".to_string(),
7010 max_request_body: 2 * 1024 * 1024,
7011 max_response_body: 10 * 1024 * 1024,
7012 max_inflight_requests: 1024,
7013 method: None,
7014 tls_config: None,
7015 };
7016 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7017
7018 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7019 let token = tokio_util::sync::CancellationToken::new();
7020 let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
7021
7022 let (signal, startup_rx) = StartupSignal::pair();
7024 let ctx = ctx.with_startup(signal);
7025
7026 tokio::spawn(async move {
7029 let _ = consumer.start(ctx).await;
7030 });
7031
7032 let result =
7037 tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
7038 .await
7039 .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
7040 assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
7041
7042 token.cancel();
7044 }
7045
7046 #[tokio::test]
7047 async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
7048 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7049
7050 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7051 let port = listener.local_addr().unwrap().port();
7052 drop(listener);
7053
7054 let consumer_cfg = HttpServerConfig {
7055 scheme: "http".to_string(),
7056 host: "127.0.0.1".to_string(),
7057 port,
7058 path: "/saturation".to_string(),
7059 max_request_body: 2 * 1024 * 1024,
7060 max_response_body: 10 * 1024 * 1024,
7061 max_inflight_requests: 1,
7062 method: None,
7063 tls_config: None,
7064 };
7065 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7066
7067 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7068 let token = tokio_util::sync::CancellationToken::new();
7069 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7070 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7071 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7072
7073 let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
7074 let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
7075
7076 tokio::spawn(async move {
7077 let mut first_seen_tx = Some(first_seen_tx);
7078 let mut unblock_first_rx = Some(unblock_first_rx);
7079
7080 while let Some(envelope) = rx.recv().await {
7081 if let Some(tx) = first_seen_tx.take() {
7082 let _ = tx.send(());
7083 if let Some(rx_unblock) = unblock_first_rx.take() {
7084 let _ = rx_unblock.await;
7085 }
7086 }
7087
7088 if let Some(reply_tx) = envelope.reply_tx {
7089 let _ = reply_tx.send(Ok(envelope.exchange));
7090 }
7091 }
7092 });
7093
7094 let client = reqwest::Client::new();
7095 let first_req = {
7096 let client = client.clone();
7097 async move {
7098 client
7099 .get(format!("http://127.0.0.1:{port}/saturation"))
7100 .send()
7101 .await
7102 .unwrap()
7103 }
7104 };
7105
7106 let first_handle = tokio::spawn(first_req);
7107 first_seen_rx.await.unwrap();
7108
7109 let second_resp = client
7110 .get(format!("http://127.0.0.1:{port}/saturation"))
7111 .send()
7112 .await
7113 .unwrap();
7114
7115 assert_eq!(second_resp.status().as_u16(), 503);
7116
7117 let _ = unblock_first_tx.send(());
7118 let first_resp = first_handle.await.unwrap();
7119 assert_eq!(first_resp.status().as_u16(), 200);
7120
7121 token.cancel();
7122 }
7123
7124 #[tokio::test]
7128 async fn test_http_consumer_chunked_body_is_capped() {
7129 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7130
7131 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7132 let port = listener.local_addr().unwrap().port();
7133 drop(listener);
7134
7135 let consumer_cfg = HttpServerConfig {
7136 scheme: "http".to_string(),
7137 host: "127.0.0.1".to_string(),
7138 port,
7139 path: "/chunked-cap".to_string(),
7140 max_request_body: 1024, max_response_body: 10 * 1024 * 1024,
7142 max_inflight_requests: 16,
7143 method: None,
7144 tls_config: None,
7145 };
7146 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7147
7148 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7149 let token = tokio_util::sync::CancellationToken::new();
7150 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7151 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7152 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7153
7154 let chunks: Vec<Result<bytes::Bytes, std::io::Error>> = (0..8)
7156 .map(|_| Ok(bytes::Bytes::from(vec![b'A'; 512])))
7157 .collect();
7158 let stream_body = reqwest::Body::wrap_stream(futures::stream::iter(chunks));
7159
7160 let client = reqwest::Client::new();
7161 let send_fut = client
7162 .post(format!("http://127.0.0.1:{port}/chunked-cap"))
7163 .body(stream_body)
7164 .send();
7165
7166 let (http_result, _) = tokio::join!(send_fut, async {
7167 if let Some(mut envelope) = rx.recv().await {
7168 let materialized = envelope
7170 .exchange
7171 .input
7172 .body
7173 .clone()
7174 .into_bytes(64 * 1024)
7175 .await;
7176 assert!(
7177 materialized.is_err(),
7178 "materializing a 4 KiB chunked body under a 1 KiB cap must fail"
7179 );
7180 let err = materialized.unwrap_err().to_string();
7181 assert!(
7182 err.contains("limit") || err.contains("exceeds"),
7183 "error should mention the limit: {err}"
7184 );
7185 if let Some(reply_tx) = envelope.reply_tx {
7186 envelope.exchange.input.body =
7187 camel_component_api::Body::Text("handled".to_string());
7188 let _ = reply_tx.send(Ok(envelope.exchange));
7189 }
7190 }
7191 });
7192
7193 let resp = http_result.unwrap();
7194 assert_eq!(resp.status().as_u16(), 200);
7195
7196 token.cancel();
7197 }
7198
7199 #[tokio::test]
7200 #[allow(clippy::await_holding_lock)]
7201 async fn test_http_consumer_enforces_max_response_body_for_bytes() {
7202 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7203
7204 let _guard = lock_registry_test_mutex();
7205
7206 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7207 let port = listener.local_addr().unwrap().port();
7208 drop(listener);
7209
7210 let consumer_cfg = HttpServerConfig {
7211 scheme: "http".to_string(),
7212 host: "127.0.0.1".to_string(),
7213 port,
7214 path: "/limit-bytes".to_string(),
7215 max_request_body: 2 * 1024 * 1024,
7216 max_response_body: 16,
7217 max_inflight_requests: 1024,
7218 method: None,
7219 tls_config: None,
7220 };
7221 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7222
7223 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7224 let token = tokio_util::sync::CancellationToken::new();
7225 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7226 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7227 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7228
7229 let client = reqwest::Client::new();
7230 let send_fut = client
7231 .get(format!("http://127.0.0.1:{port}/limit-bytes"))
7232 .send();
7233
7234 let (http_result, _) = tokio::join!(send_fut, async {
7235 if let Some(mut envelope) = rx.recv().await {
7236 envelope.exchange.input.body =
7237 camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
7238 if let Some(reply_tx) = envelope.reply_tx {
7239 let _ = reply_tx.send(Ok(envelope.exchange));
7240 }
7241 }
7242 });
7243
7244 let resp = http_result.unwrap();
7245 assert_eq!(resp.status().as_u16(), 500);
7246 let body = resp.text().await.unwrap();
7247 assert_eq!(body, "Response body exceeds configured limit");
7248 token.cancel();
7249 }
7250
7251 #[tokio::test]
7252 #[allow(clippy::await_holding_lock)]
7253 async fn test_http_consumer_enforces_max_response_body_for_json() {
7254 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7255
7256 let _guard = lock_registry_test_mutex();
7257
7258 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7259 let port = listener.local_addr().unwrap().port();
7260 drop(listener);
7261
7262 let consumer_cfg = HttpServerConfig {
7263 scheme: "http".to_string(),
7264 host: "127.0.0.1".to_string(),
7265 port,
7266 path: "/limit-json".to_string(),
7267 max_request_body: 2 * 1024 * 1024,
7268 max_response_body: 16,
7269 max_inflight_requests: 1024,
7270 method: None,
7271 tls_config: None,
7272 };
7273 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7274
7275 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7276 let token = tokio_util::sync::CancellationToken::new();
7277 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7278 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7279 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7280
7281 let client = reqwest::Client::new();
7282 let send_fut = client
7283 .get(format!("http://127.0.0.1:{port}/limit-json"))
7284 .send();
7285
7286 let (http_result, _) = tokio::join!(send_fut, async {
7287 if let Some(mut envelope) = rx.recv().await {
7288 envelope.exchange.input.body = camel_component_api::Body::Json(
7289 serde_json::json!({"message":"this response is bigger than sixteen"}),
7290 );
7291 if let Some(reply_tx) = envelope.reply_tx {
7292 let _ = reply_tx.send(Ok(envelope.exchange));
7293 }
7294 }
7295 });
7296
7297 let resp = http_result.unwrap();
7298 assert_eq!(resp.status().as_u16(), 500);
7299 let body = resp.text().await.unwrap();
7300 assert_eq!(body, "Response body exceeds configured limit");
7301 token.cancel();
7302 }
7303
7304 #[tokio::test]
7305 #[allow(clippy::await_holding_lock)]
7306 async fn test_http_consumer_enforces_max_response_body_for_xml() {
7307 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7308
7309 let _guard = lock_registry_test_mutex();
7310
7311 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7312 let port = listener.local_addr().unwrap().port();
7313 drop(listener);
7314
7315 let consumer_cfg = HttpServerConfig {
7316 scheme: "http".to_string(),
7317 host: "127.0.0.1".to_string(),
7318 port,
7319 path: "/limit-xml".to_string(),
7320 max_request_body: 2 * 1024 * 1024,
7321 max_response_body: 16,
7322 max_inflight_requests: 1024,
7323 method: None,
7324 tls_config: None,
7325 };
7326 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7327
7328 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7329 let token = tokio_util::sync::CancellationToken::new();
7330 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7331 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7332 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7333
7334 let client = reqwest::Client::new();
7335 let send_fut = client
7336 .get(format!("http://127.0.0.1:{port}/limit-xml"))
7337 .send();
7338
7339 let (http_result, _) = tokio::join!(send_fut, async {
7340 if let Some(mut envelope) = rx.recv().await {
7341 envelope.exchange.input.body = camel_component_api::Body::Xml(
7342 "<root><value>way-too-large</value></root>".into(),
7343 );
7344 if let Some(reply_tx) = envelope.reply_tx {
7345 let _ = reply_tx.send(Ok(envelope.exchange));
7346 }
7347 }
7348 });
7349
7350 let resp = http_result.unwrap();
7351 assert_eq!(resp.status().as_u16(), 500);
7352 let body = resp.text().await.unwrap();
7353 assert_eq!(body, "Response body exceeds configured limit");
7354 token.cancel();
7355 }
7356
7357 #[tokio::test]
7358 #[allow(clippy::await_holding_lock)]
7359 async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
7360 use camel_component_api::{
7361 CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
7362 };
7363 use futures::stream;
7364
7365 let _guard = lock_registry_test_mutex();
7366
7367 let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
7368 let port = listener.local_addr().unwrap().port();
7369 drop(listener);
7370
7371 let consumer_cfg = HttpServerConfig {
7372 scheme: "http".to_string(),
7373 host: "0.0.0.0".to_string(),
7374 port,
7375 path: "/limit-stream".to_string(),
7376 max_request_body: 2 * 1024 * 1024,
7377 max_response_body: 16,
7378 max_inflight_requests: 1024,
7379 method: None,
7380 tls_config: None,
7381 };
7382 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7383
7384 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7385 let token = tokio_util::sync::CancellationToken::new();
7386 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7387 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7388 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7389
7390 let client = reqwest::Client::new();
7391 let send_fut = client
7392 .get(format!("http://127.0.0.1:{port}/limit-stream"))
7393 .send();
7394
7395 let (http_result, _) = tokio::join!(send_fut, async {
7396 if let Some(mut envelope) = rx.recv().await {
7397 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
7398 vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
7399 let stream = Box::pin(stream::iter(chunks));
7400 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
7401 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
7402 metadata: StreamMetadata {
7403 size_hint: Some(32),
7404 content_type: Some("application/octet-stream".into()),
7405 origin: None,
7406 },
7407 });
7408 if let Some(reply_tx) = envelope.reply_tx {
7409 let _ = reply_tx.send(Ok(envelope.exchange));
7410 }
7411 }
7412 });
7413
7414 let resp = http_result.unwrap();
7415 assert_eq!(resp.status().as_u16(), 200);
7416 let body = resp.bytes().await.unwrap();
7417 assert_eq!(body.len(), 32);
7418 token.cancel();
7419 }
7420
7421 #[tokio::test]
7426 #[allow(clippy::await_holding_lock)]
7427 async fn test_integration_single_consumer_round_trip() {
7428 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7429
7430 let _guard = lock_registry_test_mutex();
7434
7435 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7437 let port = listener.local_addr().unwrap().port();
7438 drop(listener); let component = HttpComponent::new();
7441 let endpoint_ctx = NoOpComponentContext;
7442 let endpoint = component
7443 .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
7444 .unwrap();
7445 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7446
7447 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7448 let token = tokio_util::sync::CancellationToken::new();
7449 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7450
7451 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7452 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7453
7454 let client = reqwest::Client::new();
7455 let send_fut = client
7456 .post(format!("http://127.0.0.1:{port}/echo"))
7457 .header("Content-Type", "text/plain")
7458 .body("ping")
7459 .send();
7460
7461 let (http_result, _) = tokio::join!(send_fut, async {
7462 if let Some(mut envelope) = rx.recv().await {
7463 assert_eq!(
7464 envelope.exchange.input.header("CamelHttpMethod"),
7465 Some(&serde_json::Value::String("POST".into()))
7466 );
7467 assert_eq!(
7468 envelope.exchange.input.header("CamelHttpPath"),
7469 Some(&serde_json::Value::String("/echo".into()))
7470 );
7471 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
7472 if let Some(reply_tx) = envelope.reply_tx {
7473 let _ = reply_tx.send(Ok(envelope.exchange));
7474 }
7475 }
7476 });
7477
7478 let resp = http_result.unwrap();
7479 assert_eq!(resp.status().as_u16(), 200);
7480 let body = resp.text().await.unwrap();
7481 assert_eq!(body, "pong");
7482
7483 token.cancel();
7484 }
7485
7486 #[tokio::test]
7487 #[allow(clippy::await_holding_lock)]
7488 async fn test_integration_two_consumers_shared_port() {
7489 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7490
7491 let _guard = lock_registry_test_mutex();
7492
7493 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7495 let port = listener.local_addr().unwrap().port();
7496 drop(listener);
7497
7498 let component = HttpComponent::new();
7499 let endpoint_ctx = NoOpComponentContext;
7500
7501 let endpoint_a = component
7503 .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
7504 .unwrap();
7505 let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
7506
7507 let endpoint_b = component
7509 .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
7510 .unwrap();
7511 let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
7512
7513 let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7514 let token_a = tokio_util::sync::CancellationToken::new();
7515 let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
7516
7517 let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7518 let token_b = tokio_util::sync::CancellationToken::new();
7519 let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
7520
7521 tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
7522 tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
7523 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7524
7525 let client = reqwest::Client::new();
7526
7527 let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
7529 let (resp_hello, _) = tokio::join!(fut_hello, async {
7530 if let Some(mut envelope) = rx_a.recv().await {
7531 envelope.exchange.input.body =
7532 camel_component_api::Body::Text("hello-response".to_string());
7533 if let Some(reply_tx) = envelope.reply_tx {
7534 let _ = reply_tx.send(Ok(envelope.exchange));
7535 }
7536 }
7537 });
7538
7539 let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
7541 let (resp_world, _) = tokio::join!(fut_world, async {
7542 if let Some(mut envelope) = rx_b.recv().await {
7543 envelope.exchange.input.body =
7544 camel_component_api::Body::Text("world-response".to_string());
7545 if let Some(reply_tx) = envelope.reply_tx {
7546 let _ = reply_tx.send(Ok(envelope.exchange));
7547 }
7548 }
7549 });
7550
7551 let body_a = resp_hello.unwrap().text().await.unwrap();
7552 let body_b = resp_world.unwrap().text().await.unwrap();
7553
7554 assert_eq!(body_a, "hello-response");
7555 assert_eq!(body_b, "world-response");
7556
7557 token_a.cancel();
7558 token_b.cancel();
7559 }
7560
7561 #[tokio::test]
7562 #[allow(clippy::await_holding_lock)]
7563 async fn test_integration_unregistered_path_returns_404() {
7564 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7565
7566 let _guard = lock_registry_test_mutex();
7567
7568 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7570 let port = listener.local_addr().unwrap().port();
7571 drop(listener);
7572
7573 let component = HttpComponent::new();
7574 let endpoint_ctx = NoOpComponentContext;
7575 let endpoint = component
7576 .create_endpoint(
7577 &format!("http://127.0.0.1:{port}/registered"),
7578 &endpoint_ctx,
7579 )
7580 .unwrap();
7581 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7582
7583 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7584 let token = tokio_util::sync::CancellationToken::new();
7585 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7586
7587 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7588
7589 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
7591 loop {
7592 if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
7593 .await
7594 .is_ok()
7595 {
7596 break;
7597 }
7598 if std::time::Instant::now() >= deadline {
7599 panic!("HTTP server did not start within 5s on port {port}");
7600 }
7601 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
7602 }
7603
7604 let client = reqwest::Client::new();
7605 let resp = client
7606 .get(format!("http://127.0.0.1:{port}/not-there"))
7607 .send()
7608 .await
7609 .unwrap();
7610 assert_eq!(resp.status().as_u16(), 404);
7611
7612 token.cancel();
7613 }
7614
7615 #[test]
7616 fn test_http_consumer_declares_concurrent() {
7617 use camel_component_api::ConcurrencyModel;
7618
7619 let config = HttpServerConfig {
7620 scheme: "http".to_string(),
7621 host: "127.0.0.1".to_string(),
7622 port: 19999,
7623 path: "/test".to_string(),
7624 max_request_body: 2 * 1024 * 1024,
7625 max_response_body: 10 * 1024 * 1024,
7626 max_inflight_requests: 1024,
7627 method: None,
7628 tls_config: None,
7629 };
7630 let consumer = HttpConsumer::new(config, test_rt());
7631 assert_eq!(
7632 consumer.concurrency_model(),
7633 ConcurrencyModel::Concurrent { max: None }
7634 );
7635 }
7636
7637 #[test]
7638 fn server_config_parses_tls_cert_and_key() {
7639 let cfg = HttpServerConfig::from_uri(
7640 "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
7641 )
7642 .unwrap();
7643 assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
7644 assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
7645 }
7646
7647 #[test]
7648 fn server_config_no_tls_when_params_absent() {
7649 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
7650 assert!(cfg.tls_config.is_none());
7651 }
7652
7653 #[tokio::test]
7658 async fn test_http_reply_body_stream_variant_exists() {
7659 use bytes::Bytes;
7660 use camel_component_api::CamelError;
7661 use futures::stream;
7662
7663 let chunks: Vec<Result<Bytes, CamelError>> =
7664 vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
7665 let stream = Box::pin(stream::iter(chunks));
7666 let reply_body = HttpReplyBody::Stream(stream);
7667 match reply_body {
7669 HttpReplyBody::Stream(_) => {}
7670 HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
7671 }
7672 }
7673
7674 #[cfg(feature = "otel")]
7679 mod otel_tests {
7680 use super::*;
7681 use camel_component_api::Message;
7682 use tower::ServiceExt;
7683
7684 #[tokio::test]
7685 async fn test_producer_injects_traceparent_header() {
7686 let (url, _handle) = start_test_server_with_header_capture().await;
7687 let ctx = test_producer_ctx();
7688
7689 let component = HttpComponent::new();
7690 let endpoint_ctx = NoOpComponentContext;
7691 let endpoint = component
7692 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
7693 .unwrap();
7694 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7695
7696 let mut exchange = Exchange::new(Message::default());
7698 let mut headers = std::collections::HashMap::new();
7699 headers.insert(
7700 "traceparent".to_string(),
7701 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
7702 );
7703 camel_otel::extract_into_exchange(&mut exchange, &headers);
7704
7705 let result = producer.oneshot(exchange).await.unwrap();
7706
7707 let status = result
7709 .input
7710 .header("CamelHttpResponseCode")
7711 .and_then(|v| v.as_u64())
7712 .unwrap();
7713 assert_eq!(status, 200);
7714
7715 let traceparent = result.input.header("X-Received-Traceparent");
7717 assert!(
7718 traceparent.is_some(),
7719 "traceparent header should have been sent"
7720 );
7721
7722 let traceparent_str = traceparent.unwrap().as_str().unwrap();
7723 let parts: Vec<&str> = traceparent_str.split('-').collect();
7725 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
7726 assert_eq!(parts[0], "00", "version should be 00");
7727 assert_eq!(
7728 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
7729 "trace-id should match"
7730 );
7731 assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
7732 assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
7733 }
7734
7735 #[tokio::test]
7736 async fn test_consumer_extracts_traceparent_header() {
7737 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7738
7739 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7741 let port = listener.local_addr().unwrap().port();
7742 drop(listener);
7743
7744 let component = HttpComponent::new();
7745 let endpoint_ctx = NoOpComponentContext;
7746 let endpoint = component
7747 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
7748 .unwrap();
7749 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7750
7751 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7752 let token = tokio_util::sync::CancellationToken::new();
7753 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7754
7755 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7756 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7757
7758 let client = reqwest::Client::new();
7760 let send_fut = client
7761 .post(format!("http://127.0.0.1:{port}/trace"))
7762 .header(
7763 "traceparent",
7764 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
7765 )
7766 .body("test")
7767 .send();
7768
7769 let (http_result, _) = tokio::join!(send_fut, async {
7770 if let Some(envelope) = rx.recv().await {
7771 let mut injected_headers = std::collections::HashMap::new();
7774 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
7775
7776 assert!(
7777 injected_headers.contains_key("traceparent"),
7778 "Exchange should have traceparent after extraction"
7779 );
7780
7781 let traceparent = injected_headers.get("traceparent").unwrap();
7782 let parts: Vec<&str> = traceparent.split('-').collect();
7783 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
7784 assert_eq!(
7785 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
7786 "Trace ID should match the original traceparent header"
7787 );
7788
7789 if let Some(reply_tx) = envelope.reply_tx {
7790 let _ = reply_tx.send(Ok(envelope.exchange));
7791 }
7792 }
7793 });
7794
7795 let resp = http_result.unwrap();
7796 assert_eq!(resp.status().as_u16(), 200);
7797
7798 token.cancel();
7799 }
7800
7801 #[tokio::test]
7802 async fn test_consumer_extracts_mixed_case_traceparent_header() {
7803 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7804
7805 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7807 let port = listener.local_addr().unwrap().port();
7808 drop(listener);
7809
7810 let component = HttpComponent::new();
7811 let endpoint_ctx = NoOpComponentContext;
7812 let endpoint = component
7813 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
7814 .unwrap();
7815 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7816
7817 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7818 let token = tokio_util::sync::CancellationToken::new();
7819 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7820
7821 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7822 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7823
7824 let client = reqwest::Client::new();
7826 let send_fut = client
7827 .post(format!("http://127.0.0.1:{port}/trace"))
7828 .header(
7829 "TraceParent",
7830 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
7831 )
7832 .body("test")
7833 .send();
7834
7835 let (http_result, _) = tokio::join!(send_fut, async {
7836 if let Some(envelope) = rx.recv().await {
7837 let mut injected_headers = HashMap::new();
7840 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
7841
7842 assert!(
7843 injected_headers.contains_key("traceparent"),
7844 "Exchange should have traceparent after extraction from mixed-case header"
7845 );
7846
7847 let traceparent = injected_headers.get("traceparent").unwrap();
7848 let parts: Vec<&str> = traceparent.split('-').collect();
7849 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
7850 assert_eq!(
7851 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
7852 "Trace ID should match the original mixed-case TraceParent header"
7853 );
7854
7855 if let Some(reply_tx) = envelope.reply_tx {
7856 let _ = reply_tx.send(Ok(envelope.exchange));
7857 }
7858 }
7859 });
7860
7861 let resp = http_result.unwrap();
7862 assert_eq!(resp.status().as_u16(), 200);
7863
7864 token.cancel();
7865 }
7866
7867 #[tokio::test]
7868 async fn test_producer_no_trace_context_no_crash() {
7869 let (url, _handle) = start_test_server().await;
7870 let ctx = test_producer_ctx();
7871
7872 let component = HttpComponent::new();
7873 let endpoint_ctx = NoOpComponentContext;
7874 let endpoint = component
7875 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
7876 .unwrap();
7877 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7878
7879 let exchange = Exchange::new(Message::default());
7881
7882 let result = producer.oneshot(exchange).await.unwrap();
7884
7885 let status = result
7887 .input
7888 .header("CamelHttpResponseCode")
7889 .and_then(|v| v.as_u64())
7890 .unwrap();
7891 assert_eq!(status, 200);
7892 }
7893
7894 async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
7896 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7897 let addr = listener.local_addr().unwrap();
7898 let url = format!("http://127.0.0.1:{}", addr.port());
7899
7900 let handle = tokio::spawn(async move {
7901 loop {
7902 if let Ok((mut stream, _)) = listener.accept().await {
7903 tokio::spawn(async move {
7904 use tokio::io::{AsyncReadExt, AsyncWriteExt};
7905 let mut buf = vec![0u8; 8192];
7906 let n = stream.read(&mut buf).await.unwrap_or(0);
7907 let request = String::from_utf8_lossy(&buf[..n]).to_string();
7908
7909 let traceparent = request
7911 .lines()
7912 .find(|line| line.to_lowercase().starts_with("traceparent:"))
7913 .map(|line| {
7914 line.split(':')
7915 .nth(1)
7916 .map(|s| s.trim().to_string())
7917 .unwrap_or_default()
7918 })
7919 .unwrap_or_default();
7920
7921 let body =
7922 format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
7923 let response = format!(
7924 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
7925 body.len(),
7926 traceparent,
7927 body
7928 );
7929 let _ = stream.write_all(response.as_bytes()).await;
7930 });
7931 }
7932 }
7933 });
7934
7935 (url, handle)
7936 }
7937 }
7938
7939 #[tokio::test]
7948 async fn test_request_body_arrives_as_stream() {
7949 use camel_component_api::Body;
7950 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7951
7952 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7953 let port = listener.local_addr().unwrap().port();
7954 drop(listener);
7955
7956 let component = HttpComponent::new();
7957 let endpoint_ctx = NoOpComponentContext;
7958 let endpoint = component
7959 .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
7960 .unwrap();
7961 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7962
7963 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7964 let token = tokio_util::sync::CancellationToken::new();
7965 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7966
7967 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7968 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7969
7970 let client = reqwest::Client::new();
7971 let send_fut = client
7972 .post(format!("http://127.0.0.1:{port}/upload"))
7973 .body("hello streaming world")
7974 .send();
7975
7976 let (http_result, _) = tokio::join!(send_fut, async {
7977 if let Some(mut envelope) = rx.recv().await {
7978 assert!(
7980 matches!(envelope.exchange.input.body, Body::Stream(_)),
7981 "expected Body::Stream, got discriminant {:?}",
7982 std::mem::discriminant(&envelope.exchange.input.body)
7983 );
7984 let bytes = envelope
7986 .exchange
7987 .input
7988 .body
7989 .into_bytes(1024 * 1024)
7990 .await
7991 .unwrap();
7992 assert_eq!(&bytes[..], b"hello streaming world");
7993
7994 envelope.exchange.input.body = camel_component_api::Body::Empty;
7995 if let Some(reply_tx) = envelope.reply_tx {
7996 let _ = reply_tx.send(Ok(envelope.exchange));
7997 }
7998 }
7999 });
8000
8001 let resp = http_result.unwrap();
8002 assert_eq!(resp.status().as_u16(), 200);
8003
8004 token.cancel();
8005 }
8006
8007 #[tokio::test]
8012 async fn test_streaming_response_chunked() {
8013 use bytes::Bytes;
8014 use camel_component_api::Body;
8015 use camel_component_api::CamelError;
8016 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8017 use camel_component_api::{StreamBody, StreamMetadata};
8018 use futures::stream;
8019 use std::sync::Arc;
8020 use tokio::sync::Mutex;
8021
8022 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8023 let port = listener.local_addr().unwrap().port();
8024 drop(listener);
8025
8026 let component = HttpComponent::new();
8027 let endpoint_ctx = NoOpComponentContext;
8028 let endpoint = component
8029 .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
8030 .unwrap();
8031 let mut consumer = endpoint.create_consumer(rt()).unwrap();
8032
8033 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8034 let token = tokio_util::sync::CancellationToken::new();
8035 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8036
8037 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8038 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8039
8040 let client = reqwest::Client::new();
8041 let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
8042
8043 let (http_result, _) = tokio::join!(send_fut, async {
8044 if let Some(mut envelope) = rx.recv().await {
8045 let chunks: Vec<Result<Bytes, CamelError>> =
8047 vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
8048 let stream = Box::pin(stream::iter(chunks));
8049 envelope.exchange.input.body = Body::Stream(StreamBody {
8050 stream: Arc::new(Mutex::new(Some(stream))),
8051 metadata: StreamMetadata::default(),
8052 });
8053 if let Some(reply_tx) = envelope.reply_tx {
8054 let _ = reply_tx.send(Ok(envelope.exchange));
8055 }
8056 }
8057 });
8058
8059 let resp = http_result.unwrap();
8060 assert_eq!(resp.status().as_u16(), 200);
8061 let body = resp.text().await.unwrap();
8062 assert_eq!(body, "chunk1chunk2");
8063
8064 token.cancel();
8065 }
8066
8067 #[tokio::test]
8072 async fn test_413_when_content_length_exceeds_limit() {
8073 use camel_component_api::ConsumerContext;
8074
8075 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8076 let port = listener.local_addr().unwrap().port();
8077 drop(listener);
8078
8079 let component = HttpComponent::new();
8081 let endpoint_ctx = NoOpComponentContext;
8082 let endpoint = component
8083 .create_endpoint(
8084 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
8085 &endpoint_ctx,
8086 )
8087 .unwrap();
8088 let mut consumer = endpoint.create_consumer(rt()).unwrap();
8089
8090 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8091 let token = tokio_util::sync::CancellationToken::new();
8092 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8093
8094 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8095 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8096
8097 let client = reqwest::Client::new();
8098 let resp = client
8099 .post(format!("http://127.0.0.1:{port}/upload"))
8100 .header("Content-Length", "1000") .body("x".repeat(1000))
8102 .send()
8103 .await
8104 .unwrap();
8105
8106 assert_eq!(resp.status().as_u16(), 413);
8107
8108 token.cancel();
8109 }
8110
8111 #[tokio::test]
8115 async fn test_chunked_upload_without_content_length_bypasses_limit() {
8116 use bytes::Bytes;
8117 use camel_component_api::Body;
8118 use camel_component_api::ConsumerContext;
8119 use futures::stream;
8120
8121 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8122 let port = listener.local_addr().unwrap().port();
8123 drop(listener);
8124
8125 let component = HttpComponent::new();
8127 let endpoint_ctx = NoOpComponentContext;
8128 let endpoint = component
8129 .create_endpoint(
8130 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
8131 &endpoint_ctx,
8132 )
8133 .unwrap();
8134 let mut consumer = endpoint.create_consumer(rt()).unwrap();
8135
8136 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8137 let token = tokio_util::sync::CancellationToken::new();
8138 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8139
8140 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8141 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8142
8143 let client = reqwest::Client::new();
8144
8145 let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
8149 Ok(Bytes::from("y".repeat(50))),
8150 Ok(Bytes::from("y".repeat(50))),
8151 ];
8152 let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
8153 let send_fut = client
8154 .post(format!("http://127.0.0.1:{port}/upload"))
8155 .body(stream_body)
8156 .send();
8157
8158 let consumer_fut = async {
8159 match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
8161 Ok(Some(mut envelope)) => {
8162 assert!(
8163 matches!(envelope.exchange.input.body, Body::Stream(_)),
8164 "expected Body::Stream"
8165 );
8166 envelope.exchange.input.body = camel_component_api::Body::Empty;
8167 if let Some(reply_tx) = envelope.reply_tx {
8168 let _ = reply_tx.send(Ok(envelope.exchange));
8169 }
8170 }
8171 Ok(None) => panic!("consumer channel closed unexpectedly"),
8172 Err(_) => {
8173 }
8176 }
8177 };
8178
8179 let (http_result, _) = tokio::join!(send_fut, consumer_fut);
8180
8181 let resp = http_result.unwrap();
8182 assert_ne!(
8189 resp.status().as_u16(),
8190 413,
8191 "chunked upload has no Content-Length to pre-check"
8192 );
8193 assert_eq!(resp.status().as_u16(), 200);
8194
8195 token.cancel();
8196 }
8197
8198 #[test]
8199 fn test_is_private_ip_ranges() {
8200 use camel_api::is_ssrf_blocked_ip;
8201 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(
8220 &"2001:4860:4860::8888".parse().unwrap()
8221 )); }
8223
8224 #[test]
8225 fn test_title_case_header() {
8226 assert_eq!(title_case_header("content-type"), "Content-Type");
8227 assert_eq!(title_case_header("authorization"), "Authorization");
8228 assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
8229 assert_eq!(title_case_header("host"), "Host");
8230 assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
8231 assert_eq!(title_case_header("single"), "Single");
8232 assert_eq!(title_case_header(""), "");
8233 }
8234
8235 #[test]
8236 fn test_resolve_url_combines_path_and_query_sources() {
8237 let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
8238 let mut exchange = Exchange::new(Message::default());
8239 exchange.input.set_header(
8240 "CamelHttpPath",
8241 serde_json::Value::String("next".to_string()),
8242 );
8243 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8244 assert!(url.starts_with("http://example.com/base/next?"));
8245 assert!(url.contains("foo=bar"));
8246
8247 exchange.input.set_header(
8248 "CamelHttpUri",
8249 serde_json::Value::String("http://other.test/root".to_string()),
8250 );
8251 exchange.input.set_header(
8252 "CamelHttpQuery",
8253 serde_json::Value::String("a=1&b=2".to_string()),
8254 );
8255
8256 let override_url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8257 assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
8258 }
8259
8260 fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
8261 let mut exchange = Exchange::new(Message::default());
8262 exchange
8263 .input
8264 .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
8265 exchange.input.set_header(
8266 "CamelHttpQuery",
8267 serde_json::Value::String(query.to_string()),
8268 );
8269 exchange
8270 }
8271
8272 #[test]
8273 fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
8274 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8275 cfg.bridge_endpoint = true;
8276 cfg.query_params
8277 .push(("token".to_string(), "secret".to_string()));
8278 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
8279 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8280 assert_eq!(url, "http://x?token=secret");
8284 assert!(!url.contains("/foo"));
8285 assert!(!url.contains("dropme"));
8286 }
8287
8288 #[test]
8289 fn resolve_url_bridge_endpoint_false_merges_path() {
8290 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8291 cfg.bridge_endpoint = false;
8292 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
8293 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8294 assert!(url.contains("/foo"), "url should contain /foo: {url}");
8295 assert!(
8296 url.contains("dropme=1"),
8297 "url should contain dropme=1: {url}"
8298 );
8299 }
8300
8301 #[test]
8302 fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
8303 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8304 cfg.bridge_endpoint = true;
8305 let mut exchange = Exchange::new(Message::default());
8306 exchange.input.set_header(
8307 "CamelHttpPath",
8308 serde_json::Value::String("/foo".to_string()),
8309 );
8310 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8311 assert_eq!(url, "http://x");
8312 assert!(!url.contains("/foo"));
8313 }
8314
8315 #[test]
8316 fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
8317 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8318 cfg.bridge_endpoint = true;
8319 let mut exchange = Exchange::new(Message::default());
8321 exchange.input.set_header(
8322 "CamelHttpUri",
8323 serde_json::Value::String("http://dest/explicit".to_string()),
8324 );
8325 exchange.input.set_header(
8326 "CamelHttpPath",
8327 serde_json::Value::String("/foo".to_string()),
8328 );
8329 exchange.input.set_header(
8330 "CamelHttpQuery",
8331 serde_json::Value::String("x=1".to_string()),
8332 );
8333 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8334 assert_eq!(url, "http://x");
8338 }
8339
8340 #[test]
8341 fn bridge_programmatic_params_use_percent20() {
8342 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8343 cfg.bridge_endpoint = true;
8344 cfg.query_params = vec![("b".to_string(), "x y".to_string())];
8345 let exchange = Exchange::new(Message::default());
8346
8347 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8348
8349 assert_eq!(url, "http://x?b=x%20y");
8353 assert!(!url.contains('+'));
8354 }
8355
8356 #[test]
8357 fn bridge_arm_carries_authored_raw_query() {
8358 let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
8359 let exchange = exchange_with_path_and_query("/ignored", "dropme=1");
8362
8363 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8364
8365 assert_eq!(url, "http://h/p?a=1");
8368 assert!(!url.contains("dropme"), "exchange query leaked: {url}");
8369 assert!(!url.contains("/ignored"), "exchange path leaked: {url}");
8370 }
8371
8372 #[test]
8379 fn resolve_url_bridge_preserves_dot_segments() {
8380 let mut cfg = HttpEndpointConfig::from_uri("http://h/a/../b").unwrap();
8381 cfg.bridge_endpoint = true;
8382 cfg.query_params.push(("k".to_string(), "1".to_string()));
8383 let exchange = Exchange::new(Message::default());
8384
8385 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8386
8387 assert_eq!(url, "http://h/a/../b?k=1");
8390 }
8391
8392 #[test]
8393 fn resolve_url_bridge_preserves_default_port() {
8394 let mut cfg = HttpEndpointConfig::from_uri("http://h:80/p").unwrap();
8395 cfg.bridge_endpoint = true;
8396 cfg.query_params.push(("k".to_string(), "1".to_string()));
8397 let exchange = Exchange::new(Message::default());
8398
8399 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8400
8401 assert_eq!(url, "http://h:80/p?k=1");
8404 }
8405
8406 #[test]
8407 fn resolve_url_bridge_preserves_scheme_and_host_case() {
8408 let mut cfg = HttpEndpointConfig::from_uri("http://ExAMPLE.COM/p").unwrap();
8409 cfg.bridge_endpoint = true;
8410 cfg.query_params.push(("k".to_string(), "1".to_string()));
8411 cfg.base_url = "HTTP://ExAMPLE.COM/p".to_string();
8415 let exchange = Exchange::new(Message::default());
8416
8417 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8418
8419 assert_eq!(url, "HTTP://ExAMPLE.COM/p?k=1");
8422 }
8423
8424 #[test]
8425 fn resolve_url_bridge_no_query_emits_base_verbatim() {
8426 let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8427 cfg.bridge_endpoint = true;
8428 let exchange = Exchange::new(Message::default());
8429
8430 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8431
8432 assert_eq!(url, "http://h/p");
8435 }
8436
8437 #[test]
8438 fn resolve_url_bridge_and_non_bridge_byte_identical() {
8439 let mut bridged = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
8442 bridged.bridge_endpoint = true;
8443 bridged
8444 .query_params
8445 .push(("k".to_string(), "1".to_string()));
8446 let bridge_url =
8447 HttpProducer::resolve_url(&Exchange::new(Message::default()), &bridged).unwrap();
8448
8449 let plain = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
8452 let mut exchange = Exchange::new(Message::default());
8453 exchange.input.set_header(
8454 "CamelHttpQuery",
8455 serde_json::Value::String("k=1".to_string()),
8456 );
8457 let plain_url = HttpProducer::resolve_url(&exchange, &plain).unwrap();
8458
8459 assert_eq!(bridge_url, plain_url);
8460 assert_eq!(bridge_url, "http://H:80/a/../b?k=1");
8461 }
8462
8463 #[test]
8464 fn resolve_url_bridge_preserves_ipv6_authority_verbatim() {
8465 let mut cfg = HttpEndpointConfig::from_uri("http://[::1]:8080/p").unwrap();
8466 cfg.bridge_endpoint = true;
8467 cfg.query_params.push(("k".to_string(), "1".to_string()));
8468 let exchange = Exchange::new(Message::default());
8469
8470 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8471
8472 assert_eq!(url, "http://[::1]:8080/p?k=1");
8473 }
8474
8475 #[test]
8476 fn resolve_url_bridge_empty_base_path_keeps_no_synthetic_slash() {
8477 let cfg = HttpEndpointConfig::from_uri("http://h?x=1&bridgeEndpoint=true").unwrap();
8478 let exchange = Exchange::new(Message::default());
8479
8480 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8481
8482 assert_eq!(url, "http://h?x=1");
8485 }
8486
8487 #[test]
8492 fn resolve_url_preserves_authored_query_order_and_bytes() {
8493 let config =
8494 HttpEndpointConfig::from_uri("http://h/p?a=1&b=x,y&c=t:1&connectTimeout=5000").unwrap();
8495 let exchange = Exchange::new(Message::default());
8496
8497 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8498
8499 assert_eq!(url, "http://h/p?a=1&b=x,y&c=t:1");
8502 }
8503
8504 #[test]
8505 fn resolve_url_consumes_encoded_option_key() {
8506 let config = HttpEndpointConfig::from_uri("http://h/p?connect%54imeout=5000&a=1").unwrap();
8507 let exchange = Exchange::new(Message::default());
8508
8509 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8510
8511 assert_eq!(url, "http://h/p?a=1");
8513 }
8514
8515 #[test]
8516 fn resolve_url_all_options_consumed_drops_query() {
8517 let config = HttpEndpointConfig::from_uri("http://h/p?connectTimeout=5000").unwrap();
8518 let exchange = Exchange::new(Message::default());
8519
8520 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8521
8522 assert_eq!(url, "http://h/p");
8525 assert!(!url.contains('?'));
8526 }
8527
8528 #[test]
8529 fn resolve_url_preserves_empty_query_marker() {
8530 let config = HttpEndpointConfig::from_uri("http://h/p?").unwrap();
8531 let exchange = Exchange::new(Message::default());
8532
8533 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8534
8535 assert_eq!(url, "http://h/p?");
8538 }
8539
8540 #[test]
8541 fn resolve_url_raw_wrapper_not_re_encoded() {
8542 let config = HttpEndpointConfig::from_uri("http://h/p?token=RAW(abc)").unwrap();
8543 let exchange = Exchange::new(Message::default());
8544
8545 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8546
8547 assert_eq!(url, "http://h/p?token=RAW(abc)");
8549 assert!(!url.contains("%28"), "RAW( wrapper re-encoded: {url}");
8550 }
8551
8552 #[test]
8553 fn resolve_url_camel_http_query_composes_verbatim_span() {
8554 let config = HttpEndpointConfig::from_uri("http://h/p?x=1").unwrap();
8555 let mut exchange = Exchange::new(Message::default());
8556 exchange.input.set_header(
8557 "CamelHttpQuery",
8558 serde_json::Value::String("userFilter=a%2Cb".to_string()),
8559 );
8560
8561 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8562
8563 assert_eq!(url, "http://h/p?x=1&userFilter=a%2Cb");
8568 assert!(!url.contains("%252C"), "header bytes re-encoded: {url}");
8569 }
8570
8571 #[test]
8576 fn header_composes_with_endpoint_query() {
8577 let config =
8578 HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret&lang=en").unwrap();
8579 let mut exchange = Exchange::new(Message::default());
8580 exchange.input.set_header(
8581 "CamelHttpQuery",
8582 serde_json::Value::String("lang=es&page=2".to_string()),
8583 );
8584
8585 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8586
8587 assert_eq!(url, "http://upstream/api?apiKey=secret&lang=en&page=2");
8590 }
8591
8592 #[test]
8593 fn header_alone_still_rides() {
8594 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
8595 let mut exchange = Exchange::new(Message::default());
8596 exchange.input.set_header(
8597 "CamelHttpQuery",
8598 serde_json::Value::String("page=2".to_string()),
8599 );
8600
8601 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8602
8603 assert_eq!(url, "http://upstream/api?page=2");
8605 }
8606
8607 #[test]
8608 fn empty_reflected_query_leaves_endpoint_query_intact() {
8609 let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
8610 let mut exchange = Exchange::new(Message::default());
8611 exchange
8614 .input
8615 .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
8616
8617 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8618
8619 assert_eq!(url, "http://upstream/api?apiKey=secret");
8621 assert!(!url.ends_with('?'), "dangling '?' marker: {url}");
8622 }
8623
8624 #[test]
8625 fn forbidden_byte_in_header_query_errors() {
8626 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
8627 let mut exchange = Exchange::new(Message::default());
8628 exchange.input.set_header(
8629 "CamelHttpQuery",
8630 serde_json::Value::String("q=ab<cd".to_string()),
8631 );
8632
8633 let err = HttpProducer::resolve_url(&exchange, &config)
8634 .unwrap_err()
8635 .to_string();
8636
8637 assert!(err.contains("0x3C"), "error must name the byte: {err}");
8640 }
8641
8642 #[test]
8643 fn override_uri_with_query_plus_header_query() {
8644 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
8645 let mut exchange = Exchange::new(Message::default());
8646 exchange.input.set_header(
8647 "CamelHttpUri",
8648 serde_json::Value::String("http://host/api?a=1".to_string()),
8649 );
8650 exchange.input.set_header(
8651 "CamelHttpQuery",
8652 serde_json::Value::String("a=2&b=3".to_string()),
8653 );
8654
8655 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8656
8657 assert_eq!(url, "http://host/api?a=1&b=3");
8660 }
8661
8662 #[test]
8663 fn path_applies_before_query_composition() {
8664 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
8665 let mut exchange = Exchange::new(Message::default());
8666 exchange.input.set_header(
8667 "CamelHttpUri",
8668 serde_json::Value::String("http://host/api?a=1".to_string()),
8669 );
8670 exchange.input.set_header(
8671 "CamelHttpPath",
8672 serde_json::Value::String("/extra".to_string()),
8673 );
8674 exchange.input.set_header(
8675 "CamelHttpQuery",
8676 serde_json::Value::String("b=2".to_string()),
8677 );
8678
8679 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8680
8681 assert_eq!(url, "http://host/api/extra?a=1&b=2");
8684 }
8685
8686 #[test]
8687 fn plain_proxy_reflection_composes() {
8688 let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
8689 let exchange = exchange_with_path_and_query("/in/extra", "page=2");
8691
8692 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8693
8694 assert_eq!(url, "http://upstream/api/in/extra?apiKey=secret&page=2");
8697 }
8698
8699 #[test]
8700 fn bridge_endpoint_ignores_url_headers() {
8701 let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
8702 let mut exchange = Exchange::new(Message::default());
8703 exchange.input.set_header(
8704 "CamelHttpUri",
8705 serde_json::Value::String("http://evil.test/x".to_string()),
8706 );
8707 exchange.input.set_header(
8708 "CamelHttpPath",
8709 serde_json::Value::String("/foo".to_string()),
8710 );
8711 exchange.input.set_header(
8712 "CamelHttpQuery",
8713 serde_json::Value::String("z=9".to_string()),
8714 );
8715
8716 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8717
8718 assert_eq!(url, "http://h/p?a=1");
8721 assert!(!url.contains("evil"), "override leaked: {url}");
8722 assert!(!url.contains("z=9"), "header query leaked: {url}");
8723 assert!(!url.contains("/foo"), "header path leaked: {url}");
8724 }
8725
8726 #[test]
8727 fn resolve_url_programmatic_params_use_percent20_deterministic() {
8728 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8729 config.query_params = vec![
8730 ("b".to_string(), "x y".to_string()),
8731 ("a".to_string(), "1".to_string()),
8732 ];
8733 let exchange = Exchange::new(Message::default());
8734
8735 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8736
8737 assert_eq!(url, "http://h/p?b=x%20y&a=1");
8740 assert!(!url.contains('+'));
8741 }
8742
8743 #[test]
8744 fn resolve_url_authored_and_programmatic_merge() {
8745 let mut config = HttpEndpointConfig::from_uri("http://h/p?a=1&c=t:1").unwrap();
8746 config.query_params = vec![
8747 ("b".to_string(), "2".to_string()),
8748 ("a".to_string(), "9".to_string()),
8749 ];
8750 let exchange = Exchange::new(Message::default());
8751
8752 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8753
8754 assert_eq!(url, "http://h/p?a=1&c=t:1&b=2");
8757 }
8758
8759 #[test]
8760 fn from_uri_no_longer_fills_query_params_from_uri() {
8761 let config = HttpEndpointConfig::from_uri("http://h/p?a=1&connectTimeout=5000").unwrap();
8762
8763 assert!(
8765 config.query_params.is_empty(),
8766 "query_params is programmatic-only: {:?}",
8767 config.query_params
8768 );
8769 assert_eq!(config.raw_query.as_deref(), Some("a=1&connectTimeout=5000"));
8770 }
8771
8772 #[test]
8773 fn resolve_url_forbidden_raw_byte_errors() {
8774 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8775 config.raw_query = Some("a=x y".to_string());
8776 let exchange = Exchange::new(Message::default());
8777
8778 let err = HttpProducer::resolve_url(&exchange, &config)
8779 .expect_err("literal space in raw query must error");
8780
8781 assert!(
8783 err.to_string().contains("0x20"),
8784 "error must name the forbidden byte: {err}"
8785 );
8786 }
8787
8788 #[test]
8792 fn resolve_url_override_query_forbidden_byte_errors() {
8793 let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8794 let mut exchange = Exchange::new(Message::default());
8795 exchange.input.set_header(
8796 "CamelHttpUri",
8797 serde_json::Value::String("http://h2/p?a=x y".to_string()),
8798 );
8799
8800 let err = HttpProducer::resolve_url(&exchange, &config)
8801 .expect_err("literal space in the override URI's query must error");
8802
8803 assert!(
8804 err.to_string().contains("0x20"),
8805 "error must name the forbidden byte from the override query: {err}"
8806 );
8807 }
8808
8809 #[test]
8814 fn merge_header_query_decoded_key_collision_drops_header_pair() {
8815 let merged = merge_header_query(Some("a=1"), "%61=2")
8816 .expect("decoded-key collision must not be a parse error");
8817 assert_eq!(
8818 merged.as_deref(),
8819 Some("a=1"),
8820 "the higher-precedence span wins and the colliding header pair is dropped"
8821 );
8822 }
8823
8824 #[test]
8827 fn merge_header_query_duplicate_keys_within_header_ride_verbatim() {
8828 let merged = merge_header_query(None, "k=1&k=2")
8829 .expect("duplicate header keys must not be a parse error");
8830 assert_eq!(
8831 merged.as_deref(),
8832 Some("k=1&k=2"),
8833 "intra-header duplicate keys ride verbatim"
8834 );
8835 }
8836
8837 #[test]
8840 fn endpoint_config_debug_masks_base_url_userinfo() {
8841 let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
8842 config.base_url = "http://user:pass@h.example/p".to_string();
8843 let rendered = format!("{config:?}");
8844 assert!(
8845 rendered.contains("***@h.example"),
8846 "userinfo must render masked: {rendered}"
8847 );
8848 assert!(
8849 !rendered.contains("user:pass"),
8850 "no credentials in Debug output: {rendered}"
8851 );
8852
8853 let plain = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
8854 let rendered_plain = format!("{plain:?}");
8855 assert!(
8856 rendered_plain.contains("http://h.example/p"),
8857 "a base without userinfo renders unchanged: {rendered_plain}"
8858 );
8859 }
8860
8861 #[test]
8867 fn resolve_url_authored_apostrophe_rejected_percent_escape_rides() {
8868 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8869
8870 config.raw_query = Some("q=it's".to_string());
8871 let exchange = Exchange::new(Message::default());
8872 let err = HttpProducer::resolve_url(&exchange, &config)
8873 .expect_err("authored apostrophe must be rejected, not silently %27-normalized");
8874 assert!(
8875 err.to_string().contains("0x27"),
8876 "error must name the apostrophe byte: {err}"
8877 );
8878
8879 config.raw_query = Some("q=it%27s".to_string());
8880 let url = HttpProducer::resolve_url(&exchange, &config)
8881 .expect("authored %27 escape is wire-legal");
8882 assert!(
8883 url.contains("q=it%27s"),
8884 "the authored escape must ride byte-for-byte: {url}"
8885 );
8886
8887 for &byte in b"\"`<>" {
8891 config.raw_query = Some(format!("k={}x", byte as char));
8892 let err = HttpProducer::resolve_url(&exchange, &config)
8893 .expect_err("WHATWG special-query byte must be rejected");
8894 assert!(
8895 err.to_string().contains(&format!("0x{byte:02X}")),
8896 "error must name byte 0x{byte:02X}: {err}"
8897 );
8898 }
8899 }
8900
8901 #[test]
8902 fn armed_fence_rejects_unknown_host_redacted() {
8903 let cfg = HttpEndpointConfig::from_uri(
8904 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
8905 )
8906 .unwrap();
8907 let mut exchange = Exchange::new(Message::default());
8908 exchange.input.set_header(
8909 "CamelHttpUri",
8910 serde_json::Value::String(
8911 "http://user:pass@evil.example.com/x?token=s3cret".to_string(),
8912 ),
8913 );
8914
8915 let err = HttpProducer::resolve_url(&exchange, &cfg)
8916 .expect_err("override host outside the fence must fail resolution");
8917
8918 let message = err.to_string();
8919 assert!(!message.contains("pass"), "userinfo leaked: {message}");
8920 assert!(!message.contains("s3cret"), "query leaked: {message}");
8921 }
8922
8923 #[test]
8924 fn armed_fence_rejects_unparseable_override_redacted() {
8925 let cfg = HttpEndpointConfig::from_uri(
8926 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
8927 )
8928 .unwrap();
8929 let mut exchange = Exchange::new(Message::default());
8930 exchange.input.set_header(
8931 "CamelHttpUri",
8932 serde_json::Value::String("http://u:fencesecret@evil.example.com:99999/x".to_string()),
8933 );
8934
8935 let err = HttpProducer::resolve_url(&exchange, &cfg)
8936 .expect_err("unparseable override outside the fence must fail resolution");
8937
8938 let message = err.to_string();
8939 assert!(
8940 message.contains("allowedUriHosts fence"),
8941 "fence must be named: {message}"
8942 );
8943 assert!(
8944 message.contains("[redacted]"),
8945 "suppression sentinel missing: {message}"
8946 );
8947 assert!(
8948 !message.contains("evil.example.com"),
8949 "host leaked: fail-closed arm must render only the sentinel: {message}"
8950 );
8951 assert!(
8952 !message.contains("fencesecret"),
8953 "password leaked: {message}"
8954 );
8955 assert!(!message.contains("u:"), "userinfo leaked: {message}");
8956 }
8957
8958 #[test]
8959 fn armed_fence_rejects_password_only_userinfo_redacted() {
8960 let cfg = HttpEndpointConfig::from_uri(
8961 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
8962 )
8963 .unwrap();
8964 let mut exchange = Exchange::new(Message::default());
8965 exchange.input.set_header(
8966 "CamelHttpUri",
8967 serde_json::Value::String(
8968 "http://:passwordonly@evil.example.com/x?token=querysecret".to_string(),
8969 ),
8970 );
8971
8972 let err = HttpProducer::resolve_url(&exchange, &cfg)
8973 .expect_err("password-only override outside the fence must fail resolution");
8974
8975 let message = err.to_string();
8976 assert!(
8977 !message.contains("passwordonly"),
8978 "password-only userinfo leaked: {message}"
8979 );
8980 assert!(!message.contains("querysecret"), "query leaked: {message}");
8981 assert!(
8982 message.contains("http://***@evil.example.com/x?[redacted]"),
8983 "masked shape missing: {message}"
8984 );
8985 }
8986
8987 #[test]
8988 fn armed_fence_allows_listed_host() {
8989 let cfg = HttpEndpointConfig::from_uri(
8990 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
8991 )
8992 .unwrap();
8993 let mut exchange = Exchange::new(Message::default());
8994 exchange.input.set_header(
8995 "CamelHttpUri",
8996 serde_json::Value::String("http://cdn.example.com/x".to_string()),
8997 );
8998
8999 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9000 assert_eq!(url, "http://cdn.example.com/x");
9001 }
9002
9003 #[test]
9004 fn host_only_entry_permits_any_port() {
9005 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=cdn.example.com").unwrap();
9006 let mut exchange = Exchange::new(Message::default());
9007 exchange.input.set_header(
9008 "CamelHttpUri",
9009 serde_json::Value::String("http://cdn.example.com:9443/x".to_string()),
9010 );
9011
9012 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9013 assert_eq!(url, "http://cdn.example.com:9443/x");
9014 }
9015
9016 #[test]
9017 fn unarmed_endpoint_unchanged() {
9018 let cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9019 let mut exchange = Exchange::new(Message::default());
9020 exchange.input.set_header(
9021 "CamelHttpUri",
9022 serde_json::Value::String("http://any.example.com/path".to_string()),
9023 );
9024
9025 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9026 assert_eq!(url, "http://any.example.com/path");
9027 }
9028
9029 #[test]
9030 fn empty_allowlist_fails_endpoint_creation() {
9031 assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=,,").is_err());
9032 }
9033
9034 #[test]
9035 fn malformed_entry_fails_endpoint_creation() {
9036 assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=not a host!").is_err());
9037 }
9038
9039 #[test]
9040 fn fence_entry_with_path_fails_creation() {
9041 assert!(
9044 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=api.internal:8443/v2").is_err()
9045 );
9046 }
9047
9048 #[test]
9049 fn fence_entry_with_userinfo_fails_creation() {
9050 assert!(
9051 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=user@cdn.example.com").is_err()
9052 );
9053 }
9054
9055 #[test]
9056 fn ipv6_fence_entry_allows_bracketed_host() {
9057 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=[::1]:8443").unwrap();
9058 for uri in ["http://[::1]:8443/x", "http://[0:0:0:0:0:0:0:1]:8443/x"] {
9061 let mut exchange = Exchange::new(Message::default());
9062 exchange
9063 .input
9064 .set_header("CamelHttpUri", serde_json::Value::String(uri.to_string()));
9065 let url = HttpProducer::resolve_url(&exchange, &cfg)
9066 .unwrap_or_else(|e| panic!("override {uri} must be honored: {e}"));
9067 assert_eq!(url, uri, "bracketed IPv6 override not honored");
9068 }
9069 }
9070
9071 #[test]
9072 fn dns_case_insensitive_fence_match() {
9073 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=CDN.Example.COM").unwrap();
9076 let mut exchange = Exchange::new(Message::default());
9077 exchange.input.set_header(
9078 "CamelHttpUri",
9079 serde_json::Value::String("http://cdn.example.com/x".to_string()),
9080 );
9081 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9082 assert_eq!(url, "http://cdn.example.com/x");
9083 }
9084
9085 #[test]
9086 fn fence_allowed_override_query_merges_with_header() {
9087 let cfg =
9090 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example&k=v").unwrap();
9091 let mut exchange = Exchange::new(Message::default());
9092 exchange.input.set_header(
9093 "CamelHttpUri",
9094 serde_json::Value::String("http://host.example/api?a=1".to_string()),
9095 );
9096 exchange.input.set_header(
9097 "CamelHttpQuery",
9098 serde_json::Value::String("b=2".to_string()),
9099 );
9100
9101 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9102 assert_eq!(url, "http://host.example/api?a=1&b=2");
9103 }
9104
9105 #[test]
9106 fn empty_header_with_armed_fence_leaves_no_query() {
9107 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example").unwrap();
9108 let mut exchange = Exchange::new(Message::default());
9109 exchange.input.set_header(
9110 "CamelHttpUri",
9111 serde_json::Value::String("http://host.example/api".to_string()),
9112 );
9113 exchange
9114 .input
9115 .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
9116
9117 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9118 assert_eq!(url, "http://host.example/api");
9119 assert!(!url.contains('?'), "query marker leaked: {url}");
9120 }
9121
9122 #[test]
9123 fn fence_option_is_consumed() {
9124 let cfg =
9128 HttpEndpointConfig::from_uri("http://h/p?x=1&allowedUriHosts=cdn.example.com").unwrap();
9129 let exchange = Exchange::new(Message::default());
9130
9131 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9132 assert!(!url.contains("allowedUriHosts"), "option leaked: {url}");
9133 assert!(url.contains("x=1"), "authored query lost: {url}");
9134 }
9135
9136 #[tokio::test]
9137 async fn resolve_url_malformed_base_url_errors_no_panic() {
9138 use tower::ServiceExt;
9139
9140 let (url, _handle) = start_test_server().await;
9141 let mut config = HttpEndpointConfig::from_uri("http://[::1:bad").unwrap();
9142 config.allow_internal = true; let producer = HttpProducer {
9144 config: Arc::new(config),
9145 client: build_client(&HttpConfig::default(), None),
9146 pinned_cache: Arc::new(PinnedClientCache::new(
9147 PINNED_CLIENT_TTL,
9148 PINNED_CLIENT_MAX_ENTRIES,
9149 )),
9150 http_config: Arc::new(HttpConfig::default()),
9151 runtime: rt(),
9152 };
9153
9154 let first = producer
9157 .clone()
9158 .oneshot(Exchange::new(Message::default()))
9159 .await;
9160 let err = first.expect_err("malformed base URL must error, not panic");
9161 assert!(
9162 err.to_string().to_lowercase().contains("url"),
9163 "error must name the malformed URL: {err}"
9164 );
9165
9166 let mut exchange = Exchange::new(Message::default());
9169 exchange.input.set_header(
9170 "CamelHttpUri",
9171 serde_json::Value::String(format!("{url}/api")),
9172 );
9173 let response = producer
9174 .oneshot(exchange)
9175 .await
9176 .expect("valid request through same producer must succeed");
9177 let status = response
9178 .input
9179 .header("CamelHttpResponseCode")
9180 .and_then(|v| v.as_u64())
9181 .unwrap();
9182 assert_eq!(status, 200);
9183 }
9184
9185 #[test]
9186 fn resolve_url_bridge_malformed_base_errors_no_panic() {
9187 let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9188 cfg.bridge_endpoint = true;
9189 cfg.query_params.push(("k".to_string(), "1".to_string()));
9190 cfg.base_url = "http://[::1:bad".to_string();
9195 let exchange = Exchange::new(Message::default());
9196
9197 let err = HttpProducer::resolve_url(&exchange, &cfg)
9198 .expect_err("malformed bridge base URL must error");
9199 assert!(
9200 err.to_string().contains("invalid base URL"),
9201 "error must name the invalid base URL: {err}"
9202 );
9203 }
9204
9205 #[test]
9206 fn test_http_producer_helpers_status_and_size_boundaries() {
9207 assert!(HttpProducer::is_ok_status(200, (200, 299)));
9208 assert!(HttpProducer::is_ok_status(299, (200, 299)));
9209 assert!(!HttpProducer::is_ok_status(199, (200, 299)));
9210 assert!(!HttpProducer::is_ok_status(300, (200, 299)));
9211
9212 assert!(!exceeds_max_response_body(10, 10));
9213 assert!(exceeds_max_response_body(11, 10));
9214 }
9215
9216 #[allow(clippy::await_holding_lock)]
9221 async fn setup_consumer_on_free_port(
9222 path: &str,
9223 ) -> (
9224 u16,
9225 tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
9226 tokio_util::sync::CancellationToken,
9227 ) {
9228 use camel_component_api::ConsumerContext;
9229
9230 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9235 let port = listener.local_addr().unwrap().port();
9236
9237 let _registry_guard = lock_registry_test_mutex();
9246
9247 ServerRegistry::global()
9248 .stage_listener(listener)
9249 .await
9250 .expect("stage consumer test listener");
9251
9252 let consumer_cfg = HttpServerConfig {
9253 scheme: "http".to_string(),
9254 host: "127.0.0.1".to_string(),
9255 port,
9256 path: path.to_string(),
9257 max_request_body: 2 * 1024 * 1024,
9258 max_response_body: 10 * 1024 * 1024,
9259 max_inflight_requests: 1024,
9260 method: None,
9261 tls_config: None,
9262 };
9263 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
9264
9265 let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
9266 let token = tokio_util::sync::CancellationToken::new();
9267 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9268
9269 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9270
9271 wait_for_registry_ready("127.0.0.1", port).await;
9277 for _ in 0..8 {
9278 tokio::task::yield_now().await;
9279 }
9280
9281 (port, rx, token)
9282 }
9283
9284 async fn wait_for_registry_ready(host: &str, port: u16) {
9289 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
9290 let mut backoff = std::time::Duration::from_millis(1);
9291 while ServerRegistry::global().bound_addr(host, port).is_none() {
9292 assert!(
9293 tokio::time::Instant::now() < deadline,
9294 "consumer server did not become ready on port {port} — registry entry absent (concurrent reset or starvation)"
9295 );
9296 tokio::time::sleep(backoff).await;
9297 backoff = (backoff * 2).min(std::time::Duration::from_millis(64));
9298 }
9299 }
9300
9301 #[tokio::test]
9302 #[should_panic(expected = "registry entry absent (concurrent reset or starvation)")]
9303 async fn readiness_deadline_fires_loud_with_hint() {
9304 let held = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9315 let port = held.local_addr().unwrap().port();
9316 wait_for_registry_ready("httpflake-unreachable-host", port).await;
9317 }
9318
9319 #[tokio::test]
9324 async fn readiness_survives_concurrent_registry_reset() {
9325 let contended = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
9326 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
9327
9328 let contended_hammer = std::sync::Arc::clone(&contended);
9332 let stop_hammer = std::sync::Arc::clone(&stop);
9333 let handle = std::thread::spawn(move || {
9334 while !stop_hammer.load(std::sync::atomic::Ordering::Relaxed) {
9335 let _guard = match REGISTRY_TEST_MUTEX.try_lock() {
9336 Err(_) => {
9337 contended_hammer.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
9338 lock_registry_test_mutex()
9339 }
9340 Ok(guard) => guard,
9341 };
9342 ServerRegistry::reset();
9343 }
9344 });
9345
9346 struct StopHammerOnDrop {
9349 handle: Option<std::thread::JoinHandle<()>>,
9350 stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
9351 }
9352 impl Drop for StopHammerOnDrop {
9353 fn drop(&mut self) {
9354 self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
9355 if let Some(handle) = self.handle.take() {
9356 let _ = handle.join();
9357 }
9358 }
9359 }
9360 let _hammer_guard = StopHammerOnDrop {
9361 handle: Some(handle),
9362 stop,
9363 };
9364
9365 let mut setups = 0;
9368 loop {
9369 setups += 1;
9370 let (_port, rx, token) = setup_consumer_on_free_port("/reset-hammer").await;
9371 drop(rx);
9372 token.cancel();
9373 if (setups >= 25 && contended.load(std::sync::atomic::Ordering::SeqCst) >= 1)
9374 || setups >= 50
9375 {
9376 break;
9377 }
9378 }
9379
9380 let contended_hits = contended.load(std::sync::atomic::Ordering::SeqCst);
9381 assert!(
9382 contended_hits >= 1,
9383 "expected at least one contended registry reset across {setups} setups, got {contended_hits}"
9384 );
9385 }
9386
9387 #[tokio::test]
9388 async fn test_content_type_inferred_for_json_body() {
9389 let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
9390
9391 let client = reqwest::Client::new();
9392 let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
9393
9394 let (http_result, _) = tokio::join!(send_fut, async {
9395 if let Some(mut envelope) = rx.recv().await {
9396 envelope.exchange.input.body =
9397 camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
9398 if let Some(reply_tx) = envelope.reply_tx {
9399 let _ = reply_tx.send(Ok(envelope.exchange));
9400 }
9401 }
9402 });
9403
9404 let resp = http_result.unwrap();
9405 assert_eq!(resp.status().as_u16(), 200);
9406 let ct = resp
9407 .headers()
9408 .get("content-type")
9409 .expect("Content-Type header should be present");
9410 assert_eq!(ct, "application/json");
9411 let body = resp.text().await.unwrap();
9412 assert_eq!(body, r#"{"message":"hello"}"#);
9413
9414 token.cancel();
9415 }
9416
9417 #[tokio::test]
9418 async fn test_content_type_inferred_for_text_body() {
9419 let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
9420
9421 let client = reqwest::Client::new();
9422 let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
9423
9424 let (http_result, _) = tokio::join!(send_fut, async {
9425 if let Some(mut envelope) = rx.recv().await {
9426 envelope.exchange.input.body =
9427 camel_component_api::Body::Text("plain text response".to_string());
9428 if let Some(reply_tx) = envelope.reply_tx {
9429 let _ = reply_tx.send(Ok(envelope.exchange));
9430 }
9431 }
9432 });
9433
9434 let resp = http_result.unwrap();
9435 assert_eq!(resp.status().as_u16(), 200);
9436 let ct = resp
9437 .headers()
9438 .get("content-type")
9439 .expect("Content-Type header should be present");
9440 assert_eq!(ct, "text/plain; charset=utf-8");
9441 let body = resp.text().await.unwrap();
9442 assert_eq!(body, "plain text response");
9443
9444 token.cancel();
9445 }
9446
9447 #[tokio::test]
9448 async fn test_content_type_inferred_for_xml_body() {
9449 let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
9450
9451 let client = reqwest::Client::new();
9452 let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
9453
9454 let (http_result, _) = tokio::join!(send_fut, async {
9455 if let Some(mut envelope) = rx.recv().await {
9456 envelope.exchange.input.body =
9457 camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
9458 if let Some(reply_tx) = envelope.reply_tx {
9459 let _ = reply_tx.send(Ok(envelope.exchange));
9460 }
9461 }
9462 });
9463
9464 let resp = http_result.unwrap();
9465 assert_eq!(resp.status().as_u16(), 200);
9466 let ct = resp
9467 .headers()
9468 .get("content-type")
9469 .expect("Content-Type header should be present");
9470 assert_eq!(ct, "application/xml");
9471 let body = resp.text().await.unwrap();
9472 assert_eq!(body, "<root><item>value</item></root>");
9473
9474 token.cancel();
9475 }
9476
9477 #[tokio::test]
9478 async fn test_no_content_type_for_empty_body() {
9479 let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
9480
9481 let client = reqwest::Client::new();
9482 let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
9483
9484 let (http_result, _) = tokio::join!(send_fut, async {
9485 if let Some(mut envelope) = rx.recv().await {
9486 envelope.exchange.input.body = camel_component_api::Body::Empty;
9487 if let Some(reply_tx) = envelope.reply_tx {
9488 let _ = reply_tx.send(Ok(envelope.exchange));
9489 }
9490 }
9491 });
9492
9493 let resp = http_result.unwrap();
9494 assert_eq!(resp.status().as_u16(), 200);
9495 assert!(
9496 resp.headers().get("content-type").is_none(),
9497 "Empty body should not set Content-Type"
9498 );
9499
9500 token.cancel();
9501 }
9502
9503 #[tokio::test]
9504 async fn test_no_content_type_for_raw_bytes_body() {
9505 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
9506
9507 let client = reqwest::Client::new();
9508 let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
9509
9510 let (http_result, _) = tokio::join!(send_fut, async {
9511 if let Some(mut envelope) = rx.recv().await {
9512 envelope.exchange.input.body =
9513 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
9514 if let Some(reply_tx) = envelope.reply_tx {
9515 let _ = reply_tx.send(Ok(envelope.exchange));
9516 }
9517 }
9518 });
9519
9520 let resp = http_result.unwrap();
9521 assert_eq!(resp.status().as_u16(), 200);
9522 assert!(
9523 resp.headers().get("content-type").is_none(),
9524 "Raw Bytes body should not set Content-Type"
9525 );
9526
9527 token.cancel();
9528 }
9529
9530 #[tokio::test]
9531 async fn test_content_type_from_stream_metadata() {
9532 use camel_component_api::{StreamBody, StreamMetadata};
9533 use futures::stream;
9534
9535 let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
9536
9537 let client = reqwest::Client::new();
9538 let send_fut = client
9539 .get(format!("http://127.0.0.1:{port}/stream-ct"))
9540 .send();
9541
9542 let (http_result, _) = tokio::join!(send_fut, async {
9543 if let Some(mut envelope) = rx.recv().await {
9544 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
9545 vec![Ok(bytes::Bytes::from("audio data"))];
9546 let stream = Box::pin(stream::iter(chunks));
9547 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
9548 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
9549 metadata: StreamMetadata {
9550 size_hint: None,
9551 content_type: Some("audio/mpeg".to_string()),
9552 origin: None,
9553 },
9554 });
9555 if let Some(reply_tx) = envelope.reply_tx {
9556 let _ = reply_tx.send(Ok(envelope.exchange));
9557 }
9558 }
9559 });
9560
9561 let resp = http_result.unwrap();
9562 assert_eq!(resp.status().as_u16(), 200);
9563 let ct = resp
9564 .headers()
9565 .get("content-type")
9566 .expect("Content-Type header should be present");
9567 assert_eq!(ct, "audio/mpeg");
9568 let body = resp.text().await.unwrap();
9569 assert_eq!(body, "audio data");
9570
9571 token.cancel();
9572 }
9573
9574 #[tokio::test]
9575 async fn test_user_content_type_overrides_inferred() {
9576 let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
9577
9578 let client = reqwest::Client::new();
9579 let send_fut = client
9580 .get(format!("http://127.0.0.1:{port}/override-ct"))
9581 .send();
9582
9583 let (http_result, _) = tokio::join!(send_fut, async {
9584 if let Some(mut envelope) = rx.recv().await {
9585 envelope.exchange.input.body =
9586 camel_component_api::Body::Json(serde_json::json!({"ok": true}));
9587 envelope.exchange.input.set_header(
9588 "Content-Type",
9589 serde_json::Value::String("text/html".to_string()),
9590 );
9591 if let Some(reply_tx) = envelope.reply_tx {
9592 let _ = reply_tx.send(Ok(envelope.exchange));
9593 }
9594 }
9595 });
9596
9597 let resp = http_result.unwrap();
9598 assert_eq!(resp.status().as_u16(), 200);
9599 let ct = resp
9600 .headers()
9601 .get("content-type")
9602 .expect("Content-Type header should be present");
9603 assert_eq!(
9604 ct, "text/html",
9605 "User-set Content-Type should take precedence over inferred type"
9606 );
9607
9608 token.cancel();
9609 }
9610
9611 #[tokio::test]
9612 async fn test_user_content_type_with_bytes_body() {
9613 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
9614
9615 let client = reqwest::Client::new();
9616 let send_fut = client
9617 .get(format!("http://127.0.0.1:{port}/bytes-ct"))
9618 .send();
9619
9620 let (http_result, _) = tokio::join!(send_fut, async {
9621 if let Some(mut envelope) = rx.recv().await {
9622 envelope.exchange.input.body =
9623 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
9624 envelope.exchange.input.set_header(
9625 "Content-Type",
9626 serde_json::Value::String("application/json".to_string()),
9627 );
9628 if let Some(reply_tx) = envelope.reply_tx {
9629 let _ = reply_tx.send(Ok(envelope.exchange));
9630 }
9631 }
9632 });
9633
9634 let resp = http_result.unwrap();
9635 assert_eq!(resp.status().as_u16(), 200);
9636 let ct = resp
9637 .headers()
9638 .get("content-type")
9639 .expect("Content-Type header should be present for Bytes body with user header");
9640 assert_eq!(
9641 ct, "application/json",
9642 "User Content-Type should be sent for Bytes body"
9643 );
9644
9645 token.cancel();
9646 }
9647
9648 #[tokio::test]
9653 async fn monitor_task_silent_on_clean_exit() {
9654 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
9655 monitor_axum_task(
9657 handle,
9658 "127.0.0.1:0".to_string(),
9659 noop_rt(),
9660 "test-monitor".into(),
9661 )
9662 .await;
9663 }
9664
9665 #[tokio::test]
9666 async fn monitor_task_handles_panicked_task() {
9667 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
9668 panic!("simulated server crash");
9669 });
9670 monitor_axum_task(
9672 handle,
9673 "127.0.0.1:9999".to_string(),
9674 noop_rt(),
9675 "test-monitor".into(),
9676 )
9677 .await;
9678 }
9679
9680 #[test]
9685 fn http_auth_basic_debug_redacts_password() {
9686 let auth = HttpAuth::Basic {
9687 username: "admin".to_string(),
9688 password: "hunter2".to_string(),
9689 };
9690 let debug = format!("{:?}", auth);
9691 assert!(
9692 !debug.contains("hunter2"),
9693 "password must be redacted: {debug}"
9694 );
9695 assert!(debug.contains("admin"), "username should appear: {debug}");
9696 }
9697
9698 #[test]
9699 fn http_auth_bearer_debug_redacts_token() {
9700 let auth = HttpAuth::Bearer {
9701 token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
9702 };
9703 let debug = format!("{:?}", auth);
9704 assert!(
9705 !debug.contains("eyJhbGci"),
9706 "token must be redacted: {debug}"
9707 );
9708 }
9709
9710 #[test]
9711 fn http_auth_none_debug_shows_variant() {
9712 let debug = format!("{:?}", HttpAuth::None);
9713 assert!(
9714 debug.contains("None"),
9715 "None variant should appear: {debug}"
9716 );
9717 }
9718
9719 #[test]
9720 fn http_endpoint_config_debug_redacts_auth_credentials() {
9721 let config = HttpEndpointConfig::from_uri(
9722 "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
9723 )
9724 .unwrap();
9725 let debug = format!("{:?}", config);
9726 assert!(
9727 !debug.contains("secret123"),
9728 "password must be redacted in HttpEndpointConfig debug: {debug}"
9729 );
9730 }
9731
9732 #[test]
9733 fn debug_lists_all_public_fields() {
9734 let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9735 let debug = format!("{:?}", config);
9736 for field in [
9737 "base_url",
9738 "http_method",
9739 "throw_exception_on_failure",
9740 "ok_status_code_range",
9741 "response_timeout",
9742 "query_params",
9743 "raw_query",
9744 "allow_internal",
9745 "blocked_hosts",
9746 "max_body_size",
9747 "read_timeout_ms",
9748 "max_response_bytes",
9749 "auth",
9750 "token_provider",
9751 "user_agent",
9752 "bridge_endpoint",
9753 "connection_close",
9754 "skip_request_headers",
9755 "skip_response_headers",
9756 "follow_redirects",
9757 "max_redirects",
9758 ] {
9759 assert!(
9760 debug.contains(field),
9761 "Debug output missing field '{field}': {debug}"
9762 );
9763 }
9764 }
9765
9766 use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
9771 use tower_http::services::ServeDir;
9772
9773 fn make_test_registry() -> HttpRouteRegistry {
9774 HttpRouteRegistry::new()
9775 }
9776
9777 fn make_test_state(registry: HttpRouteRegistry) -> AppState {
9778 AppState {
9779 registry,
9780 max_request_body: 2 * 1024 * 1024,
9781 max_response_body: 10 * 1024 * 1024,
9782 inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
9783 }
9784 }
9785
9786 #[allow(clippy::await_holding_lock)]
9787 #[tokio::test]
9788 async fn test_static_file_serving_serves_file_contents() {
9789 let _guard = lock_registry_test_mutex();
9790 ServerRegistry::reset();
9791
9792 let temp_dir =
9794 std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
9795 std::fs::create_dir_all(&temp_dir).unwrap();
9796 std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
9797 std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
9798
9799 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
9800
9801 let registry = make_test_registry();
9802 let serve_dir = ServeDir::new(&canonical_dir)
9803 .precompressed_gzip()
9804 .precompressed_br()
9805 .append_index_html_on_directories(true);
9806
9807 let mount = StaticMount {
9808 mount_path: "/".to_string(),
9809 mode: MountMode::Static,
9810 dir: canonical_dir.clone(),
9811 cache_control: "public, max-age=3600".to_string(),
9812 error_pages: std::collections::HashMap::new(),
9813 serve_dir,
9814 };
9815 registry.register_static_mount(mount).await.unwrap();
9816
9817 let state = make_test_state(registry);
9818
9819 let req = Request::builder()
9821 .uri("/hello.txt")
9822 .body(AxumBody::empty())
9823 .unwrap();
9824 let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
9825 assert_eq!(resp.status(), StatusCode::OK);
9826 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9827 .await
9828 .unwrap();
9829 assert_eq!(&body[..], b"Hello, static world!");
9830
9831 let req = Request::builder()
9833 .uri("/style.css")
9834 .body(AxumBody::empty())
9835 .unwrap();
9836 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
9837 assert_eq!(resp.status(), StatusCode::OK);
9838 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9839 .await
9840 .unwrap();
9841 assert_eq!(&body[..], b"body { color: red; }");
9842
9843 let req = Request::builder()
9845 .uri("/missing.txt")
9846 .body(AxumBody::empty())
9847 .unwrap();
9848 let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
9849 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9850
9851 std::fs::remove_dir_all(&temp_dir).ok();
9853 }
9854
9855 #[allow(clippy::await_holding_lock)]
9856 #[tokio::test]
9857 async fn test_spa_fallback_serves_index_for_unknown_paths() {
9858 let _guard = lock_registry_test_mutex();
9859 ServerRegistry::reset();
9860
9861 let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
9862 std::fs::create_dir_all(&temp_dir).unwrap();
9863 std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
9864 std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
9865
9866 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
9867
9868 let registry = make_test_registry();
9869 let serve_dir = ServeDir::new(&canonical_dir)
9870 .precompressed_gzip()
9871 .precompressed_br()
9872 .append_index_html_on_directories(true);
9873
9874 let mount = StaticMount {
9875 mount_path: "/".to_string(),
9876 mode: MountMode::Spa,
9877 dir: canonical_dir.clone(),
9878 cache_control: "public, max-age=0".to_string(),
9879 error_pages: std::collections::HashMap::new(),
9880 serve_dir,
9881 };
9882 registry.register_static_mount(mount).await.unwrap();
9884
9885 let state = make_test_state(registry);
9886
9887 let req = Request::builder()
9889 .method("GET")
9890 .uri("/dashboard")
9891 .header("Accept", "text/html")
9892 .body(AxumBody::empty())
9893 .unwrap();
9894 let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
9895 assert_eq!(resp.status(), StatusCode::OK);
9896 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9897 .await
9898 .unwrap();
9899 assert_eq!(&body[..], b"<h1>SPA App</h1>");
9900
9901 let req = Request::builder()
9903 .method("GET")
9904 .uri("/app.js")
9905 .body(AxumBody::empty())
9906 .unwrap();
9907 let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
9908 assert_eq!(resp.status(), StatusCode::OK);
9909 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9910 .await
9911 .unwrap();
9912 assert_eq!(&body[..], b"console.log('app')");
9913
9914 let req = Request::builder()
9916 .method("GET")
9917 .uri("/api/data")
9918 .header("Accept", "application/json")
9919 .body(AxumBody::empty())
9920 .unwrap();
9921 let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
9922 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9923
9924 let req = Request::builder()
9926 .method("GET")
9927 .uri("/style.css")
9928 .header("Accept", "text/html")
9929 .body(AxumBody::empty())
9930 .unwrap();
9931 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
9932 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9933
9934 std::fs::remove_dir_all(&temp_dir).ok();
9936 }
9937
9938 #[allow(clippy::await_holding_lock)]
9945 async fn run_conditional_get_returns_304(mode: MountMode) {
9946 let _guard = lock_registry_test_mutex();
9947 ServerRegistry::reset();
9948
9949 let temp_dir = std::env::temp_dir().join(format!(
9950 "http_cond_get_{}_{}",
9951 if mode == MountMode::Spa {
9952 "spa"
9953 } else {
9954 "static"
9955 },
9956 std::process::id()
9957 ));
9958 std::fs::create_dir_all(&temp_dir).unwrap();
9959 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
9960
9961 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
9962
9963 let registry = make_test_registry();
9964 let serve_dir = ServeDir::new(&canonical_dir)
9965 .precompressed_gzip()
9966 .precompressed_br()
9967 .append_index_html_on_directories(true);
9968
9969 let mount = StaticMount {
9970 mount_path: "/".to_string(),
9971 mode,
9972 dir: canonical_dir.clone(),
9973 cache_control: "public, max-age=3600".to_string(),
9974 error_pages: std::collections::HashMap::new(),
9975 serve_dir,
9976 };
9977 registry.register_static_mount(mount).await.unwrap();
9978
9979 let state = make_test_state(registry);
9980
9981 let req = Request::builder()
9983 .method("GET")
9984 .uri("/index.html")
9985 .body(AxumBody::empty())
9986 .unwrap();
9987 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
9988 assert_eq!(
9989 resp.status(),
9990 StatusCode::OK,
9991 "first GET should return 200, got {}",
9992 resp.status()
9993 );
9994 assert!(
9996 resp.headers().contains_key(http::header::CACHE_CONTROL),
9997 "200 response missing Cache-Control"
9998 );
9999 let etag = resp
10000 .headers()
10001 .get(http::header::ETAG)
10002 .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
10003 .clone();
10004 let last_modified = resp
10005 .headers()
10006 .get(http::header::LAST_MODIFIED)
10007 .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
10008 .clone();
10009 let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
10011 .await
10012 .unwrap();
10013
10014 let req = Request::builder()
10018 .method("GET")
10019 .uri("/index.html")
10020 .header(http::header::IF_NONE_MATCH, etag.clone())
10021 .body(AxumBody::empty())
10022 .unwrap();
10023 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
10024 assert_eq!(
10025 resp.status(),
10026 StatusCode::NOT_MODIFIED,
10027 "If-None-Match with matching ETag should return 304, got {}",
10028 resp.status()
10029 );
10030 assert!(
10032 resp.headers().contains_key(http::header::CACHE_CONTROL),
10033 "304 (If-None-Match) missing Cache-Control"
10034 );
10035 assert_eq!(
10038 resp.headers().get(http::header::ETAG),
10039 Some(&etag),
10040 "304 (If-None-Match) must echo the ETag validator"
10041 );
10042 assert_eq!(
10043 resp.headers().get(http::header::LAST_MODIFIED),
10044 Some(&last_modified),
10045 "304 (If-None-Match) must carry Last-Modified"
10046 );
10047
10048 let req = Request::builder()
10050 .method("GET")
10051 .uri("/index.html")
10052 .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
10053 .body(AxumBody::empty())
10054 .unwrap();
10055 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
10056 assert_eq!(
10057 resp.status(),
10058 StatusCode::NOT_MODIFIED,
10059 "If-Modified-Since with matching timestamp should return 304, got {}",
10060 resp.status()
10061 );
10062 assert!(
10063 resp.headers().contains_key(http::header::CACHE_CONTROL),
10064 "304 (If-Modified-Since) missing Cache-Control"
10065 );
10066 assert_eq!(
10067 resp.headers().get(http::header::ETAG),
10068 Some(&etag),
10069 "304 (If-Modified-Since) must carry the ETag validator"
10070 );
10071 assert_eq!(
10072 resp.headers().get(http::header::LAST_MODIFIED),
10073 Some(&last_modified),
10074 "304 (If-Modified-Since) must echo Last-Modified"
10075 );
10076
10077 let req = Request::builder()
10083 .method("GET")
10084 .uri("/index.html")
10085 .header(
10086 http::header::IF_MODIFIED_SINCE,
10087 "Wed, 21 Oct 2000 07:28:00 GMT",
10088 )
10089 .body(AxumBody::empty())
10090 .unwrap();
10091 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
10092 assert_eq!(
10093 resp.status(),
10094 StatusCode::OK,
10095 "past If-Modified-Since should return 200 (file modified after it), got {}",
10096 resp.status()
10097 );
10098
10099 std::fs::remove_dir_all(&temp_dir).ok();
10101 }
10102
10103 #[tokio::test]
10104 async fn test_conditional_get_returns_304_static_mode() {
10105 run_conditional_get_returns_304(MountMode::Static).await;
10106 }
10107
10108 #[tokio::test]
10109 async fn test_conditional_get_returns_304_spa_mode() {
10110 run_conditional_get_returns_304(MountMode::Spa).await;
10111 }
10112
10113 #[allow(clippy::await_holding_lock)]
10114 #[tokio::test]
10115 async fn test_error_page_mapping_serves_custom_404() {
10116 let _guard = lock_registry_test_mutex();
10117 ServerRegistry::reset();
10118
10119 let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
10120 let errors_dir = temp_dir.join("errors");
10121 std::fs::create_dir_all(&errors_dir).unwrap();
10122 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
10123 std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
10124
10125 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
10126 let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
10127
10128 let registry = make_test_registry();
10129 let serve_dir = ServeDir::new(&canonical_dir)
10130 .precompressed_gzip()
10131 .precompressed_br()
10132 .append_index_html_on_directories(true);
10133
10134 let mut error_pages = std::collections::HashMap::new();
10135 error_pages.insert(404, canonical_404);
10136
10137 let mount = StaticMount {
10138 mount_path: "/".to_string(),
10139 mode: MountMode::Static,
10140 dir: canonical_dir.clone(),
10141 cache_control: "public, max-age=0".to_string(),
10142 error_pages,
10143 serve_dir,
10144 };
10145 registry.register_static_mount(mount).await.unwrap();
10146
10147 let state = make_test_state(registry);
10148
10149 let req = Request::builder()
10151 .method("GET")
10152 .uri("/missing.html")
10153 .body(AxumBody::empty())
10154 .unwrap();
10155 let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
10156 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
10157 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10158 .await
10159 .unwrap();
10160 assert_eq!(&body[..], b"<h1>Custom 404</h1>");
10161
10162 let req = Request::builder()
10164 .method("GET")
10165 .uri("/index.html")
10166 .body(AxumBody::empty())
10167 .unwrap();
10168 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
10169 assert_eq!(resp.status(), StatusCode::OK);
10170 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10171 .await
10172 .unwrap();
10173 assert_eq!(&body[..], b"<h1>Home</h1>");
10174
10175 std::fs::remove_dir_all(&temp_dir).ok();
10177 }
10178
10179 #[tokio::test]
10180 async fn http_consumer_returns_body_and_code_on_stop() {
10181 use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
10182 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
10183 use tower::ServiceExt;
10184
10185 let set_body_step = CompiledStep::Process {
10187 kind_hint: camel_api::SpanKindHint::Internal,
10188 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
10189 ex.input.body = Body::Text("nope".into());
10190 Box::pin(async move { Ok(ex) })
10191 }),
10192 body_contract: None,
10193 lifecycle: None,
10194 label: None,
10195 to_uri: None,
10196 };
10197 let set_status_step = CompiledStep::Process {
10198 kind_hint: camel_api::SpanKindHint::Internal,
10199 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
10200 ex.input.set_header(
10201 "CamelHttpResponseCode",
10202 serde_json::Value::Number(409.into()),
10203 );
10204 Box::pin(async move { Ok(ex) })
10205 }),
10206 body_contract: None,
10207 lifecycle: None,
10208 label: None,
10209 to_uri: None,
10210 };
10211 let pipeline = compose_pipeline_with_handler(
10212 vec![set_body_step, set_status_step, CompiledStep::Stop],
10213 None,
10214 PipelineRuntimeCtx::compile_time(),
10215 );
10216
10217 let ex = Exchange::new(Message::default());
10218 let result = pipeline.oneshot(ex).await;
10219 assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
10220 let returned = result.unwrap();
10221 assert_eq!(returned.input.body.as_text(), Some("nope"));
10222 assert_eq!(
10223 returned
10224 .input
10225 .header("CamelHttpResponseCode")
10226 .and_then(|v| v.as_u64()),
10227 Some(409)
10228 );
10229 }
10230
10231 #[tokio::test]
10232 async fn http_consumer_returns_200_when_body_empty_on_stop() {
10233 use camel_api::{Exchange, Message};
10241 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
10242 use tower::ServiceExt;
10243
10244 let pipeline = compose_pipeline_with_handler(
10245 vec![CompiledStep::Stop],
10246 None,
10247 PipelineRuntimeCtx::compile_time(),
10248 );
10249 let ex = Exchange::new(Message::default());
10250 let result = pipeline.oneshot(ex).await;
10251 assert!(result.is_ok(), "Stop with empty body arrives as Ok");
10252 }
10255
10256 async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
10264 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10265 let port = listener.local_addr().unwrap().port();
10266 let registry = HttpRouteRegistry::new();
10267 tokio::spawn(run_axum_server(
10268 listener,
10269 registry.clone(),
10270 2 * 1024 * 1024,
10271 10 * 1024 * 1024,
10272 Arc::new(tokio::sync::Semaphore::new(1024)),
10273 test_rt(),
10274 "test-route".into(),
10275 ));
10276 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
10278 (port, registry)
10279 }
10280
10281 fn spawn_responder(
10286 mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
10287 status: u16,
10288 body: String,
10289 ) -> tokio::task::JoinHandle<()> {
10290 tokio::spawn(async move {
10291 if let Some(envelope) = rx.recv().await {
10292 let _ = envelope.reply_tx.send(HttpReply {
10293 status,
10294 headers: vec![],
10295 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
10296 });
10297 }
10298 })
10299 }
10300
10301 #[tokio::test]
10302 async fn method_aware_dispatch_same_path_different_verbs() {
10303 let (port, registry) = spawn_test_server().await;
10304
10305 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10310 registry
10311 .register_rest_endpoint(
10312 "GET".into(),
10313 vec![PathSegment::Literal("users".into())],
10314 get_tx,
10315 )
10316 .await;
10317
10318 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10319 registry
10320 .register_rest_endpoint(
10321 "POST".into(),
10322 vec![PathSegment::Literal("users".into())],
10323 post_tx,
10324 )
10325 .await;
10326
10327 let get_handle = spawn_responder(get_rx, 200, "list".into());
10328 let post_handle = spawn_responder(post_rx, 201, "create".into());
10329
10330 let client = reqwest::Client::new();
10331
10332 let resp = client
10334 .get(format!("http://127.0.0.1:{port}/users"))
10335 .send()
10336 .await
10337 .unwrap();
10338 assert_eq!(resp.status().as_u16(), 200);
10339 let body = resp.text().await.unwrap();
10340 assert_eq!(body, "list");
10341
10342 let resp = client
10344 .post(format!("http://127.0.0.1:{port}/users"))
10345 .send()
10346 .await
10347 .unwrap();
10348 assert_eq!(resp.status().as_u16(), 201);
10349 let body = resp.text().await.unwrap();
10350 assert_eq!(body, "create");
10351
10352 let _ = tokio::join!(get_handle, post_handle);
10353 }
10354
10355 #[tokio::test]
10356 async fn method_aware_dispatch_templated_path_extracts_params() {
10357 let (port, registry) = spawn_test_server().await;
10358
10359 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10363 registry
10364 .register_rest_endpoint(
10365 "GET".into(),
10366 vec![
10367 PathSegment::Literal("users".into()),
10368 PathSegment::Param("id".into()),
10369 ],
10370 tx,
10371 )
10372 .await;
10373
10374 let handle = tokio::spawn(async move {
10377 if let Some(envelope) = rx.recv().await {
10378 let id = envelope.path_params.get("id").cloned().unwrap_or_default();
10379 let _ = envelope.reply_tx.send(HttpReply {
10380 status: 200,
10381 headers: vec![],
10382 body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
10383 });
10384 }
10385 });
10386
10387 let client = reqwest::Client::new();
10388 let resp = client
10389 .get(format!("http://127.0.0.1:{port}/users/42"))
10390 .send()
10391 .await
10392 .unwrap();
10393 assert_eq!(resp.status().as_u16(), 200);
10394 let body = resp.text().await.unwrap();
10395 assert_eq!(body, "id=42");
10396
10397 let _ = handle.await;
10398 }
10399
10400 #[tokio::test]
10401 async fn method_aware_dispatch_unmatched_method_falls_through() {
10402 let (port, _registry) = spawn_test_server().await;
10407
10408 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10410 _registry
10411 .register_rest_endpoint(
10412 "GET".into(),
10413 vec![PathSegment::Literal("users".into())],
10414 get_tx,
10415 )
10416 .await;
10417
10418 let drain = tokio::spawn(async move {
10421 let mut get_rx = get_rx;
10422 while get_rx.recv().await.is_some() {}
10423 });
10424
10425 let client = reqwest::Client::new();
10426 let resp = client
10427 .delete(format!("http://127.0.0.1:{port}/users"))
10428 .send()
10429 .await
10430 .unwrap();
10431 assert_eq!(resp.status().as_u16(), 404);
10432
10433 drop(drain);
10434 }
10435
10436 #[tokio::test]
10437 async fn regression_legacy_exact_api_route_still_works() {
10438 let (port, registry) = spawn_test_server().await;
10443
10444 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10445 registry.register_api_route("/legacy/path".into(), tx).await;
10446
10447 let handle = tokio::spawn(async move {
10448 if let Some(envelope) = rx.recv().await {
10449 let _ = envelope.reply_tx.send(HttpReply {
10450 status: 200,
10451 headers: vec![],
10452 body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
10453 });
10454 }
10455 });
10456
10457 let client = reqwest::Client::new();
10458 let resp = client
10459 .get(format!("http://127.0.0.1:{port}/legacy/path"))
10460 .send()
10461 .await
10462 .unwrap();
10463 assert_eq!(resp.status().as_u16(), 200);
10464 let body = resp.text().await.unwrap();
10465 assert_eq!(body, "legacy ok");
10466
10467 let _ = handle.await;
10468 }
10469
10470 #[allow(clippy::await_holding_lock)]
10471 #[tokio::test]
10472 async fn regression_static_mount_still_works() {
10473 let _guard = lock_registry_test_mutex();
10477 ServerRegistry::reset();
10478
10479 let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
10480 std::fs::create_dir_all(&temp_dir).unwrap();
10481 std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
10482 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
10483
10484 let registry = make_test_registry();
10485 let serve_dir = ServeDir::new(&canonical_dir)
10486 .precompressed_gzip()
10487 .precompressed_br()
10488 .append_index_html_on_directories(true);
10489 let mount = StaticMount {
10490 mount_path: "/".to_string(),
10491 mode: MountMode::Static,
10492 dir: canonical_dir.clone(),
10493 cache_control: "public, max-age=3600".to_string(),
10494 error_pages: std::collections::HashMap::new(),
10495 serve_dir,
10496 };
10497 registry.register_static_mount(mount).await.unwrap();
10498
10499 let state = make_test_state(registry);
10500 let req = Request::builder()
10501 .uri("/regress.txt")
10502 .body(AxumBody::empty())
10503 .unwrap();
10504 let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
10505 assert_eq!(resp.status(), StatusCode::OK);
10506 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10507 .await
10508 .unwrap();
10509 assert_eq!(&body[..], b"static works");
10510
10511 std::fs::remove_dir_all(&temp_dir).ok();
10512 }
10513
10514 #[tokio::test]
10523 async fn deregister_one_method_keeps_sibling_verbs() {
10524 let (port, registry) = spawn_test_server().await;
10528
10529 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10530 registry
10531 .register_rest_endpoint(
10532 "GET".into(),
10533 vec![PathSegment::Literal("users".into())],
10534 get_tx,
10535 )
10536 .await;
10537
10538 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10539 registry
10540 .register_rest_endpoint(
10541 "POST".into(),
10542 vec![PathSegment::Literal("users".into())],
10543 post_tx,
10544 )
10545 .await;
10546
10547 let drain = tokio::spawn(async move {
10549 let mut get_rx = get_rx;
10550 while get_rx.recv().await.is_some() {}
10551 });
10552
10553 registry.unregister_rest_endpoint("GET", "/users").await;
10555 drop(drain);
10556
10557 let post_handle = spawn_responder(post_rx, 201, "create".into());
10558
10559 let client = reqwest::Client::new();
10560 let resp = client
10562 .post(format!("http://127.0.0.1:{port}/users"))
10563 .send()
10564 .await
10565 .unwrap();
10566 assert_eq!(resp.status().as_u16(), 201);
10567 assert_eq!(resp.text().await.unwrap(), "create");
10568
10569 let _ = post_handle.await;
10570 }
10571
10572 #[tokio::test]
10573 async fn dispatch_exact_legacy_beats_rest_template() {
10574 let (port, registry) = spawn_test_server().await;
10579
10580 let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10582 registry
10583 .register_api_route("/api/users".into(), exact_tx)
10584 .await;
10585 let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
10586
10587 let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10589 registry
10590 .register_rest_endpoint(
10591 "GET".into(),
10592 vec![
10593 PathSegment::Literal("api".into()),
10594 PathSegment::Param("resource".into()),
10595 ],
10596 tpl_tx,
10597 )
10598 .await;
10599 let _tpl_drain = tokio::spawn(async move {
10605 let mut tpl_rx = tpl_rx;
10606 if let Some(env) = tpl_rx.recv().await {
10607 let _ = env.reply_tx.send(HttpReply {
10608 status: 200,
10609 headers: vec![],
10610 body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
10611 });
10612 }
10613 });
10614
10615 let client = reqwest::Client::new();
10616 let resp = client
10617 .get(format!("http://127.0.0.1:{port}/api/users"))
10618 .send()
10619 .await
10620 .unwrap();
10621 assert_eq!(resp.status().as_u16(), 200);
10622 assert_eq!(resp.text().await.unwrap(), "exact");
10624
10625 let _ = exact_handle.await;
10626 }
10627
10628 #[tokio::test]
10629 async fn ambiguous_rest_templates_return_500_not_silent_404() {
10630 let (port, registry) = spawn_test_server().await;
10635
10636 let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10637 registry
10638 .register_rest_endpoint(
10639 "GET".into(),
10640 vec![
10641 PathSegment::Literal("users".into()),
10642 PathSegment::Param("id".into()),
10643 ],
10644 a_tx,
10645 )
10646 .await;
10647
10648 let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10649 registry
10650 .register_rest_endpoint(
10651 "GET".into(),
10652 vec![
10653 PathSegment::Literal("users".into()),
10654 PathSegment::Param("name".into()),
10655 ],
10656 b_tx,
10657 )
10658 .await;
10659
10660 let client = reqwest::Client::new();
10661 let resp = client
10662 .get(format!("http://127.0.0.1:{port}/users/42"))
10663 .send()
10664 .await
10665 .unwrap();
10666 assert_eq!(resp.status().as_u16(), 500);
10668 }
10669
10670 #[test]
10671 fn from_uri_round_trips_templated_path_with_http_method() {
10672 use crate::UriConfig;
10678 let cfg =
10679 HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
10680 assert_eq!(cfg.host, "0.0.0.0");
10681 assert_eq!(cfg.port, 8080);
10682 assert_eq!(cfg.path, "/users/{id}");
10683 assert_eq!(cfg.method.as_deref(), Some("GET"));
10684
10685 let cfg_lc =
10687 HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
10688 assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
10689 assert_eq!(cfg_lc.path, "/orders");
10690 }
10691
10692 #[test]
10697 fn type_conversion_failed_maps_to_400() {
10698 let reply = pipeline_error_to_reply(
10699 CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
10700 "/api/users",
10701 );
10702 assert_eq!(reply.status, 400);
10703 let json_ct = reply
10705 .headers
10706 .iter()
10707 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
10708 .count();
10709 assert_eq!(json_ct, 1);
10710 let body = match &reply.body {
10712 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
10713 _ => panic!("expected bytes body"),
10714 };
10715 let parsed: serde_json::Value =
10716 serde_json::from_str(&body).expect("body must be valid JSON");
10717 assert_eq!(parsed["error"], "bad_request");
10718 assert_eq!(parsed["message"], "invalid JSON at line 1");
10719 }
10720
10721 #[test]
10722 fn other_error_still_maps_to_500() {
10723 let reply =
10724 pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
10725 assert_eq!(reply.status, 500);
10726 }
10727
10728 #[test]
10729 fn unauthenticated_maps_to_401() {
10730 let reply = pipeline_error_to_reply(
10731 CamelError::Unauthenticated("no token".to_string()),
10732 "/api/users",
10733 );
10734 assert_eq!(reply.status, 401);
10735 }
10736
10737 #[test]
10738 fn unauthorized_maps_to_403() {
10739 let reply = pipeline_error_to_reply(
10740 CamelError::Unauthorized("forbidden".to_string()),
10741 "/api/users",
10742 );
10743 assert_eq!(reply.status, 403);
10744 }
10745
10746 #[test]
10747 fn validation_error_maps_to_400() {
10748 let reply = pipeline_error_to_reply(
10749 CamelError::ValidationError("body does not match schema".to_string()),
10750 "/api/users",
10751 );
10752 assert_eq!(reply.status, 400);
10753 let json_ct = reply
10754 .headers
10755 .iter()
10756 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
10757 .count();
10758 assert_eq!(json_ct, 1);
10759 let body = match &reply.body {
10760 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
10761 _ => panic!("expected bytes body"),
10762 };
10763 let parsed: serde_json::Value =
10764 serde_json::from_str(&body).expect("body must be valid JSON");
10765 assert_eq!(parsed["error"], "validation_error");
10766 assert_eq!(parsed["message"], "body does not match schema");
10767 }
10768
10769 #[test]
10774 fn finalizer_maps_unsupported_media_type() {
10775 let reply = pipeline_error_to_reply(
10776 CamelError::UnsupportedMediaType {
10777 consumed: "text/plain".to_string(),
10778 declared: "application/json".to_string(),
10779 },
10780 "/x",
10781 );
10782 assert_eq!(reply.status, 415);
10783 let json_ct = reply
10784 .headers
10785 .iter()
10786 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
10787 .count();
10788 assert_eq!(json_ct, 1);
10789 let body = match &reply.body {
10790 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
10791 _ => panic!("expected bytes body"),
10792 };
10793 let parsed: serde_json::Value =
10794 serde_json::from_str(&body).expect("body must be valid JSON");
10795 assert_eq!(parsed["error"], "unsupported_media_type");
10796 assert_eq!(
10797 parsed["message"],
10798 "consumed text/plain, declared application/json"
10799 );
10800 }
10801
10802 #[test]
10803 fn finalizer_maps_not_acceptable() {
10804 let reply = pipeline_error_to_reply(
10805 CamelError::NotAcceptable {
10806 accept: "application/xml".to_string(),
10807 produced: "application/json".to_string(),
10808 },
10809 "/x",
10810 );
10811 assert_eq!(reply.status, 406);
10812 let json_ct = reply
10813 .headers
10814 .iter()
10815 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
10816 .count();
10817 assert_eq!(json_ct, 1);
10818 let body = match &reply.body {
10819 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
10820 _ => panic!("expected bytes body"),
10821 };
10822 let parsed: serde_json::Value =
10823 serde_json::from_str(&body).expect("body must be valid JSON");
10824 assert_eq!(parsed["error"], "not_acceptable");
10825 assert_eq!(
10826 parsed["message"],
10827 "accept application/xml, produced application/json"
10828 );
10829 }
10830
10831 #[test]
10832 fn json_error_reply_preserves_empty_message() {
10833 let reply = json_error_reply(400, "bad_request", "".to_string());
10834 assert_eq!(reply.status, 400);
10835 let json_ct = reply
10836 .headers
10837 .iter()
10838 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
10839 .count();
10840 assert_eq!(json_ct, 1);
10841 let body = match &reply.body {
10842 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
10843 _ => panic!("expected bytes body"),
10844 };
10845 let parsed: serde_json::Value =
10846 serde_json::from_str(&body).expect("body must be valid JSON");
10847 assert_eq!(parsed["error"], "bad_request");
10848 assert_eq!(parsed["message"], "");
10849 }
10850
10851 #[test]
10852 fn https_consumer_without_tls_cert_errors() {
10853 let endpoint = HttpEndpoint {
10854 uri: "https://0.0.0.0:8443/api".to_string(),
10855 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
10856 server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
10857 client: reqwest::Client::new(),
10858 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
10859 PINNED_CLIENT_TTL,
10860 PINNED_CLIENT_MAX_ENTRIES,
10861 )),
10862 http_config: HttpConfig::default(),
10863 };
10864 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
10865 let result = endpoint.create_consumer(rt);
10866 assert!(result.is_err(), "expected error for https without tls cert");
10867 if let Err(e) = result {
10868 let msg = e.to_string();
10869 assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
10870 }
10871 }
10872
10873 #[test]
10874 fn http_consumer_with_tls_config_errors() {
10875 let endpoint = HttpEndpoint {
10876 uri: "http://0.0.0.0:8080/api".to_string(),
10877 config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
10878 server_config: HttpServerConfig::from_uri(
10879 "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
10880 )
10881 .unwrap(),
10882 client: reqwest::Client::new(),
10883 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
10884 PINNED_CLIENT_TTL,
10885 PINNED_CLIENT_MAX_ENTRIES,
10886 )),
10887 http_config: HttpConfig::default(),
10888 };
10889 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
10890 let result = endpoint.create_consumer(rt);
10891 assert!(result.is_err(), "expected error for http with tls config");
10892 if let Err(e) = result {
10893 let msg = e.to_string();
10894 assert!(msg.contains("https"), "error must mention https: {msg}");
10895 }
10896 }
10897
10898 #[test]
10899 fn https_consumer_with_partial_tls_cert_only_errors() {
10900 let server_config =
10903 HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
10904 assert!(
10905 server_config.tls_config.is_none(),
10906 "partial tlsCert must not create ServerTlsConfig"
10907 );
10908 let endpoint = HttpEndpoint {
10909 uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
10910 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
10911 .unwrap(),
10912 server_config,
10913 client: reqwest::Client::new(),
10914 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
10915 PINNED_CLIENT_TTL,
10916 PINNED_CLIENT_MAX_ENTRIES,
10917 )),
10918 http_config: HttpConfig::default(),
10919 };
10920 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
10921 let result = endpoint.create_consumer(rt);
10922 assert!(
10923 result.is_err(),
10924 "must error: https:// requires both tlsCert and tlsKey"
10925 );
10926 }
10927
10928 #[test]
10929 fn load_tls_config_parses_valid_pem() {
10930 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
10932 use camel_component_api::test_support::tls;
10933 let (_, cert_pem, key_pem) = tls::gen_server_cert();
10934 let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
10935 let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
10936
10937 let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
10938 assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
10939 }
10940
10941 #[tokio::test(flavor = "multi_thread")]
10942 #[allow(clippy::await_holding_lock)]
10943 async fn consumer_tls_handshake_roundtrip() {
10944 use camel_component_api::test_support::tls;
10945 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
10946
10947 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
10949
10950 let _guard = lock_registry_test_mutex();
10952
10953 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
10955 let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
10956 let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
10957 let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
10958
10959 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10961 let port = probe.local_addr().unwrap().port();
10962 drop(probe);
10963
10964 ServerRegistry::reset();
10965
10966 let component = HttpComponent::new();
10968 let endpoint_ctx = NoOpComponentContext;
10969 let uri = format!(
10970 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
10971 cert_path.to_string_lossy(),
10972 key_path.to_string_lossy(),
10973 );
10974 let endpoint = component
10975 .create_endpoint(&uri, &endpoint_ctx)
10976 .expect("create TLS endpoint");
10977 let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
10978
10979 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
10981 let token = tokio_util::sync::CancellationToken::new();
10982 let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
10983 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10984
10985 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
10987
10988 let ca_bytes = std::fs::read(&ca_path).unwrap();
10990 let client = reqwest::Client::builder()
10991 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
10992 .build()
10993 .unwrap();
10994
10995 let send_fut = client
10996 .post(format!("https://localhost:{port}/test"))
10997 .body("ping")
10998 .send();
10999
11000 let (http_result, _) = tokio::join!(send_fut, async {
11002 if let Some(mut envelope) = rx.recv().await {
11003 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
11004 if let Some(reply_tx) = envelope.reply_tx {
11005 let _ = reply_tx.send(Ok(envelope.exchange));
11006 }
11007 }
11008 });
11009
11010 let resp = http_result.expect("TLS handshake + request must succeed");
11011
11012 assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
11013 let body = resp.text().await.unwrap();
11014 assert_eq!(body, "pong");
11015
11016 token.cancel();
11017 }
11018
11019 #[tokio::test(flavor = "multi_thread")]
11020 #[allow(clippy::await_holding_lock)]
11021 async fn consumer_tls_rejects_client_without_ca() {
11022 use camel_component_api::test_support::tls;
11023 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
11024
11025 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
11026
11027 let _guard = lock_registry_test_mutex();
11029
11030 let (_, cert_pem, key_pem) = tls::gen_server_cert();
11031 let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
11032 let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
11033
11034 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11035 let port = probe.local_addr().unwrap().port();
11036 drop(probe);
11037
11038 ServerRegistry::reset();
11039
11040 let component = HttpComponent::new();
11042 let endpoint_ctx = NoOpComponentContext;
11043 let uri = format!(
11044 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
11045 cert_path.to_string_lossy(),
11046 key_path.to_string_lossy(),
11047 );
11048 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
11049 let mut consumer = endpoint.create_consumer(rt()).unwrap();
11050 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
11051 let token = tokio_util::sync::CancellationToken::new();
11052 let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
11053 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
11054
11055 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
11056
11057 let client = reqwest::Client::builder().build().unwrap();
11059
11060 let result = client
11061 .get(format!("https://localhost:{port}/test"))
11062 .send()
11063 .await;
11064
11065 assert!(
11066 result.is_err(),
11067 "must reject without CA — proves real verification"
11068 );
11069
11070 token.cancel();
11071 }
11072
11073 #[test]
11074 fn server_config_partial_tls_cert_without_key() {
11075 let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
11077 assert!(cfg.tls_config.is_none());
11079 }
11080
11081 #[test]
11082 fn endpoint_uri_options_count_parity() {
11083 assert_eq!(
11085 HttpEndpointConfig::uri_options().len(),
11086 22,
11087 "HttpEndpointUriConfig #[uri_param] count drifted from parser"
11088 );
11089 }
11090
11091 fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
11092 pairs
11093 .iter()
11094 .map(|(k, v)| {
11095 (
11096 (*k).to_string(),
11097 serde_json::Value::String((*v).to_string()),
11098 )
11099 })
11100 .collect()
11101 }
11102
11103 #[test]
11104 fn response_emits_cache_control_via_pragma_warning() {
11105 let headers = make_headers(&[
11106 ("Cache-Control", "public, max-age=3600"),
11107 ("Via", "1.1 myproxy"),
11108 ("Pragma", "no-cache"),
11109 ("Warning", "199 misc"),
11110 ]);
11111 let selected = select_response_headers(&headers, None, None);
11112 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
11113 for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
11114 assert!(
11115 names.contains(&expected),
11116 "{expected} should pass through to the response"
11117 );
11118 }
11119 }
11120
11121 #[test]
11122 fn response_excludes_request_only_and_server_owned() {
11123 let headers = make_headers(&[
11124 ("User-Agent", "x"),
11125 ("Accept", "*/*"),
11126 ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
11127 ]);
11128 let selected = select_response_headers(&headers, None, None);
11129 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
11130 for excluded in ["User-Agent", "Accept", "Date"] {
11131 assert!(
11132 !names.contains(&excluded),
11133 "{excluded} should NOT appear in the response"
11134 );
11135 }
11136 }
11137
11138 #[test]
11139 fn response_re_derives_content_type() {
11140 let headers = make_headers(&[("Content-Type", "text/plain")]);
11141 let selected = select_response_headers(&headers, Some("application/json".into()), None);
11142 let ct_entries: Vec<&str> = selected
11143 .iter()
11144 .filter(|(k, _)| k == "Content-Type")
11145 .map(|(_, v)| v.as_str())
11146 .collect();
11147 assert_eq!(
11148 ct_entries,
11149 ["application/json"],
11150 "exactly one Content-Type entry, re-derived from user_content_type"
11151 );
11152 }
11153
11154 #[test]
11155 fn response_excludes_camel_headers() {
11156 let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
11157 let selected = select_response_headers(&headers, None, None);
11158 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
11159 assert!(
11160 !names.contains(&"CamelHttpPath"),
11161 "Camel-namespace headers must be excluded"
11162 );
11163 assert!(
11164 names.contains(&"Cache-Control"),
11165 "Cache-Control must pass through"
11166 );
11167 }
11168
11169 #[test]
11170 fn response_stringifies_scalar_header_values() {
11171 let mut headers = make_headers(&[("X-Label", "keep")]);
11172 headers.insert("X-Retries".to_string(), serde_json::json!(3));
11173 headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
11174 headers.insert("X-Enabled".to_string(), serde_json::json!(true));
11175 let selected = select_response_headers(&headers, None, None);
11176 let get = |name: &str| -> Option<&str> {
11177 selected
11178 .iter()
11179 .find(|(k, _)| k == name)
11180 .map(|(_, v)| v.as_str())
11181 };
11182 assert_eq!(
11183 get("X-Retries"),
11184 Some("3"),
11185 "integer header must be stringified"
11186 );
11187 assert_eq!(
11188 get("X-Ratio"),
11189 Some("3.5"),
11190 "float header must be stringified"
11191 );
11192 assert_eq!(
11193 get("X-Enabled"),
11194 Some("true"),
11195 "bool header must be stringified"
11196 );
11197 assert_eq!(
11198 get("X-Label"),
11199 Some("keep"),
11200 "string header must pass through"
11201 );
11202 }
11203
11204 #[test]
11205 fn response_drops_null_and_structured_header_values() {
11206 let mut headers = make_headers(&[("X-Keep", "yes")]);
11207 headers.insert("X-Null".to_string(), serde_json::Value::Null);
11208 headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
11209 headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
11210 let selected = select_response_headers(&headers, None, None);
11211 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
11212 for dropped in ["X-Null", "X-Obj", "X-Arr"] {
11213 assert!(
11214 !names.contains(&dropped),
11215 "{dropped} must not be emitted: no single-value form"
11216 );
11217 }
11218 assert!(names.contains(&"X-Keep"), "scalar headers must survive");
11219 }
11220
11221 #[test]
11222 fn response_stringifies_scalars_despite_excluded_names() {
11223 let mut headers = HashMap::new();
11227 headers.insert("Content-Length".to_string(), serde_json::json!(999));
11228 headers.insert("Date".to_string(), serde_json::json!(12345));
11229 let selected = select_response_headers(&headers, None, None);
11230 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
11231 assert!(
11232 !names.contains(&"Content-Length"),
11233 "content-length is re-derived by the server"
11234 );
11235 assert!(!names.contains(&"Date"), "date is server-owned");
11236 }
11237
11238 #[test]
11239 fn outbound_stringifies_scalar_header_values() {
11240 let mut headers = make_headers(&[("X-Label", "keep")]);
11241 headers.insert("X-Retries".to_string(), serde_json::json!(3));
11242 headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
11243 headers.insert("X-Enabled".to_string(), serde_json::json!(true));
11244 let outbound = select_outbound_headers(&headers, &[], &[]);
11245 let get = |name: &str| -> Option<String> {
11247 outbound
11248 .accepted
11249 .iter()
11250 .find(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
11251 .map(|(_, v)| v.to_str().unwrap().to_string())
11252 };
11253 assert_eq!(
11254 get("X-Retries").as_deref(),
11255 Some("3"),
11256 "integer header must be stringified"
11257 );
11258 assert_eq!(
11259 get("X-Ratio").as_deref(),
11260 Some("3.5"),
11261 "float header must be stringified"
11262 );
11263 assert_eq!(
11264 get("X-Enabled").as_deref(),
11265 Some("true"),
11266 "bool header must be stringified"
11267 );
11268 assert_eq!(
11269 get("X-Label").as_deref(),
11270 Some("keep"),
11271 "string header must pass through"
11272 );
11273 assert!(outbound.drops.is_empty(), "scalar headers must not drop");
11274 }
11275
11276 #[test]
11277 fn outbound_drops_null_and_structured_header_values() {
11278 let mut headers = make_headers(&[("X-Keep", "yes")]);
11279 headers.insert("X-Null".to_string(), serde_json::Value::Null);
11280 headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
11281 headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
11282 let outbound = select_outbound_headers(&headers, &[], &[]);
11283 let has = |name: &str| {
11284 outbound
11285 .accepted
11286 .iter()
11287 .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
11288 };
11289 assert!(has("X-Keep"), "scalar headers must survive");
11290 for (name, kind) in [("X-Null", "null"), ("X-Obj", "object"), ("X-Arr", "array")] {
11291 let dropped = outbound
11292 .drops
11293 .iter()
11294 .find(|d| d.name == name)
11295 .unwrap_or_else(|| panic!("{name} must have a drop record: {:?}", outbound.drops));
11296 assert_eq!(
11297 dropped.reason, "no scalar string form",
11298 "{name} drop reason must name the value kind absence"
11299 );
11300 assert_eq!(dropped.value_kind, Some(kind), "{name} kind recorded");
11301 }
11302 }
11303
11304 #[test]
11305 fn outbound_stringifies_scalars_despite_excluded_names() {
11306 let mut headers = HashMap::new();
11310 headers.insert("Transfer-Encoding".to_string(), serde_json::json!(7));
11311 headers.insert("Host".to_string(), serde_json::json!(12345));
11312 headers.insert("X-Ok".to_string(), serde_json::json!(7));
11313 let outbound = select_outbound_headers(&headers, &[], &[]);
11314 let has = |name: &str| {
11315 outbound
11316 .accepted
11317 .iter()
11318 .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
11319 };
11320 assert!(
11321 !has("Transfer-Encoding"),
11322 "hop-by-hop header must stay excluded"
11323 );
11324 assert!(!has("Host"), "host is destination-derived");
11325 assert!(has("X-Ok"), "non-excluded scalar must be stringified");
11326 assert!(
11327 outbound
11328 .drops
11329 .iter()
11330 .any(|d| d.name == "Transfer-Encoding" && d.reason == "outbound emission policy"),
11331 "policy drop must be recorded before coercion"
11332 );
11333 }
11334
11335 #[test]
11336 fn outbound_drops_invalid_names_values_and_skip_config() {
11337 let mut headers = make_headers(&[("X-Good", "fine")]);
11338 headers.insert("X Bad Name".to_string(), serde_json::json!("v"));
11339 headers.insert(
11340 "X-Control-Value".to_string(),
11341 serde_json::json!("line1\nline2"),
11342 );
11343 headers.insert("X-Secret".to_string(), serde_json::json!("s3cr3t"));
11344 headers.insert("CamelHttpQuery".to_string(), serde_json::json!("q=1"));
11345 let skip = vec!["x-secret".to_string()];
11346 let outbound = select_outbound_headers(&headers, &skip, &[]);
11347 let has = |name: &str| {
11348 outbound
11349 .accepted
11350 .iter()
11351 .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
11352 };
11353 assert!(has("X-Good"), "valid header must survive");
11354 assert!(!has("X Bad Name"), "invalid header name must drop");
11355 assert!(!has("X-Control-Value"), "control-char value must drop");
11356 assert!(!has("X-Secret"), "skipped header must drop");
11357 assert!(!has("CamelHttpQuery"), "Camel-namespace header must drop");
11358 let reason = |n: &str| {
11359 outbound
11360 .drops
11361 .iter()
11362 .find(|d| d.name == n)
11363 .map(|d| d.reason)
11364 };
11365 assert_eq!(reason("X Bad Name"), Some("invalid header name"));
11366 assert_eq!(reason("X-Control-Value"), Some("invalid header value"));
11367 assert_eq!(reason("X-Secret"), Some("skip_request_headers"));
11368 assert_eq!(reason("CamelHttpQuery"), Some("Camel namespace"));
11369 }
11370
11371 #[test]
11372 fn constructed_header_invalid_value_returns_drop_record() {
11373 let result = constructed_header("user-agent", "bad\r\ns3nt1nel");
11374 let Err(record) = result else {
11375 panic!("invalid value must produce a drop record");
11376 };
11377 assert_eq!(record.reason, "invalid header value");
11378 assert_eq!(record.name, "user-agent");
11379 assert!(record.value_kind.is_none());
11380 let debug = format!("{record:?}");
11381 assert!(
11382 !debug.contains("bad\r\n") && !debug.contains("s3nt1nel"),
11383 "drop record debug must not leak the value"
11384 );
11385 }
11386
11387 #[test]
11388 fn constructed_header_invalid_name_returns_drop_record() {
11389 let result = constructed_header("bad name", "ok");
11390 let Err(record) = result else {
11391 panic!("invalid name must produce a drop record");
11392 };
11393 assert_eq!(record.reason, "invalid header name");
11394 assert_eq!(record.name, "bad name");
11395 let debug = format!("{record:?}");
11396 assert!(
11397 !debug.contains("ok"),
11398 "drop record debug must not leak the value"
11399 );
11400 }
11401
11402 #[test]
11403 fn constructed_header_valid_pair_roundtrip() {
11404 let result = constructed_header("authorization", "Bearer abc123");
11405 let Ok((name, val)) = result else {
11406 panic!("valid pair must construct");
11407 };
11408 assert_eq!(name.as_str(), "authorization");
11409 let Ok(roundtrip) = val.to_str() else {
11410 panic!("valid value must roundtrip to str");
11411 };
11412 assert_eq!(roundtrip, "Bearer abc123");
11413 }
11414
11415 async fn start_host_capturing_destination() -> (
11427 String,
11428 Arc<std::sync::Mutex<Option<(String, String)>>>,
11429 tokio::task::JoinHandle<()>,
11430 ) {
11431 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11432 let port = listener.local_addr().unwrap().port();
11433 let url = format!("http://127.0.0.1:{port}");
11434 let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
11435 Arc::new(std::sync::Mutex::new(None));
11436 let captured_clone = Arc::clone(&captured);
11437 let handle = tokio::spawn(async move {
11438 use tokio::io::{AsyncReadExt, AsyncWriteExt};
11439 if let Ok((mut stream, _)) = listener.accept().await {
11440 let mut buf = vec![0u8; 16384];
11441 let n = stream.read(&mut buf).await.unwrap_or(0);
11442 let request = String::from_utf8_lossy(&buf[..n]).to_string();
11443 if request.contains("\r\n\r\n") {
11444 let request_line = request.lines().next().unwrap_or("").to_string();
11445 let host_value = request
11446 .lines()
11447 .find(|l| l.to_lowercase().starts_with("host:"))
11448 .and_then(|l| l.split_once(':'))
11449 .map(|(_, v)| v.trim().to_string())
11450 .unwrap_or_default();
11451 *captured_clone.lock().unwrap() = Some((host_value, request_line));
11452 }
11453 let body = r#"{"echo":"ok"}"#;
11454 let resp = format!(
11455 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
11456 body.len(),
11457 body
11458 );
11459 let _ = stream.write_all(resp.as_bytes()).await;
11460 }
11461 });
11462 (url, captured, handle)
11463 }
11464
11465 #[tokio::test]
11470 async fn bridge_proxy_outbound_host_matches_destination() {
11471 use tower::ServiceExt;
11472
11473 let (url, captured, _handle) = start_host_capturing_destination().await;
11474 let expected_host = url.strip_prefix("http://").unwrap();
11477
11478 let ctx = test_producer_ctx();
11479 let component = HttpComponent::new();
11480 let endpoint_ctx = NoOpComponentContext;
11481 let endpoint = component
11482 .create_endpoint(
11483 &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
11484 &endpoint_ctx,
11485 )
11486 .unwrap();
11487 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
11488
11489 let mut exchange = Exchange::new(Message::default());
11492 exchange.input.set_header("Host", "localhost");
11493 exchange.input.set_header("CamelHttpPath", "/foo");
11494
11495 let result = producer.oneshot(exchange).await;
11496 assert!(result.is_ok(), "producer call failed: {:?}", result);
11497
11498 tokio::time::sleep(Duration::from_millis(100)).await;
11499 let (host_value, request_line) = captured
11500 .lock()
11501 .unwrap()
11502 .take()
11503 .expect("destination capture mutex empty — producer did not reach the destination");
11504
11505 assert_ne!(
11506 host_value, "localhost",
11507 "bridge producer must not forward the exchange Host: localhost"
11508 );
11509 assert_eq!(
11510 host_value, expected_host,
11511 "Host must be derived from the destination authority (no scheme)"
11512 );
11513 assert!(
11514 !request_line.contains("/foo"),
11515 "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
11516 );
11517 }
11518
11519 #[tokio::test]
11524 async fn bridge_proxy_route_set_response_header_survives() {
11525 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
11526
11527 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11528 let port = listener.local_addr().unwrap().port();
11529 drop(listener);
11530
11531 let component = HttpComponent::new();
11532 let endpoint_ctx = NoOpComponentContext;
11533 let endpoint = component
11534 .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
11535 .unwrap();
11536 let mut consumer = endpoint.create_consumer(rt()).unwrap();
11537
11538 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
11539 let token = tokio_util::sync::CancellationToken::new();
11540 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
11541
11542 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
11543 tokio::time::sleep(Duration::from_millis(50)).await;
11544
11545 let client = reqwest::Client::new();
11546 let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
11547
11548 let (http_result, _) = tokio::join!(send_fut, async {
11552 if let Some(mut envelope) = rx.recv().await {
11553 envelope
11554 .exchange
11555 .input
11556 .set_header("Cache-Control", "public, max-age=3600");
11557 if let Some(reply_tx) = envelope.reply_tx {
11558 let _ = reply_tx.send(Ok(envelope.exchange));
11559 }
11560 }
11561 });
11562
11563 let resp = http_result.unwrap();
11564 assert_eq!(resp.status().as_u16(), 200);
11565
11566 let cache_control = resp.headers().get("cache-control");
11567 assert!(
11568 cache_control.is_some(),
11569 "Cache-Control header must survive to the wire response"
11570 );
11571 assert_eq!(
11572 cache_control.unwrap().to_str().unwrap(),
11573 "public, max-age=3600"
11574 );
11575
11576 token.cancel();
11577 }
11578
11579 use camel_api::security_policy::CredentialSource;
11598 use camel_auth::credential_source::extract_token_from_exchange;
11599 use camel_auth::native_auth::NativeCredentialStore;
11600 use camel_auth::{StaticTokenAuthenticator, TokenAuthenticator};
11601
11602 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 {
11610 let mut msg = Message::default();
11611 msg.set_header(
11612 "CamelHttpMethod",
11613 serde_json::Value::String(envelope.method.clone()),
11614 );
11615 msg.set_header(
11616 "CamelHttpPath",
11617 serde_json::Value::String(envelope.path.clone()),
11618 );
11619 msg.set_header(
11620 "CamelHttpQuery",
11621 serde_json::Value::String(envelope.query.clone()),
11622 );
11623 for (k, v) in &envelope.headers {
11624 if let Ok(val_str) = v.to_str() {
11625 msg.set_header(
11626 title_case_header(k.as_str()),
11627 serde_json::Value::String(val_str.to_string()),
11628 );
11629 }
11630 }
11631 Exchange::new(msg)
11632 }
11633
11634 async fn spawn_failing_auth_route(
11641 registry: &HttpRouteRegistry,
11642 path: &str,
11643 sources: Vec<CredentialSource>,
11644 ) {
11645 let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
11646 NativeCredentialStore::try_new(vec![]).unwrap(),
11647 ));
11648 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11649 registry.register_api_route(path.to_string(), tx).await;
11650 let path_owned = path.to_string();
11651 tokio::spawn(async move {
11652 while let Some(envelope) = rx.recv().await {
11653 let exchange = envelope_to_exchange(&envelope);
11654 let reply_tx = envelope.reply_tx;
11655 let result: Result<(), CamelError> = async {
11656 let token = extract_token_from_exchange(&exchange, &sources)
11657 .map(|extracted| extracted.token)
11658 .ok_or_else(|| {
11659 CamelError::Unauthenticated("no credential in any source".into())
11660 })?;
11661 authenticator.authenticate_bearer(&token).await?;
11662 Ok(())
11663 }
11664 .await;
11665 let reply = match result {
11666 Ok(()) => HttpReply {
11667 status: 200,
11668 headers: vec![],
11669 body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
11670 },
11671 Err(e) => pipeline_error_to_reply(e, &path_owned),
11672 };
11673 let _ = reply_tx.send(reply);
11674 }
11675 });
11676 }
11677
11678 fn captured_logs_contain(needle: &str) -> bool {
11682 let buf = tracing_test::internal::global_buf().lock().unwrap();
11683 String::from_utf8_lossy(&buf).contains(needle)
11684 }
11685
11686 #[tracing_test::traced_test]
11687 #[tokio::test]
11688 async fn error_context_redacts_query_sentinel() {
11689 let (port, registry) = spawn_test_server().await;
11690 spawn_failing_auth_route(
11691 ®istry,
11692 "/secure-query",
11693 vec![CredentialSource::QueryParam {
11694 param: "token".to_string(),
11695 }],
11696 )
11697 .await;
11698
11699 let client = reqwest::Client::new();
11700 let resp = client
11701 .get(format!(
11703 "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
11704 ))
11705 .send()
11706 .await
11707 .unwrap();
11708
11709 assert_eq!(resp.status().as_u16(), 401);
11710 let body = resp.text().await.unwrap();
11711 assert_eq!(body, "Unauthorized");
11712 assert!(
11713 !body.contains(SENTINEL_QRY_42),
11714 "reply body must not contain the query credential"
11715 );
11716 assert!(
11717 !captured_logs_contain(SENTINEL_QRY_42),
11718 "no tracing record during request handling may render the query credential"
11719 );
11720 assert!(
11724 captured_logs_contain("Authentication failed"),
11725 "positive control: the failed-auth warn! must be captured by the test subscriber"
11726 );
11727 }
11728
11729 #[tracing_test::traced_test]
11730 #[tokio::test]
11731 async fn error_context_redacts_cookie_sentinel() {
11732 let (port, registry) = spawn_test_server().await;
11733 spawn_failing_auth_route(
11734 ®istry,
11735 "/secure-cookie",
11736 vec![CredentialSource::Cookie {
11737 name: "session".to_string(),
11738 }],
11739 )
11740 .await;
11741
11742 let client = reqwest::Client::new();
11743 let resp = client
11744 .get(format!("http://127.0.0.1:{port}/secure-cookie"))
11745 .header("Cookie", format!("session={SENTINEL_CKY_7}"))
11746 .send()
11747 .await
11748 .unwrap();
11749
11750 assert_eq!(resp.status().as_u16(), 401);
11751 let body = resp.text().await.unwrap();
11752 assert_eq!(body, "Unauthorized");
11753 assert!(
11754 !body.contains(SENTINEL_CKY_7),
11755 "reply body must not contain the cookie credential"
11756 );
11757 assert!(
11758 !captured_logs_contain(SENTINEL_CKY_7),
11759 "no tracing record during request handling may render the cookie credential"
11760 );
11761 }
11762
11763 #[tracing_test::traced_test]
11764 #[tokio::test]
11765 async fn error_reply_no_credential_value() {
11766 let (port, registry) = spawn_test_server().await;
11767 spawn_failing_auth_route(
11768 ®istry,
11769 "/secure-bad",
11770 vec![CredentialSource::Cookie {
11771 name: "session".to_string(),
11772 }],
11773 )
11774 .await;
11775
11776 let client = reqwest::Client::new();
11777 let resp = client
11778 .get(format!("http://127.0.0.1:{port}/secure-bad"))
11779 .header("Cookie", format!("session={SENTINEL_BAD_1}"))
11780 .send()
11781 .await
11782 .unwrap();
11783
11784 assert_eq!(resp.status().as_u16(), 401);
11785 let body = resp.text().await.unwrap();
11786 assert_eq!(body, "Unauthorized");
11787 assert!(
11788 !body.contains(SENTINEL_BAD_1),
11789 "reply body must not contain the credential value"
11790 );
11791 assert!(
11792 !captured_logs_contain(SENTINEL_BAD_1),
11793 "error logs must not render the credential value"
11794 );
11795 }
11796
11797 async fn spawn_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
11811 use tokio::io::AsyncWriteExt;
11812
11813 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
11814 .await
11815 .expect("bind ephemeral 127.0.0.1 listener");
11816 let port = listener.local_addr().expect("local addr").port();
11817 let base_url = format!("http://localhost:{port}");
11818 let handle = tokio::spawn(async move {
11819 while let Ok((mut conn, _)) = listener.accept().await {
11820 let _ = conn
11821 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
11822 .await;
11823 let _ = conn.shutdown().await;
11824 }
11825 });
11826 (base_url, handle)
11827 }
11828
11829 async fn spawn_tls_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
11836 use tokio::io::AsyncWriteExt;
11837
11838 let (_ca_pem, cert_pem, key_pem) =
11839 camel_component_api::test_support::tls::gen_server_cert();
11840 let certs: Vec<_> = rustls_pemfile::certs(&mut cert_pem.as_bytes())
11841 .collect::<Result<_, _>>()
11842 .expect("parse server cert pem");
11843 let key = rustls_pemfile::private_key(&mut key_pem.as_bytes())
11844 .expect("parse server key pem")
11845 .expect("server key present");
11846 let provider =
11849 std::sync::Arc::new(tokio_rustls::rustls::crypto::aws_lc_rs::default_provider());
11850 let tls_cfg = tokio_rustls::rustls::ServerConfig::builder_with_provider(provider)
11851 .with_safe_default_protocol_versions()
11852 .expect("safe default protocol versions")
11853 .with_no_client_auth()
11854 .with_single_cert(certs, key)
11855 .expect("build rustls server config");
11856 let acceptor = tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(tls_cfg));
11857
11858 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
11859 .await
11860 .expect("bind ephemeral 127.0.0.1 listener");
11861 let port = listener.local_addr().expect("local addr").port();
11862 let base_url = format!("https://localhost:{port}");
11863 let handle = tokio::spawn(async move {
11864 while let Ok((conn, _)) = listener.accept().await {
11865 let acceptor = acceptor.clone();
11866 tokio::spawn(async move {
11867 if let Ok(mut tls) = acceptor.accept(conn).await {
11868 let _ = tls
11869 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
11870 .await;
11871 let _ = tls.shutdown().await;
11872 }
11873 });
11874 }
11875 });
11876 (base_url, handle)
11877 }
11878
11879 fn responder_port(base_url: &str) -> u16 {
11883 url::Url::parse(base_url)
11884 .expect("responder base URL parses")
11885 .port()
11886 .expect("responder base URL carries an explicit port")
11887 }
11888
11889 fn endpoint_with_shared_cache(
11894 base_url: &str,
11895 pinned_cache: &Arc<PinnedClientCache>,
11896 ) -> HttpEndpoint {
11897 let uri = format!("{base_url}?allowInternal=true");
11898 HttpEndpoint {
11899 uri: uri.clone(),
11900 config: HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses"),
11901 server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
11902 client: reqwest::Client::new(),
11903 pinned_cache: Arc::clone(pinned_cache),
11904 http_config: HttpConfig::default(),
11905 }
11906 }
11907
11908 #[tokio::test]
11909 async fn producers_share_endpoint_cache() {
11910 use tower::ServiceExt;
11911
11912 let (base_url, _handle) = spawn_multi_accept_200().await;
11913 let pinned_cache = Arc::new(PinnedClientCache::new(
11914 PINNED_CLIENT_TTL,
11915 PINNED_CLIENT_MAX_ENTRIES,
11916 ));
11917
11918 let ctx = test_producer_ctx();
11919 let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
11920 let producer_a = endpoint.create_producer(rt(), &ctx);
11921 let producer_b = endpoint.create_producer(rt(), &ctx);
11922
11923 for producer in [producer_a, producer_b] {
11926 let producer = producer.expect("create producer");
11927 let exchange = Exchange::new(Message::default());
11928 let reply = producer.oneshot(exchange).await;
11929 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
11930 }
11931
11932 assert_eq!(
11933 pinned_cache.build_count(),
11934 1,
11935 "both producers must hit the same shared cache entry; a second \
11936 build means sharing is broken"
11937 );
11938 }
11939
11940 #[tokio::test]
11941 async fn producer_repeated_hostname_requests_build_one_client() {
11942 use tower::ServiceExt;
11943
11944 let (base_url, _handle) = spawn_multi_accept_200().await;
11945 let pinned_cache = Arc::new(PinnedClientCache::new(
11946 PINNED_CLIENT_TTL,
11947 PINNED_CLIENT_MAX_ENTRIES,
11948 ));
11949 let ctx = test_producer_ctx();
11950 let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
11951 let producer = endpoint
11952 .create_producer(rt(), &ctx)
11953 .expect("create producer");
11954
11955 for i in 0..2 {
11958 let exchange = Exchange::new(Message::default());
11959 let reply = producer.clone().oneshot(exchange).await;
11960 assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
11961 }
11962
11963 assert_eq!(
11964 pinned_cache.build_count(),
11965 1,
11966 "repeated hostname requests must reuse the one pinned client; \
11967 0 builds means the producer bypassed the cache, more than 1 \
11968 means the entry was dropped"
11969 );
11970 }
11971
11972 #[tokio::test]
11973 async fn ip_literal_request_never_enters_cache() {
11974 use tower::ServiceExt;
11975
11976 let (base_url, _handle) = spawn_multi_accept_200().await;
11977 let pinned_cache = Arc::new(PinnedClientCache::new(
11978 PINNED_CLIENT_TTL,
11979 PINNED_CLIENT_MAX_ENTRIES,
11980 ));
11981
11982 let ctx = test_producer_ctx();
11983 let destination = format!("http://127.0.0.1:{}/ping", responder_port(&base_url));
11984 let endpoint = endpoint_with_shared_cache(&destination, &pinned_cache);
11985 let producer = endpoint
11986 .create_producer(rt(), &ctx)
11987 .expect("create producer");
11988
11989 let exchange = Exchange::new(Message::default());
11990 let reply = producer.oneshot(exchange).await;
11991 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
11992
11993 assert_eq!(
11994 pinned_cache.build_count(),
11995 0,
11996 "an IP-literal URL must use the shared unpinned client and \
11997 never enter the pinned cache"
11998 );
11999 }
12000
12001 #[tokio::test]
12002 async fn test_component_endpoints_share_pinned_cache() {
12003 use tower::ServiceExt;
12004
12005 let component = HttpComponent::new();
12006 let (base_url, _handle) = spawn_multi_accept_200().await;
12007 let baseline = component.pinned_cache.build_count();
12008
12009 let ctx = test_producer_ctx();
12010 let endpoint_ctx = NoOpComponentContext;
12011 for uri in [
12012 format!("{base_url}/a?allowInternal=true&k=a"),
12013 format!("{base_url}/b?allowInternal=true&k=b"),
12014 ] {
12015 let endpoint = component
12016 .create_endpoint(&uri, &endpoint_ctx)
12017 .expect("create endpoint");
12018 let producer = endpoint
12019 .create_producer(rt(), &ctx)
12020 .expect("create producer");
12021 let exchange = Exchange::new(Message::default());
12022 let reply = producer.oneshot(exchange).await;
12023 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
12024 }
12025
12026 assert_eq!(
12027 component.pinned_cache.build_count() - baseline,
12028 1,
12029 "endpoints created by one component must share its pinned cache; \
12030 0 builds means the endpoints bypassed it, more than 1 means \
12031 per-endpoint caches came back"
12032 );
12033 }
12034
12035 #[tokio::test]
12036 async fn test_dynamic_resolution_sequence_hits_shared_cache() {
12037 use tower::ServiceExt;
12038
12039 let component = HttpComponent::new();
12040 let (base_url, _handle) = spawn_multi_accept_200().await;
12041 let baseline = component.pinned_cache.build_count();
12042
12043 let ctx = test_producer_ctx();
12044 let endpoint_ctx = NoOpComponentContext;
12045 for i in 0..3 {
12046 let endpoint = component
12047 .create_endpoint(
12048 &format!("{base_url}/r{i}?allowInternal=true&k={i}"),
12049 &endpoint_ctx,
12050 )
12051 .expect("create endpoint");
12052 let producer = endpoint
12053 .create_producer(rt(), &ctx)
12054 .expect("create producer");
12055 let exchange = Exchange::new(Message::default());
12056 let reply = producer.oneshot(exchange).await;
12057 assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
12058 }
12059
12060 assert_eq!(
12061 component.pinned_cache.build_count() - baseline,
12062 1,
12063 "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
12064 must reuse the component's one pinned cache entry; 0 builds \
12065 means the endpoints bypassed it, more than 1 means \
12066 per-endpoint caches came back"
12067 );
12068 }
12069
12070 #[tokio::test]
12078 async fn test_https_component_endpoints_share_pinned_cache_behaviorally() {
12079 use tower::ServiceExt;
12080
12081 let http_config = HttpConfig {
12082 tls: Some(crate::config::TlsConfig {
12083 enabled: true,
12084 insecure: true,
12085 ..Default::default()
12086 }),
12087 ..Default::default()
12088 };
12089 let component = HttpsComponent::with_config(http_config);
12090 let (base_url, _handle) = spawn_tls_multi_accept_200().await;
12091 let baseline = component.pinned_cache.build_count();
12092
12093 let ctx = test_producer_ctx();
12094 let endpoint_ctx = NoOpComponentContext;
12095 for uri in [
12096 format!("{base_url}/a?allowInternal=true&k=a"),
12097 format!("{base_url}/b?allowInternal=true&k=b"),
12098 ] {
12099 let endpoint = component
12100 .create_endpoint(&uri, &endpoint_ctx)
12101 .expect("create https endpoint");
12102 let producer = endpoint
12103 .create_producer(rt(), &ctx)
12104 .expect("create producer");
12105 let exchange = Exchange::new(Message::default());
12106 let reply = producer.oneshot(exchange).await;
12107 assert!(reply.is_ok(), "https request failed: {reply:?}");
12108 }
12109
12110 assert_eq!(
12111 component.pinned_cache.build_count() - baseline,
12112 1,
12113 "endpoints of one HttpsComponent must share its pinned cache over \
12114 real https requests; 0 builds means the endpoints bypassed it \
12115 (per-endpoint cache regression), more than 1 means \
12116 per-endpoint caches came back"
12117 );
12118 }
12119
12120 #[test]
12121 fn test_https_component_owns_distinct_cache() {
12122 let http = HttpComponent::new();
12123 let https = HttpsComponent::new();
12124
12125 assert!(
12126 !Arc::ptr_eq(&http.pinned_cache, &https.pinned_cache),
12127 "http and https components must each own their own pinned cache"
12128 );
12129
12130 let endpoint_ctx = NoOpComponentContext;
12131 let _ = http
12132 .create_endpoint("http://localhost:1/?allowInternal=true", &endpoint_ctx)
12133 .expect("http endpoint");
12134 let _ = https
12135 .create_endpoint("https://localhost:1/?allowInternal=true", &endpoint_ctx)
12136 .expect("https endpoint");
12137
12138 assert_eq!(
12139 http.pinned_cache.build_count(),
12140 0,
12141 "endpoint creation must not build a pinned client"
12142 );
12143 assert_eq!(
12144 https.pinned_cache.build_count(),
12145 0,
12146 "endpoint creation must not build a pinned client"
12147 );
12148 }
12149
12150 #[test]
12151 fn test_component_constructor_builds_one_unpinned_client() {
12152 let baseline = build_client_call_count();
12153
12154 let _http = HttpComponent::new();
12155 assert_eq!(
12156 build_client_call_count() - baseline,
12157 1,
12158 "HttpComponent::new() must build exactly one shared unpinned client"
12159 );
12160
12161 let _https = HttpsComponent::new();
12162 assert_eq!(
12163 build_client_call_count() - baseline,
12164 2,
12165 "HttpsComponent::new() must build exactly one more shared unpinned client"
12166 );
12167 }
12168
12169 #[test]
12170 fn test_component_endpoints_share_unpinned_client() {
12171 let component = HttpComponent::new();
12172 let baseline = build_client_call_count();
12173
12174 let endpoint_ctx = NoOpComponentContext;
12175 for uri in [
12176 "http://localhost:1/a?allowInternal=true",
12177 "http://localhost:1/b?allowInternal=true",
12178 ] {
12179 let _endpoint = component
12180 .create_endpoint(uri, &endpoint_ctx)
12181 .expect("create endpoint");
12182 }
12183
12184 assert_eq!(
12185 build_client_call_count() - baseline,
12186 0,
12187 "create_endpoint must clone the component's shared unpinned client, \
12188 never build a fresh one"
12189 );
12190 }
12191
12192 #[test]
12193 fn test_dynamic_resolution_adds_no_unpinned_client_builds() {
12194 let component = HttpComponent::new();
12195 let baseline = build_client_call_count();
12196
12197 let ctx = test_producer_ctx();
12198 let endpoint_ctx = NoOpComponentContext;
12199 for i in 0..3 {
12200 let endpoint = component
12201 .create_endpoint(
12202 &format!("http://localhost:1/r{i}?allowInternal=true&k={i}"),
12203 &endpoint_ctx,
12204 )
12205 .expect("create endpoint");
12206 let _producer = endpoint
12207 .create_producer(rt(), &ctx)
12208 .expect("create producer");
12209 }
12210
12211 assert_eq!(
12212 build_client_call_count() - baseline,
12213 0,
12214 "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
12215 must reuse the component's shared unpinned client and build \
12216 no additional clients"
12217 );
12218 }
12219}