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 {
160 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161 f.debug_struct("HttpEndpointConfig")
162 .field("base_url", &camel_api::redact::redact_url(&self.base_url))
163 .field("http_method", &self.http_method)
164 .field(
165 "throw_exception_on_failure",
166 &self.throw_exception_on_failure,
167 )
168 .field("ok_status_code_range", &self.ok_status_code_range)
169 .field("response_timeout", &self.response_timeout)
170 .field(
171 "query_params",
172 &self
173 .query_params
174 .iter()
175 .map(|(key, _)| (key, "***"))
176 .collect::<Vec<_>>(),
177 )
178 .field("raw_query", &self.raw_query.as_ref().map(|_| "?[redacted]"))
179 .field("allow_internal", &self.allow_internal)
180 .field("blocked_hosts", &self.blocked_hosts)
181 .field("max_body_size", &self.max_body_size)
182 .field("read_timeout_ms", &self.read_timeout_ms)
183 .field("max_response_bytes", &self.max_response_bytes)
184 .field("auth", &self.auth)
185 .field("token_provider", &self.token_provider)
186 .field("user_agent", &self.user_agent)
187 .field("bridge_endpoint", &self.bridge_endpoint)
188 .field("connection_close", &self.connection_close)
189 .field("skip_request_headers", &self.skip_request_headers)
190 .field("skip_response_headers", &self.skip_response_headers)
191 .field("follow_redirects", &self.follow_redirects)
192 .field("max_redirects", &self.max_redirects)
193 .field("allowed_uri_hosts", &self.allowed_uri_hosts)
194 .finish()
195 }
196}
197
198#[derive(Clone, PartialEq)]
199pub enum HttpAuth {
200 None,
201 Basic { username: String, password: String },
202 Bearer { token: String },
203}
204
205impl std::fmt::Debug for HttpAuth {
206 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207 match self {
208 HttpAuth::None => f.write_str("None"),
209 HttpAuth::Basic { username, .. } => f
210 .debug_struct("Basic")
211 .field("username", username)
212 .field("password", &"***")
213 .finish(),
214 HttpAuth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
215 }
216 }
217}
218
219fn is_consumed_option(key: &str) -> bool {
228 HttpEndpointConfig::uri_options()
229 .iter()
230 .any(|option| option.name == key || option.aliases.iter().any(|alias| alias == key))
231}
232
233impl UriConfig for HttpEndpointConfig {
234 fn scheme() -> &'static str {
236 "http"
237 }
238
239 fn from_uri(uri: &str) -> Result<Self, CamelError> {
240 let parts = parse_uri(uri)?;
241 Self::from_components(parts)
242 }
243
244 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
245 if parts.scheme != "http" && parts.scheme != "https" {
247 return Err(CamelError::InvalidUri(format!(
248 "expected scheme 'http' or 'https', got '{}'",
249 parts.scheme
250 )));
251 }
252
253 let base_url = format!("{}:{}", parts.scheme, parts.path);
256
257 let http_method = parts.params.get("httpMethod").cloned();
258
259 let throw_exception_on_failure = match parts.params.get("throwExceptionOnFailure") {
260 Some(v) => parse_bool_param_http(v).map_err(|e| {
261 CamelError::InvalidUri(format!("invalid value for throwExceptionOnFailure: {e}"))
262 })?,
263 None => true,
264 };
265
266 let ok_status_code_range = match parts.params.get("okStatusCodeRange") {
268 Some(v) => parse_ok_status_code_range(v)?,
269 None => (200, 299),
270 };
271
272 let response_timeout = match parts.params.get("responseTimeout") {
273 Some(v) => Some(v.parse::<u64>().map(Duration::from_millis).map_err(|e| {
274 CamelError::InvalidUri(format!("invalid value for responseTimeout: {e}"))
275 })?),
276 None => None,
277 };
278
279 let allow_internal = match parts.params.get("allowInternal") {
281 Some(v) => parse_bool_param_http(v).map_err(|e| {
282 CamelError::InvalidUri(format!("invalid value for allowInternal: {e}"))
283 })?,
284 None => false, };
286
287 let blocked_hosts = parts
289 .params
290 .get("blockedHosts")
291 .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
292 .unwrap_or_default();
293
294 let max_body_size = match parts.params.get("maxBodySize") {
295 Some(v) => v.parse::<usize>().map_err(|e| {
296 CamelError::InvalidUri(format!("invalid value for maxBodySize: {e}"))
297 })?,
298 None => 10 * 1024 * 1024, };
300
301 let read_timeout_ms = match parts.params.get("readTimeout") {
302 Some(v) => v.parse::<u64>().map_err(|e| {
303 CamelError::InvalidUri(format!("invalid value for readTimeout: {e}"))
304 })?,
305 None => 30_000, };
307
308 let max_response_bytes = match parts.params.get("maxResponseBytes") {
309 Some(v) => v.parse::<usize>().map_err(|e| {
310 CamelError::InvalidUri(format!("invalid value for maxResponseBytes: {e}"))
311 })?,
312 None => 10 * 1024 * 1024, };
314
315 let auth = parse_auth_from_params(&parts.params)?;
316
317 let user_agent = parts.params.get("userAgent").cloned();
318
319 if parts.params.contains_key("cookieHandling") {
320 return Err(CamelError::InvalidUri(
321 "cookieHandling is not supported".into(),
322 ));
323 }
324
325 let bridge_endpoint = match parts.params.get("bridgeEndpoint") {
326 Some(v) => parse_bool_param_http(v).map_err(|e| {
327 CamelError::InvalidUri(format!("invalid value for bridgeEndpoint: {e}"))
328 })?,
329 None => false,
330 };
331
332 let connection_close = match parts.params.get("connectionClose") {
333 Some(v) => parse_bool_param_http(v).map_err(|e| {
334 CamelError::InvalidUri(format!("invalid value for connectionClose: {e}"))
335 })?,
336 None => false,
337 };
338
339 let skip_request_headers = parts
340 .params
341 .get("skipRequestHeaders")
342 .map(|v| {
343 v.split(',')
344 .map(str::trim)
345 .filter(|s| !s.is_empty())
346 .map(|s| s.to_ascii_lowercase())
347 .collect::<Vec<_>>()
348 })
349 .unwrap_or_default();
350
351 let skip_response_headers = parts
352 .params
353 .get("skipResponseHeaders")
354 .map(|v| {
355 v.split(',')
356 .map(str::trim)
357 .filter(|s| !s.is_empty())
358 .map(|s| s.to_ascii_lowercase())
359 .collect::<Vec<_>>()
360 })
361 .unwrap_or_default();
362
363 let follow_redirects = match parts.params.get("followRedirects") {
364 Some(v) => parse_bool_param_http(v).map_err(|e| {
365 CamelError::InvalidUri(format!("invalid value for followRedirects: {e}"))
366 })?,
367 None => false,
368 };
369
370 let max_redirects = match parts.params.get("maxRedirects") {
371 Some(v) => v.parse::<usize>().map_err(|e| {
372 CamelError::InvalidUri(format!("invalid value for maxRedirects: {e}"))
373 })?,
374 None => 10,
375 };
376
377 let allowed_uri_hosts = match parts.params.get("allowedUriHosts") {
380 Some(v) => Some(parse_allowed_uri_hosts(v)?),
381 None => None,
382 };
383
384 let raw_query = parts.raw_query.clone();
389
390 Ok(Self {
391 base_url,
392 http_method,
393 throw_exception_on_failure,
394 ok_status_code_range,
395 response_timeout,
396 query_params: Vec::new(),
397 raw_query,
398 allow_internal,
399 blocked_hosts,
400 max_body_size,
401 read_timeout_ms,
402 max_response_bytes,
403 auth,
404 token_provider: None,
405 user_agent,
406 bridge_endpoint,
407 connection_close,
408 skip_request_headers,
409 skip_response_headers,
410 follow_redirects,
411 max_redirects,
412 allowed_uri_hosts,
413 })
414 }
415}
416
417#[derive(Debug, Clone, UriConfig)]
424#[allow(dead_code)]
425#[uri_scheme = "http"]
426#[uri_config(
427 skip_impl,
428 metadata(
429 scheme = "http",
430 description = "HTTP client and server component",
431 producer,
432 consumer,
433 streaming
434 ),
435 crate = "camel_component_api"
436)]
437struct HttpEndpointUriConfig {
438 #[allow(dead_code)]
439 _base_url: String,
440
441 #[uri_param(
442 name = "httpMethod",
443 desc = "HTTP method. Defaults to CamelHttpMethod header or POST/GET"
444 )]
445 http_method: Option<String>,
446
447 #[uri_param(
448 name = "throwExceptionOnFailure",
449 default = "true",
450 desc = "Throw on non-2xx status"
451 )]
452 throw_exception_on_failure: bool,
453
454 #[uri_param(
455 name = "okStatusCodeRange",
456 default = "200-299",
457 desc = "Success status code range"
458 )]
459 ok_status_code_range: String,
460
461 #[uri_param(name = "responseTimeout", desc = "Response timeout in milliseconds")]
462 response_timeout: Option<u64>,
463
464 #[uri_param(
465 name = "connectTimeout",
466 desc = "Connection timeout in milliseconds (consumed option; effective timeout comes from the global http config)"
467 )]
468 connect_timeout: Option<u64>,
469
470 #[uri_param(
471 name = "allowInternal",
472 default = "false",
473 desc = "Allow private/internal network destinations (SSRF)"
474 )]
475 allow_internal: bool,
476
477 #[uri_param(name = "blockedHosts", desc = "Comma-separated blocked host list")]
478 blocked_hosts: Option<String>,
479
480 #[uri_param(
481 name = "maxBodySize",
482 default = "10485760",
483 desc = "Max request/response body bytes"
484 )]
485 max_body_size: u64,
486
487 #[uri_param(name = "readTimeout", desc = "Socket read timeout in milliseconds")]
488 read_timeout: Option<u64>,
489
490 #[uri_param(name = "maxResponseBytes", desc = "Max response body bytes")]
491 max_response_bytes: Option<u64>,
492
493 #[uri_param(
494 name = "authMethod",
495 kind = "enum:Basic,Bearer",
496 desc = "Authentication method"
497 )]
498 auth_method: Option<String>,
499
500 #[uri_param(name = "authUsername", secret, desc = "Basic auth username")]
501 auth_username: Option<String>,
502
503 #[uri_param(name = "authPassword", secret, desc = "Basic auth password")]
504 auth_password: Option<String>,
505
506 #[uri_param(name = "authBearerToken", secret, desc = "Bearer auth token")]
507 auth_bearer_token: Option<String>,
508
509 #[uri_param(name = "userAgent", desc = "User-Agent header")]
510 user_agent: Option<String>,
511
512 #[uri_param(
513 name = "bridgeEndpoint",
514 default = "false",
515 desc = "Bridge endpoint mode"
516 )]
517 bridge_endpoint: bool,
518
519 #[uri_param(
520 name = "connectionClose",
521 default = "false",
522 desc = "Send Connection: close"
523 )]
524 connection_close: bool,
525
526 #[uri_param(
527 name = "skipRequestHeaders",
528 desc = "Comma-separated request headers to skip"
529 )]
530 skip_request_headers: Option<String>,
531
532 #[uri_param(
533 name = "skipResponseHeaders",
534 desc = "Comma-separated response headers to skip"
535 )]
536 skip_response_headers: Option<String>,
537
538 #[uri_param(
539 name = "followRedirects",
540 default = "false",
541 desc = "Follow HTTP redirects"
542 )]
543 follow_redirects: bool,
544
545 #[uri_param(name = "maxRedirects", default = "10", desc = "Max redirect hops")]
546 max_redirects: u64,
547
548 #[uri_param(
549 name = "allowedUriHosts",
550 desc = "Comma-separated allowlist of CamelHttpUri override hosts (host or host:port)"
551 )]
552 allowed_uri_hosts: Option<String>,
553}
554
555impl HttpEndpointConfig {
556 pub fn metadata() -> ComponentMetadata {
559 HttpEndpointUriConfig::metadata()
560 }
561
562 pub fn uri_options() -> Vec<camel_api::component_metadata::UriOption> {
564 HttpEndpointUriConfig::uri_options()
565 }
566}
567
568fn parse_auth_from_params(params: &HashMap<String, String>) -> Result<HttpAuth, CamelError> {
569 let Some(method) = params.get("authMethod") else {
570 return Ok(HttpAuth::None);
571 };
572
573 if method.eq_ignore_ascii_case("none") {
574 return Ok(HttpAuth::None);
575 }
576
577 if method.eq_ignore_ascii_case("basic") {
578 let username = params.get("authUsername").cloned().ok_or_else(|| {
579 CamelError::InvalidUri("authUsername is required for authMethod=Basic".to_string())
580 })?;
581 let password = params.get("authPassword").cloned().ok_or_else(|| {
582 CamelError::InvalidUri("authPassword is required for authMethod=Basic".to_string())
583 })?;
584 return Ok(HttpAuth::Basic { username, password });
585 }
586
587 if method.eq_ignore_ascii_case("bearer") {
588 let token = params.get("authBearerToken").cloned().ok_or_else(|| {
589 CamelError::InvalidUri("authBearerToken is required for authMethod=Bearer".to_string())
590 })?;
591 return Ok(HttpAuth::Bearer { token });
592 }
593
594 Err(CamelError::InvalidUri(format!(
595 "invalid value for authMethod: {method} (expected None, Basic, or Bearer)"
596 )))
597}
598
599fn parse_bool_param_http(value: &str) -> Result<bool, CamelError> {
600 match value.to_ascii_lowercase().as_str() {
601 "true" | "1" | "yes" => Ok(true),
602 "false" | "0" | "no" => Ok(false),
603 _ => Err(CamelError::InvalidUri(format!(
604 "invalid boolean value: '{value}'"
605 ))),
606 }
607}
608
609impl HttpEndpointConfig {
610 pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
611 let parts = parse_uri(uri)?;
612 let mut endpoint = Self::from_components(parts.clone())?;
613 if endpoint.response_timeout.is_none() {
614 endpoint.response_timeout = Some(Duration::from_millis(config.response_timeout_ms));
615 }
616 if !parts.params.contains_key("allowInternal") {
617 endpoint.allow_internal = config.allow_internal;
618 }
619 if !parts.params.contains_key("blockedHosts") {
620 endpoint.blocked_hosts = config.blocked_hosts.clone();
621 }
622 if !parts.params.contains_key("maxBodySize") {
623 endpoint.max_body_size = config.max_body_size;
624 }
625 if !parts.params.contains_key("readTimeout") {
626 endpoint.read_timeout_ms = config.read_timeout_ms;
627 }
628 if !parts.params.contains_key("maxResponseBytes") {
629 endpoint.max_response_bytes = config.max_response_bytes;
630 }
631 if !parts.params.contains_key("okStatusCodeRange")
632 && let Some(range) = &config.ok_status_code_range
633 {
634 endpoint.ok_status_code_range = parse_ok_status_code_range(range)?;
635 }
636 if !parts.params.contains_key("followRedirects") {
637 endpoint.follow_redirects = config.follow_redirects;
638 }
639 if !parts.params.contains_key("maxRedirects") {
640 endpoint.max_redirects = config.max_redirects.unwrap_or(10);
641 }
642
643 Ok(endpoint)
644 }
645}
646
647#[derive(Debug, Clone)]
653pub struct HttpServerConfig {
654 pub scheme: String,
656 pub host: String,
658 pub port: u16,
660 pub path: String,
662 pub max_request_body: usize,
664 pub max_response_body: usize,
666 pub max_inflight_requests: usize,
668 pub method: Option<String>,
675 pub tls_config: Option<crate::config::ServerTlsConfig>,
678}
679
680impl UriConfig for HttpServerConfig {
681 fn scheme() -> &'static str {
683 "http"
684 }
685
686 fn from_uri(uri: &str) -> Result<Self, CamelError> {
687 let parts = parse_uri(uri)?;
688 Self::from_components(parts)
689 }
690
691 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
692 if parts.scheme != "http" && parts.scheme != "https" {
694 return Err(CamelError::InvalidUri(format!(
695 "expected scheme 'http' or 'https', got '{}'",
696 parts.scheme
697 )));
698 }
699
700 let authority_and_path = parts.path.trim_start_matches('/');
703
704 let (authority, path_suffix) = if let Some(idx) = authority_and_path.find('/') {
706 (&authority_and_path[..idx], &authority_and_path[idx..])
707 } else {
708 (authority_and_path, "/")
709 };
710
711 let path = if path_suffix.is_empty() {
712 "/"
713 } else {
714 path_suffix
715 }
716 .to_string();
717
718 let (host, port) = if let Some(colon) = authority.rfind(':') {
720 let port_str = &authority[colon + 1..];
721 match port_str.parse::<u16>() {
722 Ok(p) => (authority[..colon].to_string(), p),
723 Err(_) => {
724 return Err(CamelError::InvalidUri(format!(
725 "invalid port '{}' in authority",
726 port_str
727 )));
728 }
729 }
730 } else {
731 let default_port = if parts.scheme == "https" { 443 } else { 80 };
733 (authority.to_string(), default_port)
734 };
735
736 let max_request_body = parts
737 .params
738 .get("maxRequestBody")
739 .and_then(|v| v.parse::<usize>().ok())
740 .unwrap_or(2 * 1024 * 1024); let max_response_body = parts
743 .params
744 .get("maxResponseBody")
745 .and_then(|v| v.parse::<usize>().ok())
746 .unwrap_or(10 * 1024 * 1024); let max_inflight_requests = parts
749 .params
750 .get("maxInflightRequests")
751 .and_then(|v| v.parse::<usize>().ok())
752 .unwrap_or(1024);
753
754 let method = parts.params.get("httpMethod").map(|m| m.to_uppercase());
760
761 Ok(Self {
762 scheme: parts.scheme,
763 host,
764 port,
765 path,
766 max_request_body,
767 max_response_body,
768 max_inflight_requests,
769 method,
770 tls_config: {
771 let cert = parts.params.get("tlsCert").cloned();
772 let key = parts.params.get("tlsKey").cloned();
773 match (cert, key) {
774 (Some(c), Some(k)) => Some(crate::config::ServerTlsConfig {
775 cert_path: c,
776 key_path: k,
777 }),
778 (None, None) => None,
779 _ => None, }
781 },
782 })
783 }
784}
785
786impl HttpServerConfig {
787 pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
788 let parts = parse_uri(uri)?;
789 let mut server = Self::from_components(parts.clone())?;
790 if !parts.params.contains_key("maxRequestBody") {
791 server.max_request_body = config.max_request_body;
792 }
793 if !parts.params.contains_key("maxResponseBody") {
794 server.max_response_body = config.max_body_size;
796 }
797 Ok(server)
798 }
799}
800
801pub enum HttpReplyBody {
809 Bytes(bytes::Bytes),
810 Stream(BoxStream<'static, Result<bytes::Bytes, CamelError>>),
811}
812
813pub struct RequestEnvelope {
818 pub method: String,
819 pub path: String,
820 pub query: String,
821 pub headers: http::HeaderMap,
822 pub body: StreamBody,
823 pub path_params: std::collections::HashMap<String, String>,
829 pub reply_tx: tokio::sync::oneshot::Sender<HttpReply>,
830}
831
832pub struct HttpReply {
836 pub status: u16,
837 pub headers: Vec<(String, String)>,
838 pub body: HttpReplyBody,
839}
840
841type ServerKey = (String, u16);
846
847struct ServerHandle {
849 registry: HttpRouteRegistry,
850 bound_addr: std::net::SocketAddr,
853 max_request_body: usize,
854 max_response_body: usize,
855 max_inflight_requests: usize,
856 is_tls: bool,
857 tls_cert_path: Option<String>,
858 tls_key_path: Option<String>,
859 monitor_task: tokio::task::JoinHandle<()>,
862 #[allow(dead_code)]
868 server_abort: tokio::task::AbortHandle,
869 tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
872 tls_source: Option<ServerTlsSource>,
873}
874
875#[derive(Default)]
878struct RegistryState {
879 entries: HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>,
880 staged: HashMap<ServerKey, tokio::net::TcpListener>,
881}
882
883pub struct ServerRegistry {
885 inner: Mutex<RegistryState>,
886}
887
888impl ServerRegistry {
889 pub fn global() -> &'static Self {
891 static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
892 INSTANCE.get_or_init(|| ServerRegistry {
893 inner: Mutex::new(RegistryState::default()),
894 })
895 }
896
897 #[allow(clippy::too_many_arguments)]
900 pub async fn get_or_spawn(
901 &'static self,
902 host: &str,
903 port: u16,
904 max_request_body: usize,
905 max_response_body: usize,
906 max_inflight_requests: usize,
907 runtime: Arc<dyn RuntimeObservability>,
908 route_id: String,
909 tls_config: Option<crate::config::ServerTlsConfig>,
910 ) -> Result<HttpRouteRegistry, CamelError> {
911 self.get_or_spawn_internal(
912 host,
913 port,
914 max_request_body,
915 max_response_body,
916 max_inflight_requests,
917 runtime,
918 route_id,
919 tls_config,
920 None,
921 )
922 .await
923 }
924
925 #[allow(clippy::too_many_arguments)]
932 pub async fn get_or_spawn_with_listener(
933 &'static self,
934 listener: tokio::net::TcpListener,
935 max_request_body: usize,
936 max_response_body: usize,
937 max_inflight_requests: usize,
938 runtime: Arc<dyn RuntimeObservability>,
939 route_id: String,
940 tls_config: Option<crate::config::ServerTlsConfig>,
941 ) -> Result<HttpRouteRegistry, CamelError> {
942 let addr = listener
943 .local_addr()
944 .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
945 self.get_or_spawn_internal(
946 &addr.ip().to_string(),
947 addr.port(),
948 max_request_body,
949 max_response_body,
950 max_inflight_requests,
951 runtime,
952 route_id,
953 tls_config,
954 Some(listener),
955 )
956 .await
957 }
958
959 pub async fn stage_listener(
966 &'static self,
967 listener: tokio::net::TcpListener,
968 ) -> Result<(), CamelError> {
969 let addr = listener
970 .local_addr()
971 .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
972 let host = addr.ip().to_string();
973 use std::collections::hash_map::Entry;
974 let mut guard = self.inner.lock().map_err(|_| {
975 CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
976 })?;
977 match guard.staged.entry((host.clone(), addr.port())) {
978 Entry::Occupied(_) => Err(CamelError::EndpointCreationFailed(format!(
979 "listener already staged for {host}:{}",
980 addr.port()
981 ))),
982 Entry::Vacant(slot) => {
983 slot.insert(listener);
984 Ok(())
985 }
986 }
987 }
988
989 pub fn bound_addr(&'static self, host: &str, port: u16) -> Option<std::net::SocketAddr> {
992 let guard = self.inner.lock().ok()?;
993 guard
994 .entries
995 .get(&(host.to_string(), port))
996 .and_then(|cell| cell.get())
997 .map(|handle| handle.bound_addr)
998 }
999
1000 #[allow(clippy::too_many_arguments)]
1001 async fn get_or_spawn_internal(
1002 &'static self,
1003 host: &str,
1004 port: u16,
1005 max_request_body: usize,
1006 max_response_body: usize,
1007 max_inflight_requests: usize,
1008 runtime: Arc<dyn RuntimeObservability>,
1009 route_id: String,
1010 tls_config: Option<crate::config::ServerTlsConfig>,
1011 provided: Option<tokio::net::TcpListener>,
1012 ) -> Result<HttpRouteRegistry, CamelError> {
1013 let host_owned = host.to_string();
1014 let key = (host.to_string(), port);
1015
1016 let cell = {
1017 let mut guard = self.inner.lock().map_err(|_| {
1018 CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
1019 })?;
1020 if let Some(existing) = guard.entries.get(&key)
1024 && let Some(handle) = existing.get()
1025 && handle.monitor_task.is_finished()
1026 {
1027 if handle.is_tls {
1030 let scheme = if handle.is_tls { "https" } else { "http" };
1031 camel_component_api::tls_source::TlsReloadRegistry::global()
1032 .unregister(scheme, host, port);
1033 }
1034 guard.entries.remove(&key);
1035 }
1036 guard
1037 .entries
1038 .entry(key)
1039 .or_insert_with(|| Arc::new(OnceCell::new()))
1040 .clone()
1041 };
1042
1043 if let Some(existing) = cell.get()
1044 && existing.max_request_body != max_request_body
1045 {
1046 return Err(CamelError::EndpointCreationFailed(format!(
1047 "incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
1048 existing.max_request_body, max_request_body
1049 )));
1050 }
1051
1052 if let Some(existing) = cell.get()
1053 && existing.max_response_body != max_response_body
1054 {
1055 return Err(CamelError::EndpointCreationFailed(format!(
1056 "incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
1057 existing.max_response_body, max_response_body
1058 )));
1059 }
1060
1061 if let Some(existing) = cell.get()
1062 && existing.max_inflight_requests != max_inflight_requests
1063 {
1064 return Err(CamelError::EndpointCreationFailed(format!(
1065 "incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
1066 existing.max_inflight_requests, max_inflight_requests
1067 )));
1068 }
1069
1070 if let Some(existing) = cell.get()
1072 && existing.is_tls != tls_config.is_some()
1073 {
1074 return Err(CamelError::EndpointCreationFailed(format!(
1075 "incompatible TLS mode for shared server (host={host}, port={port}): existing is_tls={}, new has_tls={}",
1076 existing.is_tls,
1077 tls_config.is_some()
1078 )));
1079 }
1080
1081 if let (Some(existing), Some(new_tls)) = (cell.get(), &tls_config)
1083 && (existing.tls_cert_path.as_deref() != Some(&new_tls.cert_path)
1084 || existing.tls_key_path.as_deref() != Some(&new_tls.key_path))
1085 {
1086 return Err(CamelError::EndpointCreationFailed(format!(
1087 "incompatible TLS cert/key for shared server (host={host}, port={port}): routes on the same TLS port must use the same cert and key"
1088 )));
1089 }
1090
1091 let handle = cell
1092 .get_or_try_init(|| {
1093 let rt = Arc::clone(&runtime);
1094 let rid = route_id.clone();
1095 let key = (host_owned.clone(), port);
1096 async move {
1097 let source = match provided {
1106 Some(listener) => ListenerSource::Staged(listener),
1107 None => {
1108 let mut guard = self.inner.lock().map_err(|_| {
1109 CamelError::EndpointCreationFailed(
1110 "ServerRegistry lock poisoned".into(),
1111 )
1112 })?;
1113 match guard.staged.remove(&key) {
1114 Some(listener) => ListenerSource::Staged(listener),
1115 None => {
1119 if let Some((staged_host, _)) = guard
1120 .staged
1121 .keys()
1122 .find(|(_, staged_port)| *staged_port == port)
1123 {
1124 let staged_host = staged_host.clone();
1125 return Err(CamelError::EndpointCreationFailed(
1126 format!(
1127 "staged listener conflict on port {port}: staged under host {staged_host}, requested {host_owned}"
1128 ),
1129 ));
1130 }
1131 ListenerSource::Bind
1132 }
1133 }
1134 }
1135 };
1136 spawn_entry(
1137 key,
1138 source,
1139 max_request_body,
1140 max_response_body,
1141 max_inflight_requests,
1142 rt,
1143 rid,
1144 tls_config,
1145 )
1146 .await
1147 .and_then(|handle| {
1148 Arc::try_unwrap(handle).map_err(|_| {
1152 CamelError::EndpointCreationFailed(
1153 "spawned server handle has dangling clones".into(),
1154 )
1155 })
1156 })
1157 }
1158 })
1159 .await?;
1160
1161 Ok(handle.registry.clone())
1162 }
1163
1164 pub async fn unregister(&self, host: &str, port: u16) {
1168 debug!(
1169 host = host,
1170 port = port,
1171 "consumer unregistered from HTTP server"
1172 );
1173 }
1174
1175 #[cfg(test)]
1182 pub fn reset() {
1183 let instance = Self::global();
1184 let mut guard = instance
1185 .inner
1186 .lock()
1187 .expect("ServerRegistry lock poisoned during test reset");
1188 guard.entries.clear();
1189 guard.staged.clear();
1190 }
1191}
1192
1193enum ListenerSource {
1196 Bind,
1197 Staged(tokio::net::TcpListener),
1198}
1199
1200#[allow(clippy::too_many_arguments)]
1205async fn spawn_entry(
1206 key: ServerKey,
1207 source: ListenerSource,
1208 max_request_body: usize,
1209 max_response_body: usize,
1210 max_inflight_requests: usize,
1211 runtime: Arc<dyn RuntimeObservability>,
1212 route_id: String,
1213 tls_config: Option<crate::config::ServerTlsConfig>,
1214) -> Result<Arc<ServerHandle>, CamelError> {
1215 let rt = Arc::clone(&runtime);
1216 let rid = route_id.clone();
1217 let (host_owned, port) = key;
1218 let listener = match source {
1219 ListenerSource::Bind => {
1220 let addr = format!("{host_owned}:{port}");
1221 tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
1222 CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
1223 })?
1224 }
1225 ListenerSource::Staged(listener) => listener,
1226 };
1227 let bound_addr = listener
1228 .local_addr()
1229 .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
1230 let server_exited = tokio_util::sync::CancellationToken::new();
1231 let registry = HttpRouteRegistry::new_with_server_exited(server_exited.clone());
1232 let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
1233 let tls_rustls_cfg: Option<axum_server::tls_rustls::RustlsConfig>;
1236 let tls_source: Option<ServerTlsSource>;
1237 let server_task = if let Some(ref tls) = tls_config {
1238 let rustls_config = load_tls_config(&tls.cert_path, &tls.key_path)?;
1239 let source = ServerTlsSource {
1240 cert_path: std::path::PathBuf::from(&tls.cert_path),
1241 key_path: std::path::PathBuf::from(&tls.key_path),
1242 client_ca_path: None,
1243 };
1244 let rustls_cfg =
1248 axum_server::tls_rustls::RustlsConfig::from_config(std::sync::Arc::new(rustls_config));
1249 tls_rustls_cfg = Some(rustls_cfg.clone());
1250 tls_source = Some(source);
1251 let std_listener = listener.into_std().map_err(|e| {
1253 CamelError::EndpointCreationFailed(format!("TLS listener conversion: {e}"))
1254 })?;
1255 tokio::spawn(run_axum_server_tls(
1256 std_listener,
1257 rustls_cfg,
1258 registry.clone(),
1259 max_request_body,
1260 max_response_body,
1261 Arc::clone(&inflight),
1262 Arc::clone(&rt),
1263 rid.clone(),
1264 ))
1265 } else {
1266 tls_rustls_cfg = None;
1267 tls_source = None;
1268 tokio::spawn(run_axum_server(
1269 listener,
1270 registry.clone(),
1271 max_request_body,
1272 max_response_body,
1273 Arc::clone(&inflight),
1274 Arc::clone(&rt),
1275 rid.clone(),
1276 ))
1277 };
1278 let addr_for_monitor = format!("{host_owned}:{port}");
1279 let server_abort = server_task.abort_handle();
1280 let monitor_task = tokio::spawn(monitor_axum_task(
1281 server_task,
1282 addr_for_monitor,
1283 Arc::clone(&rt),
1284 rid,
1285 server_exited,
1286 ));
1287 let handle = ServerHandle {
1288 registry,
1289 bound_addr,
1290 max_request_body,
1291 max_response_body,
1292 max_inflight_requests,
1293 is_tls: tls_config.is_some(),
1294 tls_cert_path: tls_config.as_ref().map(|t| t.cert_path.clone()),
1295 tls_key_path: tls_config.as_ref().map(|t| t.key_path.clone()),
1296 monitor_task,
1297 server_abort,
1298 tls_config: tls_rustls_cfg,
1299 tls_source,
1300 };
1301 if let (Some(tls_cfg), Some(source)) = (handle.tls_config.as_ref(), handle.tls_source.as_ref())
1306 {
1307 let handler = Arc::new(crate::tls_reload::HttpReloadHandler::new(
1308 tls_cfg.clone(),
1309 source.clone(),
1310 host_owned.clone(),
1311 port,
1312 ));
1313 camel_component_api::tls_source::TlsReloadRegistry::global().register(handler);
1314 }
1315 Ok(Arc::new(handle))
1316}
1317
1318use axum::{
1323 Router,
1324 body::Body as AxumBody,
1325 extract::{Request, State},
1326 http::{Response, StatusCode},
1327 response::IntoResponse,
1328};
1329
1330#[derive(Clone)]
1331pub(crate) struct AppState {
1332 registry: HttpRouteRegistry,
1333 max_request_body: usize,
1334 max_response_body: usize,
1335 inflight: Arc<tokio::sync::Semaphore>,
1336}
1337
1338const CONSUMER_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
1345
1346async fn run_axum_server(
1347 listener: tokio::net::TcpListener,
1348 registry: HttpRouteRegistry,
1349 max_request_body: usize,
1350 max_response_body: usize,
1351 inflight: Arc<tokio::sync::Semaphore>,
1352 runtime: Arc<dyn RuntimeObservability>,
1353 route_id: String,
1354) {
1355 let state = AppState {
1356 registry,
1357 max_request_body,
1358 max_response_body,
1359 inflight,
1360 };
1361 let app = Router::new()
1362 .fallback(dispatch_handler)
1363 .with_state(state)
1364 .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1365 StatusCode::REQUEST_TIMEOUT,
1366 CONSUMER_REQUEST_TIMEOUT,
1367 ));
1368
1369 axum::serve(listener, app).await.unwrap_or_else(|e| {
1370 runtime
1371 .metrics()
1372 .increment_errors(&route_id, "e:http:accept");
1373 tracing::error!(error = %e, "Axum server error");
1375 });
1376}
1377
1378#[allow(clippy::too_many_arguments)]
1379async fn run_axum_server_tls(
1380 listener: std::net::TcpListener,
1381 tls_cfg: axum_server::tls_rustls::RustlsConfig,
1382 registry: HttpRouteRegistry,
1383 max_request_body: usize,
1384 max_response_body: usize,
1385 inflight: Arc<tokio::sync::Semaphore>,
1386 runtime: Arc<dyn RuntimeObservability>,
1387 route_id: String,
1388) {
1389 let state = AppState {
1390 registry,
1391 max_request_body,
1392 max_response_body,
1393 inflight,
1394 };
1395 let app = Router::new()
1396 .fallback(dispatch_handler)
1397 .with_state(state)
1398 .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1399 StatusCode::REQUEST_TIMEOUT,
1400 CONSUMER_REQUEST_TIMEOUT,
1401 ));
1402
1403 let server = match axum_server::from_tcp_rustls(listener, tls_cfg) {
1408 Ok(server) => server,
1409 Err(e) => {
1410 runtime
1411 .metrics()
1412 .increment_errors(&route_id, "e:http:accept-tls");
1413 tracing::error!(error = %e, "Axum TLS server setup error");
1415 return;
1416 }
1417 };
1418
1419 server
1420 .serve(app.into_make_service())
1421 .await
1422 .unwrap_or_else(|e| {
1423 runtime
1424 .metrics()
1425 .increment_errors(&route_id, "e:http:accept-tls");
1426 tracing::error!(error = %e, "Axum TLS server error");
1428 });
1429}
1430
1431async fn monitor_axum_task(
1441 handle: tokio::task::JoinHandle<()>,
1442 addr: String,
1443 runtime: Arc<dyn RuntimeObservability>,
1444 route_id: String,
1445 server_exited: tokio_util::sync::CancellationToken,
1446) {
1447 match handle.await {
1448 Ok(()) => {
1449 }
1451 Err(join_err) => {
1452 runtime
1453 .metrics()
1454 .increment_errors(&route_id, "e:http:server-task-exited");
1455 tracing::error!(
1457 addr = %addr,
1458 error = %join_err,
1459 "Axum server task exited unexpectedly — all routes on this port are now dead"
1460 );
1461 server_exited.cancel();
1465 }
1466 }
1467}
1468
1469fn load_tls_config(
1472 cert_path: &str,
1473 key_path: &str,
1474) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
1475 use std::fs::File;
1476 use std::io::BufReader;
1477
1478 let cert_file = File::open(cert_path)
1479 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
1480 let key_file = File::open(key_path)
1481 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
1482
1483 let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
1484 .collect::<Result<Vec<_>, _>>()
1485 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
1486
1487 let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
1488 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
1489 .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
1490
1491 tokio_rustls::rustls::ServerConfig::builder()
1492 .with_no_client_auth()
1493 .with_single_cert(certs, key)
1494 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
1495}
1496
1497async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
1498 let path = req.uri().path().to_owned();
1499 let method = req.method().to_string();
1500
1501 let api_sender = {
1518 let inner = state.registry.inner.read().await;
1519 inner.api_routes.get(&path).cloned()
1520 }; let (rest_sender, path_params) = if api_sender.is_some() {
1523 (None, Default::default())
1525 } else {
1526 let inner = state.registry.inner.read().await;
1527 match rest_match::match_endpoint(&method, &path, &inner.rest_endpoints) {
1528 rest_match::MatchOutcome::Found(m) => (Some(m.payload), m.path_params),
1529 rest_match::MatchOutcome::Ambiguous => {
1530 tracing::warn!(
1536 method = %method,
1537 path = %path,
1538 "ambiguous REST template match — returning 500"
1539 );
1540 return Response::builder()
1541 .status(StatusCode::INTERNAL_SERVER_ERROR)
1542 .body(AxumBody::from("Internal Server Error"))
1543 .expect("infallible"); }
1545 rest_match::MatchOutcome::NotFound => (None, Default::default()),
1546 }
1547 }; let sender = api_sender.or(rest_sender);
1550
1551 if let Some(sender) = sender {
1552 let query = req.uri().query().unwrap_or("").to_string();
1553 let headers = req.headers().clone();
1554
1555 let content_length: Option<u64> = headers
1557 .get(http::header::CONTENT_LENGTH)
1558 .and_then(|v| v.to_str().ok())
1559 .and_then(|s| s.parse().ok());
1560
1561 if let Some(len) = content_length
1562 && len > state.max_request_body as u64
1563 {
1564 return Response::builder()
1565 .status(StatusCode::PAYLOAD_TOO_LARGE)
1566 .body(AxumBody::from("Request body exceeds configured limit"))
1567 .expect("infallible"); }
1569
1570 let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
1571 Ok(permit) => permit,
1572 Err(_) => {
1573 return Response::builder()
1574 .status(StatusCode::SERVICE_UNAVAILABLE)
1575 .body(AxumBody::from("Service Unavailable"))
1576 .expect("infallible"); }
1578 };
1579
1580 let content_type = headers
1586 .get(http::header::CONTENT_TYPE)
1587 .and_then(|v| v.to_str().ok())
1588 .map(|s| s.to_string());
1589
1590 let data_stream: BodyDataStream = req.into_body().into_data_stream();
1591 let max_body = state.max_request_body;
1592 let mut seen: u64 = 0;
1593 let capped_stream =
1594 data_stream
1595 .map_err(|e| CamelError::Io(e.to_string()))
1596 .map(move |chunk| match chunk {
1597 Ok(bytes) => {
1598 seen = seen.saturating_add(bytes.len() as u64);
1599 if seen > max_body as u64 {
1600 Err(CamelError::ProcessorError(format!(
1601 "Request body exceeds configured limit of {max_body} bytes"
1602 )))
1603 } else {
1604 Ok(bytes)
1605 }
1606 }
1607 Err(e) => Err(e),
1608 });
1609 let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(capped_stream);
1610
1611 let stream_body = StreamBody {
1612 stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
1613 metadata: StreamMetadata {
1614 size_hint: content_length,
1615 content_type,
1616 origin: None,
1617 },
1618 };
1619
1620 let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
1621 let envelope = RequestEnvelope {
1622 method,
1623 path,
1624 query,
1625 headers,
1626 body: stream_body,
1627 path_params,
1628 reply_tx,
1629 };
1630
1631 if sender.send(envelope).await.is_err() {
1632 return Response::builder()
1633 .status(StatusCode::SERVICE_UNAVAILABLE)
1634 .body(AxumBody::from("Consumer unavailable"))
1635 .expect("infallible"); }
1637
1638 match reply_rx.await {
1639 Ok(reply) => {
1640 let reply = match reply.body {
1641 HttpReplyBody::Bytes(b)
1642 if exceeds_max_response_body(b.len(), state.max_response_body) =>
1643 {
1644 HttpReply {
1645 status: 500,
1646 headers: vec![],
1647 body: HttpReplyBody::Bytes(bytes::Bytes::from(
1648 "Response body exceeds configured limit",
1649 )),
1650 }
1651 }
1652 _ => reply,
1653 };
1654
1655 let status =
1656 StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1657 let mut builder = Response::builder().status(status);
1658 for (k, v) in &reply.headers {
1659 builder = builder.header(k.as_str(), v.as_str());
1660 }
1661 match reply.body {
1662 HttpReplyBody::Bytes(b) => {
1663 builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
1664 Response::builder()
1665 .status(StatusCode::INTERNAL_SERVER_ERROR)
1666 .body(AxumBody::from("Invalid response headers from consumer"))
1667 .expect("infallible") })
1669 }
1670 HttpReplyBody::Stream(stream) => builder
1671 .body(AxumBody::from_stream(stream))
1672 .unwrap_or_else(|_| {
1673 Response::builder()
1674 .status(StatusCode::INTERNAL_SERVER_ERROR)
1675 .body(AxumBody::from("Invalid response headers from consumer"))
1676 .expect("infallible") }),
1678 }
1679 }
1680 Err(_) => Response::builder()
1681 .status(StatusCode::INTERNAL_SERVER_ERROR)
1682 .body(AxumBody::from("Pipeline error"))
1683 .expect("infallible"), }
1685 } else {
1686 static_dispatch::dispatch_static(&state, req, &path).await
1688 }
1689}
1690
1691fn exceeds_max_response_body(len: usize, max: usize) -> bool {
1692 len > max
1693}
1694
1695fn title_case_header(name: &str) -> String {
1696 name.split('-')
1697 .map(|part| {
1698 let mut chars = part.chars();
1699 match chars.next() {
1700 None => String::new(),
1701 Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
1702 }
1703 })
1704 .collect::<Vec<_>>()
1705 .join("-")
1706}
1707
1708pub(crate) struct HttpKernelAuth {
1723 pub(crate) plan: camel_api::security_policy::RouteSecurityPlan,
1724 pub(crate) providers: Arc<camel_auth::ProviderRegistry>,
1725}
1726
1727impl HttpKernelAuth {
1728 pub(crate) fn from_security_context(
1733 ctx: &camel_component_api::SecurityContext,
1734 ) -> Option<Self> {
1735 Some(Self {
1736 plan: ctx.plan.clone()?,
1737 providers: ctx.providers.clone()?,
1738 })
1739 }
1740}
1741
1742fn envelope_channel_capacity(max_inflight_requests: usize) -> usize {
1756 max_inflight_requests.max(1)
1757}
1758
1759pub struct HttpConsumer {
1760 config: HttpServerConfig,
1761 runtime: Arc<dyn RuntimeObservability>,
1763 kernel: Option<Arc<HttpKernelAuth>>,
1767}
1768
1769impl HttpConsumer {
1770 pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
1771 Self {
1772 config,
1773 runtime,
1774 kernel: None,
1775 }
1776 }
1777}
1778
1779#[async_trait::async_trait]
1780impl Consumer for HttpConsumer {
1781 async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
1782 use camel_component_api::{Body, Exchange, Message};
1783
1784 let registry = ServerRegistry::global()
1785 .get_or_spawn(
1786 &self.config.host,
1787 self.config.port,
1788 self.config.max_request_body,
1789 self.config.max_response_body,
1790 self.config.max_inflight_requests,
1791 self.runtime.clone(),
1792 ctx.route_id().to_string(),
1793 self.config.tls_config.clone(),
1794 )
1795 .await?;
1796
1797 let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(
1801 envelope_channel_capacity(self.config.max_inflight_requests),
1802 );
1803 if let Some(method) = self.config.method.clone() {
1810 let segments = rest_match::parse_path_template(&self.config.path);
1811 registry
1812 .register_rest_endpoint(method, segments, env_tx)
1813 .await;
1814 } else {
1815 registry
1816 .register_api_route(self.config.path.clone(), env_tx)
1817 .await;
1818 }
1819
1820 ctx.mark_ready();
1829
1830 let path = self.config.path.clone();
1831 let registry_for_cleanup = registry.clone();
1832 let server_exited = registry.server_exited.clone();
1833 let cancel_token = ctx.cancel_token();
1834 let kernel = self.kernel.clone();
1835 let mut server_died = false;
1840 loop {
1841 tokio::select! {
1842 _ = ctx.cancelled() => {
1843 break;
1844 }
1845 _ = server_exited.cancelled() => {
1846 server_died = true;
1851 break;
1852 }
1853 envelope = env_rx.recv() => {
1854 let Some(envelope) = envelope else { break; };
1855
1856 let mut msg = Message::default();
1858
1859 msg.set_header("CamelHttpMethod",
1861 serde_json::Value::String(envelope.method.clone()));
1862 msg.set_header("CamelHttpPath",
1863 serde_json::Value::String(envelope.path.clone()));
1864 msg.set_header("CamelHttpQuery",
1865 serde_json::Value::String(envelope.query.clone()));
1866
1867 for (param_name, param_value) in &envelope.path_params {
1874 msg.set_header(
1875 format!("CamelHttpPath_{param_name}"),
1876 serde_json::Value::String(param_value.clone()),
1877 );
1878 }
1879
1880 for (k, v) in &envelope.headers {
1882 if let Ok(val_str) = v.to_str() {
1883 msg.set_header(
1884 title_case_header(k.as_str()),
1885 serde_json::Value::String(val_str.to_string()),
1886 );
1887 }
1888 }
1889
1890 msg.body = Body::Stream(envelope.body);
1893
1894 #[allow(unused_mut)]
1895 let mut exchange = Exchange::new(msg);
1896
1897 #[cfg(feature = "otel")]
1899 {
1900 let headers: HashMap<String, String> = envelope
1901 .headers
1902 .iter()
1903 .filter_map(|(k, v)| {
1904 Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1905 })
1906 .collect();
1907 camel_otel::extract_into_exchange(&mut exchange, &headers);
1908 }
1909
1910 let reply_tx = envelope.reply_tx;
1911 let sender = ctx.sender().clone();
1912 let path_clone = path.clone();
1913 let cancel = cancel_token.clone();
1914 let auth_headers = envelope.headers.clone();
1918 let auth_uri: http::Uri = {
1919 let full = if envelope.query.is_empty() {
1920 envelope.path.clone()
1921 } else {
1922 format!("{}?{}", envelope.path, envelope.query)
1923 };
1924 full.parse().unwrap_or_default()
1928 };
1929 let kernel = kernel.clone();
1930
1931 tokio::spawn(async move {
1951 if cancel.is_cancelled() {
1959 let _ = reply_tx.send(HttpReply {
1960 status: 503,
1961 headers: vec![],
1962 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
1963 });
1964 return;
1965 }
1966
1967 if let Some(kernel) = kernel.as_ref()
1976 && !matches!(
1977 kernel.plan.access_mode,
1978 camel_api::security_policy::AccessMode::Public
1979 )
1980 {
1981 let principal = match camel_auth::extract_token_multi(
1982 &auth_headers,
1983 &auth_uri,
1984 &kernel.plan.credential_sources,
1985 ) {
1986 Some(extracted) => {
1987 match camel_auth::kernel_authenticate(
1988 &kernel.plan,
1989 &kernel.providers,
1990 &extracted,
1991 )
1992 .await
1993 {
1994 Ok(principal) => principal,
1995 Err(e) => {
1996 tracing::warn!(
1998 path = %path_clone,
1999 error = %e,
2000 "HTTP request authentication failed"
2001 );
2002 let _ = reply_tx.send(pipeline_error_to_reply(
2003 e,
2004 &path_clone,
2005 ));
2006 return;
2007 }
2008 }
2009 }
2010 None => {
2011 tracing::warn!(
2013 path = %path_clone,
2014 "HTTP request rejected: no credential found in any source"
2015 );
2016 let _ = reply_tx.send(pipeline_error_to_reply(
2017 CamelError::Unauthenticated(
2018 "no credential found in any source".to_string(),
2019 ),
2020 &path_clone,
2021 ));
2022 return;
2023 }
2024 };
2025 camel_auth::install_carrier(&mut exchange, &principal);
2026 }
2027
2028 let (tx, rx) = tokio::sync::oneshot::channel();
2030 let envelope = camel_component_api::consumer::ExchangeEnvelope {
2031 exchange,
2032 reply_tx: Some(tx),
2033 };
2034
2035 let result = match sender.send(envelope).await {
2036 Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
2037 Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
2038 }
2039 .and_then(|r| r);
2040
2041 let reply = match result {
2042 Ok(out) => {
2043 let status = out
2044 .input
2045 .header("CamelHttpResponseCode")
2046 .and_then(|v| {
2047 let raw = v.as_u64()
2048 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
2049 let code = raw as u16;
2050 (100..1000).contains(&code).then_some(code)
2051 })
2052 .unwrap_or(200);
2053
2054 let user_content_type = out
2055 .input
2056 .header("Content-Type")
2057 .and_then(|v| v.as_str().map(|s| s.to_string()));
2058
2059 let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
2060 Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
2061 Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
2062 Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
2063 Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
2064 v.to_string().into_bytes(),
2065 )), Some("application/json".to_string())),
2066 Body::Stream(s) => {
2067 let ct = s.metadata.content_type.clone();
2068 match s.stream.lock().await.take() {
2069 Some(stream) => (
2070 HttpReplyBody::Stream(stream),
2071 ct,
2072 ),
2073 None => {
2074 tracing::error!(
2076 "Body::Stream already consumed before HTTP reply — returning 500"
2077 );
2078 let error_reply = HttpReply {
2079 status: 500,
2080 headers: vec![],
2081 body: HttpReplyBody::Bytes(bytes::Bytes::new()),
2082 };
2083 if reply_tx.send(error_reply).is_err() {
2084 debug!("reply_tx dropped before error reply could be sent");
2085 }
2086 return;
2087 }
2088 }
2089 }
2090 _ => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
2092 };
2093
2094 let resp_headers = select_response_headers(
2095 &out.input.headers,
2096 user_content_type,
2097 inferred_content_type,
2098 );
2099
2100 HttpReply {
2101 status,
2102 headers: resp_headers,
2103 body: reply_body,
2104 }
2105 }
2106 Err(e) => {
2107 pipeline_error_to_reply(e, &path_clone)
2108 }
2109 };
2110
2111 let _ = reply_tx.send(reply);
2113 });
2114 }
2115 }
2116 }
2117
2118 if let Some(method) = &self.config.method {
2123 registry_for_cleanup
2124 .unregister_rest_endpoint(method, &path)
2125 .await;
2126 } else {
2127 registry_for_cleanup.unregister_api_route(&path).await;
2128 }
2129
2130 ServerRegistry::global()
2136 .unregister(&self.config.host, self.config.port)
2137 .await;
2138
2139 if server_died {
2140 tracing::error!(
2142 host = %self.config.host,
2143 port = self.config.port,
2144 path = %path,
2145 "Shared HTTP server exited — failing consumer to engage route supervision (ADR-0007)"
2146 );
2147 return Err(CamelError::RouteError(format!(
2148 "shared HTTP server for {}:{} exited unexpectedly; route transport is dead",
2149 self.config.host, self.config.port
2150 )));
2151 }
2152
2153 Ok(())
2154 }
2155
2156 async fn stop(&mut self) -> Result<(), CamelError> {
2157 Ok(())
2158 }
2159
2160 fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
2161 camel_component_api::ConcurrencyModel::Concurrent { max: None }
2162 }
2163
2164 fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
2171 camel_component_api::ConsumerStartupMode::Explicit
2172 }
2173
2174 fn set_security_context(&mut self, ctx: camel_component_api::SecurityContext) {
2177 self.kernel = HttpKernelAuth::from_security_context(&ctx).map(Arc::new);
2178 }
2179}
2180
2181pub struct HttpComponent {
2186 config: HttpConfig,
2187 pinned_cache: std::sync::Arc<PinnedClientCache>,
2188 client: reqwest::Client,
2189 strict_tls_error: Option<CamelError>,
2193}
2194
2195#[cfg(test)]
2196thread_local! {
2197 static BUILD_CLIENT_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
2198}
2199
2200pub(crate) fn build_client(
2201 config: &HttpConfig,
2202 resolve_override: Option<(&str, &[std::net::SocketAddr])>,
2203) -> reqwest::Client {
2204 #[cfg(test)]
2205 BUILD_CLIENT_CALLS.with(|c| c.set(c.get() + 1));
2206
2207 let mut builder = reqwest::Client::builder()
2208 .no_proxy() .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
2210 .pool_max_idle_per_host(config.pool_max_idle_per_host)
2211 .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
2212
2213 builder = builder.redirect(reqwest::redirect::Policy::none());
2217
2218 if let Some((host, addrs)) = resolve_override {
2219 builder = builder.resolve_to_addrs(host, addrs);
2220 }
2221
2222 if let Some(tls) = &config.tls
2223 && tls.enabled
2224 {
2225 if tls.insecure || !tls.verify_peer {
2226 tracing::warn!("HTTP TLS verification disabled — connections are vulnerable to MitM");
2228 builder = builder.danger_accept_invalid_certs(true);
2229 }
2230
2231 if let Some(ca_path) = &tls.ca_cert_path {
2232 match std::fs::read(ca_path) {
2237 Ok(ca_bytes) => {
2238 let pem_sections = rustls_pemfile::certs(&mut std::io::Cursor::new(&ca_bytes))
2245 .filter(|r| r.is_ok())
2246 .count();
2247 if pem_sections == 0 {
2248 tracing::warn!(
2250 "configured CA certificate contains no parseable PEM CERTIFICATE section — falling back to system roots"
2251 );
2252 }
2253 match reqwest::Certificate::from_pem(&ca_bytes)
2254 .or_else(|_| reqwest::Certificate::from_der(&ca_bytes))
2255 {
2256 Ok(ca_cert) => {
2257 builder = builder.add_root_certificate(ca_cert);
2258 }
2259 Err(e) => {
2260 tracing::warn!(
2262 error = %e,
2263 "configured CA certificate failed to parse — falling back to system roots"
2264 );
2265 }
2266 }
2267 }
2268 Err(e) => {
2269 tracing::warn!(
2271 error = %e,
2272 "configured CA certificate file unreadable — falling back to system roots"
2273 );
2274 }
2275 }
2276 }
2277
2278 if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path) {
2282 match (std::fs::read(cert_path), std::fs::read(key_path)) {
2283 (Ok(cert_bytes), Ok(key_bytes)) => {
2284 let mut identity_pem = cert_bytes;
2285 identity_pem.extend_from_slice(&key_bytes);
2286 match reqwest::Identity::from_pem(&identity_pem) {
2287 Ok(identity) => {
2288 builder = builder.identity(identity);
2289 }
2290 Err(e) => {
2291 tracing::warn!(
2293 error = %e,
2294 "configured mTLS identity failed to parse — client certificate NOT used"
2295 );
2296 }
2297 }
2298 }
2299 (cert_r, key_r) => {
2300 tracing::warn!(
2302 cert_ok = cert_r.is_ok(),
2303 key_ok = key_r.is_ok(),
2304 "configured mTLS cert/key file unreadable — client certificate NOT used"
2305 );
2306 }
2307 }
2308 }
2309 }
2310
2311 builder
2312 .build()
2313 .expect("reqwest::Client::build() with valid config should not fail") }
2315
2316fn strict_tls_error(config: &HttpConfig) -> Option<CamelError> {
2325 let tls = config.tls.as_ref()?;
2326 if !tls.enabled || !tls.strict {
2327 return None;
2328 }
2329 if let Some(ca_path) = &tls.ca_cert_path {
2330 match std::fs::read(ca_path) {
2331 Ok(ca_bytes) => {
2332 let pem_sections = rustls_pemfile::certs(&mut std::io::Cursor::new(&ca_bytes))
2344 .filter(|r| r.is_ok())
2345 .count();
2346 if pem_sections == 0 {
2347 return Some(CamelError::EndpointCreationFailed(format!(
2348 "tls.strict: configured CA certificate '{ca_path}' has no \
2349 parseable PEM CERTIFICATE section (DER bundles are not \
2350 enforced by the TLS backend — convert to PEM)"
2351 )));
2352 }
2353 }
2354 Err(e) => {
2355 return Some(CamelError::EndpointCreationFailed(format!(
2356 "tls.strict: configured CA certificate '{ca_path}' is unreadable: {e}"
2357 )));
2358 }
2359 }
2360 }
2361 if tls.client_cert_path.is_some() != tls.client_key_path.is_some() {
2365 return Some(CamelError::EndpointCreationFailed(
2366 "tls.strict: mTLS requires BOTH client_cert_path and client_key_path".to_string(),
2367 ));
2368 }
2369 if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path) {
2370 match (std::fs::read(cert_path), std::fs::read(key_path)) {
2371 (Ok(mut cert_bytes), Ok(key_bytes)) => {
2372 cert_bytes.extend_from_slice(&key_bytes);
2373 if reqwest::Identity::from_pem(&cert_bytes).is_err() {
2374 return Some(CamelError::EndpointCreationFailed(
2375 "tls.strict: configured mTLS identity failed to parse".to_string(),
2376 ));
2377 }
2378 }
2379 _ => {
2380 return Some(CamelError::EndpointCreationFailed(
2381 "tls.strict: configured mTLS cert/key files are unreadable".to_string(),
2382 ));
2383 }
2384 }
2385 }
2386 None
2387}
2388
2389#[cfg(test)]
2390pub(crate) fn build_client_call_count() -> u64 {
2391 BUILD_CLIENT_CALLS.with(|c| c.get())
2392}
2393
2394impl HttpComponent {
2395 pub fn new() -> Self {
2396 let config = HttpConfig::default();
2397 let strict_err = strict_tls_error(&config);
2398 Self {
2399 client: build_client(&config, None),
2400 config,
2401 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2402 PINNED_CLIENT_TTL,
2403 PINNED_CLIENT_MAX_ENTRIES,
2404 )),
2405 strict_tls_error: strict_err,
2406 }
2407 }
2408
2409 pub fn with_config(config: HttpConfig) -> Self {
2410 let strict_err = strict_tls_error(&config);
2411 Self {
2412 client: build_client(&config, None),
2413 config,
2414 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2415 PINNED_CLIENT_TTL,
2416 PINNED_CLIENT_MAX_ENTRIES,
2417 )),
2418 strict_tls_error: strict_err,
2419 }
2420 }
2421
2422 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2423 match config {
2424 Some(cfg) => Self::with_config(cfg),
2425 None => Self::new(),
2426 }
2427 }
2428}
2429
2430impl Default for HttpComponent {
2431 fn default() -> Self {
2432 Self::new()
2433 }
2434}
2435
2436impl Component for HttpComponent {
2437 fn scheme(&self) -> &str {
2438 "http"
2439 }
2440
2441 fn metadata(&self) -> ComponentMetadata {
2442 HttpEndpointConfig::metadata()
2443 }
2444
2445 fn create_endpoint(
2446 &self,
2447 uri: &str,
2448 ctx: &dyn camel_component_api::ComponentContext,
2449 ) -> Result<Box<dyn Endpoint>, CamelError> {
2450 if let Some(err) = &self.strict_tls_error {
2451 return Err(err.clone());
2452 }
2453 self.config.validate()?;
2454 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2455 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2456 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2457 server_config.host.clone(),
2458 server_config.port,
2459 )));
2460 self.pinned_cache
2461 .wire(HttpComponentKind::Http, ctx.metrics());
2462 Ok(Box::new(HttpEndpoint {
2463 uri: uri.to_string(),
2464 config,
2465 server_config,
2466 client: self.client.clone(),
2467 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2468 http_config: self.config.clone(),
2469 }))
2470 }
2471}
2472
2473pub struct HttpsComponent {
2474 config: HttpConfig,
2475 pinned_cache: std::sync::Arc<PinnedClientCache>,
2476 client: reqwest::Client,
2477 strict_tls_error: Option<CamelError>,
2481}
2482
2483impl HttpsComponent {
2484 pub fn new() -> Self {
2485 let config = HttpConfig::default();
2486 let strict_err = strict_tls_error(&config);
2487 Self {
2488 client: build_client(&config, None),
2489 config,
2490 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2491 PINNED_CLIENT_TTL,
2492 PINNED_CLIENT_MAX_ENTRIES,
2493 )),
2494 strict_tls_error: strict_err,
2495 }
2496 }
2497
2498 pub fn with_config(config: HttpConfig) -> Self {
2499 let strict_err = strict_tls_error(&config);
2500 Self {
2501 client: build_client(&config, None),
2502 config,
2503 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2504 PINNED_CLIENT_TTL,
2505 PINNED_CLIENT_MAX_ENTRIES,
2506 )),
2507 strict_tls_error: strict_err,
2508 }
2509 }
2510
2511 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2512 match config {
2513 Some(cfg) => Self::with_config(cfg),
2514 None => Self::new(),
2515 }
2516 }
2517}
2518
2519impl Default for HttpsComponent {
2520 fn default() -> Self {
2521 Self::new()
2522 }
2523}
2524
2525impl Component for HttpsComponent {
2526 fn scheme(&self) -> &str {
2527 "https"
2528 }
2529
2530 fn metadata(&self) -> ComponentMetadata {
2531 let mut meta = HttpEndpointConfig::metadata();
2534 meta.scheme = "https".to_string();
2535 meta.description = "HTTPS client and server component (TLS over HTTP)".to_string();
2536 meta
2537 }
2538
2539 fn create_endpoint(
2540 &self,
2541 uri: &str,
2542 ctx: &dyn camel_component_api::ComponentContext,
2543 ) -> Result<Box<dyn Endpoint>, CamelError> {
2544 if let Some(err) = &self.strict_tls_error {
2545 return Err(err.clone());
2546 }
2547 self.config.validate()?;
2548 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2549 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2550 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2551 server_config.host.clone(),
2552 server_config.port,
2553 )));
2554 self.pinned_cache
2555 .wire(HttpComponentKind::Https, ctx.metrics());
2556 Ok(Box::new(HttpEndpoint {
2557 uri: uri.to_string(),
2558 config,
2559 server_config,
2560 client: self.client.clone(),
2561 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2562 http_config: self.config.clone(),
2563 }))
2564 }
2565}
2566
2567struct HttpEndpoint {
2572 uri: String,
2573 config: HttpEndpointConfig,
2574 server_config: HttpServerConfig,
2575 client: reqwest::Client,
2576 pinned_cache: std::sync::Arc<PinnedClientCache>,
2577 http_config: HttpConfig,
2578}
2579
2580impl Endpoint for HttpEndpoint {
2581 fn uri(&self) -> &str {
2582 &self.uri
2583 }
2584
2585 fn create_consumer(
2586 &self,
2587 rt: Arc<dyn camel_component_api::RuntimeObservability>,
2588 ) -> Result<Box<dyn Consumer>, CamelError> {
2589 let scheme_is_https = self.server_config.scheme == "https";
2592 let has_tls = self.server_config.tls_config.is_some();
2593
2594 if scheme_is_https && !has_tls {
2595 return Err(CamelError::EndpointCreationFailed(
2596 "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
2597 ));
2598 }
2599 if !scheme_is_https && has_tls {
2600 return Err(CamelError::EndpointCreationFailed(
2601 "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
2602 ));
2603 }
2604 Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
2605 }
2606
2607 fn create_producer(
2608 &self,
2609 rt: Arc<dyn camel_component_api::RuntimeObservability>,
2610 _ctx: &ProducerContext,
2611 ) -> Result<BoxProcessor, CamelError> {
2612 let producer = HttpProducer {
2613 config: Arc::new(self.config.clone()),
2614 client: self.client.clone(),
2615 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2616 http_config: Arc::new(self.http_config.clone()),
2617 runtime: rt,
2618 };
2619 if let Some(ref provider) = self.config.token_provider {
2620 let layer = BearerTokenLayer::new(Arc::clone(provider));
2621 Ok(BoxProcessor::new(layer.layer(producer)))
2622 } else {
2623 Ok(BoxProcessor::new(producer))
2624 }
2625 }
2626}
2627
2628#[derive(Clone)]
2633struct HttpProducer {
2634 config: Arc<HttpEndpointConfig>,
2635 client: reqwest::Client,
2636 pinned_cache: std::sync::Arc<PinnedClientCache>,
2637 http_config: Arc<HttpConfig>,
2638 runtime: Arc<dyn RuntimeObservability>,
2644}
2645
2646impl HttpProducer {
2647 fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
2648 if let Some(ref method) = config.http_method {
2649 return method.to_uppercase();
2650 }
2651 if let Some(method) = exchange
2652 .input
2653 .header("CamelHttpMethod")
2654 .and_then(|v| v.as_str())
2655 {
2656 return method.to_uppercase();
2657 }
2658 if !exchange.input.body.is_empty() {
2659 return "POST".to_string();
2660 }
2661 "GET".to_string()
2662 }
2663
2664 fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> Result<String, CamelError> {
2665 if config.bridge_endpoint {
2674 let Some(query) = resolve_endpoint_query(config)? else {
2675 return Ok(config.base_url.clone());
2676 };
2677 let _: url::Url = url::Url::parse(&config.base_url).map_err(|e| {
2685 CamelError::ProcessorError(format!(
2686 "invalid base URL '{}': {e}",
2687 redact_url_for_diagnostics(&config.base_url)
2688 ))
2689 })?;
2690 let mut url = config.base_url.clone();
2691 url.push('?');
2692 url.push_str(&query);
2693 return Ok(url);
2694 }
2695
2696 if let Some(uri) = exchange
2697 .input
2698 .header("CamelHttpUri")
2699 .and_then(|v| v.as_str())
2700 {
2701 if let Some(fence) = &config.allowed_uri_hosts
2706 && !uri_host_allowed(uri, fence)?
2707 {
2708 return Err(CamelError::ProcessorError(format!(
2709 "CamelHttpUri host not allowed by allowedUriHosts fence: {}",
2710 redact_url_for_diagnostics(uri)
2711 )));
2712 }
2713 let (base, override_query) = match uri.split_once('?') {
2720 Some((base, query)) => (base, Some(query)),
2721 None => (uri, None),
2722 };
2723 if let Some(query) = override_query {
2729 for (_key, span) in raw_query_pairs(query)? {
2730 validate_raw_query_span(span)?;
2731 }
2732 }
2733 let mut url = base.to_string();
2734 if let Some(path) = exchange
2735 .input
2736 .header("CamelHttpPath")
2737 .and_then(|v| v.as_str())
2738 {
2739 if !url.ends_with('/') && !path.starts_with('/') {
2740 url.push('/');
2741 }
2742 url.push_str(path);
2743 }
2744 if let Some(query) = exchange
2745 .input
2746 .header("CamelHttpQuery")
2747 .and_then(|v| v.as_str())
2748 {
2749 if let Some(merged) = merge_header_query(override_query, query)? {
2750 url.push('?');
2751 url.push_str(&merged);
2752 }
2753 return Ok(url);
2754 }
2755 if let Some(query) = override_query {
2756 url.push('?');
2757 url.push_str(query);
2758 }
2759 return Ok(url);
2760 }
2761
2762 let mut url = config.base_url.clone();
2763
2764 if let Some(path) = exchange
2765 .input
2766 .header("CamelHttpPath")
2767 .and_then(|v| v.as_str())
2768 {
2769 if !url.ends_with('/') && !path.starts_with('/') {
2770 url.push('/');
2771 }
2772 url.push_str(path);
2773 }
2774
2775 if let Some(query) = exchange
2776 .input
2777 .header("CamelHttpQuery")
2778 .and_then(|v| v.as_str())
2779 {
2780 if let Some(merged) =
2785 merge_header_query(resolve_endpoint_query(config)?.as_deref(), query)?
2786 {
2787 url.push('?');
2788 url.push_str(&merged);
2789 }
2790 return Ok(url);
2791 }
2792
2793 if let Some(query) = resolve_endpoint_query(config)? {
2794 url.push('?');
2795 url.push_str(&query);
2796 }
2797
2798 Ok(url)
2799 }
2800
2801 fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
2802 status >= range.0 && status <= range.1
2803 }
2804}
2805
2806#[derive(Clone, Debug, PartialEq, Eq)]
2811pub struct AllowedUriHost {
2812 pub host: String,
2814 pub port: Option<u16>,
2816}
2817
2818fn parse_allowed_uri_hosts(raw: &str) -> Result<Vec<AllowedUriHost>, CamelError> {
2826 let mut entries = Vec::new();
2827 for segment in raw.split(',') {
2828 let segment = segment.trim();
2829 if segment.is_empty() {
2830 continue;
2831 }
2832 let parsed = url::Url::parse(&format!("http://{segment}"))
2833 .map_err(|_| invalid_allowed_uri_host_entry(segment))?;
2834 if parsed.path() != "/" || !parsed.username().is_empty() || parsed.password().is_some() {
2838 return Err(invalid_allowed_uri_host_entry(segment));
2839 }
2840 let Some(host) = parsed.host_str() else {
2841 return Err(invalid_allowed_uri_host_entry(segment));
2842 };
2843 entries.push(AllowedUriHost {
2844 host: host.to_string(),
2845 port: parsed.port(),
2846 });
2847 }
2848 if entries.is_empty() {
2849 return Err(CamelError::InvalidUri(
2850 "allowedUriHosts declares no valid host entries".to_string(),
2851 ));
2852 }
2853 Ok(entries)
2854}
2855
2856fn invalid_allowed_uri_host_entry(segment: &str) -> CamelError {
2857 CamelError::InvalidUri(format!("invalid allowedUriHosts entry '{segment}'"))
2858}
2859
2860pub(crate) fn uri_host_allowed(
2867 url_str: &str,
2868 fence: &[AllowedUriHost],
2869) -> Result<bool, CamelError> {
2870 let Ok(parsed) = url::Url::parse(url_str) else {
2871 return Ok(false);
2872 };
2873 let Some(host) = parsed.host_str() else {
2874 return Ok(false);
2875 };
2876 let effective_port = parsed.port().or(match parsed.scheme() {
2877 "https" => Some(443_u16),
2878 "http" => Some(80),
2879 _ => None,
2880 });
2881 Ok(fence.iter().any(|entry| {
2882 entry.host == host
2883 && match entry.port {
2884 None => true,
2885 Some(port) => effective_port == Some(port),
2886 }
2887 }))
2888}
2889
2890fn resolve_endpoint_query(config: &HttpEndpointConfig) -> Result<Option<String>, CamelError> {
2903 let mut parts: Vec<String> = Vec::new();
2904 let mut authored_keys = std::collections::HashSet::new();
2905
2906 if let Some(raw) = config.raw_query.as_deref() {
2907 for (key, span) in raw_query_pairs(raw)? {
2908 authored_keys.insert(key.clone());
2909 if is_consumed_option(&key) {
2910 continue;
2911 }
2912 validate_raw_query_span(span)?;
2913 parts.push(span.to_string());
2914 }
2915 }
2916
2917 for (key, value) in &config.query_params {
2918 if !authored_keys.contains(key.as_str()) {
2919 parts.push(format!(
2920 "{}={}",
2921 encode_query_component(key),
2922 encode_query_component(value)
2923 ));
2924 }
2925 }
2926
2927 if parts.is_empty() && config.raw_query.as_deref() != Some("") {
2928 return Ok(None);
2929 }
2930 Ok(Some(parts.join("&")))
2931}
2932
2933fn merge_header_query(
2942 higher_precedence: Option<&str>,
2943 header_query: &str,
2944) -> Result<Option<String>, CamelError> {
2945 if header_query.is_empty() {
2946 return Ok(higher_precedence.map(str::to_string));
2947 }
2948 let mut parts: Vec<String> = Vec::new();
2949 let mut higher_keys = std::collections::HashSet::new();
2950 for (key, span) in raw_query_pairs(higher_precedence.unwrap_or(""))? {
2951 higher_keys.insert(key);
2952 parts.push(span.to_string());
2953 }
2954 for (key, span) in raw_query_pairs(header_query)? {
2955 validate_raw_query_span(span)?;
2956 if !higher_keys.contains(key.as_str()) {
2957 parts.push(span.to_string());
2958 }
2959 }
2960 if parts.is_empty() {
2961 return Ok(None);
2962 }
2963 Ok(Some(parts.join("&")))
2964}
2965
2966fn is_legal_query_byte(byte: u8) -> bool {
2977 matches!(byte,
2978 b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z'
2979 | b'-' | b'.' | b'_' | b'~'
2980 | b'!' | b'$' | b'&' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'='
2981 | b':' | b'@' | b'/' | b'?'
2982 | b'%')
2983}
2984
2985fn validate_raw_query_span(span: &str) -> Result<(), CamelError> {
2991 for &byte in span.as_bytes() {
2992 if !is_legal_query_byte(byte) {
2993 return Err(CamelError::ProcessorError(format!(
2994 "raw query pair '{span}' contains byte 0x{byte:02X}, which is not legal in a URL query component"
2995 )));
2996 }
2997 }
2998 Ok(())
2999}
3000
3001fn encode_query_component(component: &str) -> String {
3005 const HEX: &[u8; 16] = b"0123456789ABCDEF";
3006 let mut out = String::with_capacity(component.len());
3007 for &byte in component.as_bytes() {
3008 match byte {
3009 b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z' | b'-' | b'.' | b'_' | b'~' => {
3010 out.push(byte as char);
3011 }
3012 _ => {
3013 out.push('%');
3014 out.push(HEX[(byte >> 4) as usize] as char);
3015 out.push(HEX[(byte & 0x0f) as usize] as char);
3016 }
3017 }
3018 }
3019 out
3020}
3021
3022pub(crate) fn redact_url_for_diagnostics(raw: &str) -> String {
3050 match url::Url::parse(raw) {
3051 Ok(mut u) => {
3052 if !u.cannot_be_a_base()
3059 && u.host_str().is_none()
3060 && camel_api::redact::window_has_at_sign(raw)
3061 {
3062 return "[redacted]".to_string();
3063 }
3064 if !u.username().is_empty() || u.password().is_some() {
3065 let _ = u.set_username("***");
3066 let _ = u.set_password(None);
3067 }
3068 let s = u.to_string();
3071 camel_api::redact::redact_url(&s)
3072 }
3073 Err(_) => camel_api::redact::redact_url_fail_closed(raw),
3074 }
3075}
3076
3077const MAX_ERROR_RESPONSE_BODY_BYTES: usize = 4096;
3082
3083fn truncate_error_body(body: &[u8]) -> String {
3084 if body.len() <= MAX_ERROR_RESPONSE_BODY_BYTES {
3085 String::from_utf8_lossy(body).into_owned()
3086 } else {
3087 let mut s = String::from_utf8_lossy(&body[..MAX_ERROR_RESPONSE_BODY_BYTES]).into_owned();
3088 s.push_str("...[truncated]");
3089 s
3090 }
3091}
3092
3093impl HttpProducer {
3094 fn is_entity_enclosing(method: &str) -> bool {
3099 matches!(method, "POST" | "PUT" | "PATCH")
3100 }
3101}
3102
3103impl Service<Exchange> for HttpProducer {
3104 type Response = Exchange;
3105 type Error = CamelError;
3106 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
3107
3108 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
3109 Poll::Ready(Ok(()))
3110 }
3111
3112 fn call(&mut self, exchange: Exchange) -> Self::Future {
3113 let config = self.config.clone();
3114 let shared_client = self.client.clone();
3115 let pinned_cache = std::sync::Arc::clone(&self.pinned_cache);
3116 let http_config = self.http_config.clone();
3117 let component_metrics = self.runtime.component_metrics();
3118
3119 Box::pin(async move {
3120 let mut exchange = exchange;
3121 let outcome = async {
3122 let method_str = HttpProducer::resolve_method(&exchange, &config);
3123 let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
3128 let url = HttpProducer::resolve_url(&exchange, &config)?;
3129
3130 ssrf::validate_url_for_ssrf(&url, &config)?;
3132
3133 let resolved =
3141 ssrf::resolve_initial_url_for_ssrf(&url, config.allow_internal).await?;
3142 let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
3143 pinned_cache
3144 .get_or_build(host.as_str(), addrs, || {
3145 build_client(&http_config, Some((host.as_str(), addrs)))
3146 })
3147 .await
3148 } else {
3149 shared_client.clone()
3150 };
3151
3152 debug!(
3153 correlation_id = %exchange.correlation_id(),
3154 method = %method_str,
3155 url = %redact_url_for_diagnostics(&url),
3156 "HTTP request"
3157 );
3158
3159 let method = method_str.parse::<reqwest::Method>().map_err(|e| {
3160 CamelError::ProcessorError(format!(
3161 "Invalid HTTP method '{}': {}",
3162 method_str, e
3163 ))
3164 })?;
3165
3166 let mut collected_headers: Vec<(
3168 reqwest::header::HeaderName,
3169 reqwest::header::HeaderValue,
3170 )> = Vec::new();
3171
3172 if let Some(user_agent) = &config.user_agent
3173 && !config.bridge_endpoint
3174 {
3175 match constructed_header("user-agent", user_agent) {
3176 Ok((_, val)) => {
3177 collected_headers.push((reqwest::header::USER_AGENT, val));
3178 }
3179 Err(drop) => debug!(
3180 correlation_id = %exchange.correlation_id(),
3181 header = %drop.name,
3182 "outbound header dropped: {}",
3183 drop.reason
3184 ),
3185 }
3186 }
3187
3188 #[cfg(feature = "otel")]
3190 let should_inject_otel = !config.bridge_endpoint;
3191 #[cfg(feature = "otel")]
3192 if should_inject_otel {
3193 let mut otel_headers = HashMap::new();
3194 camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
3195 for (k, v) in otel_headers {
3196 match constructed_header(&k, &v) {
3197 Ok((name, val)) => collected_headers.push((name, val)),
3198 Err(drop) => debug!(
3199 correlation_id = %exchange.correlation_id(),
3200 header = %drop.name,
3201 "outbound header dropped: {}",
3202 drop.reason
3203 ),
3204 }
3205 }
3206 }
3207
3208 let conn_tokens = header_policy::connection_tokens(
3209 exchange
3210 .input
3211 .headers
3212 .iter()
3213 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3214 .filter_map(|(_, v)| v.as_str()),
3215 );
3216
3217 let outbound = select_outbound_headers(
3218 &exchange.input.headers,
3219 &config.skip_request_headers,
3220 &conn_tokens,
3221 );
3222 for drop in &outbound.drops {
3223 if let Some(value_kind) = drop.value_kind {
3224 debug!(
3225 correlation_id = %exchange.correlation_id(),
3226 header = %drop.name,
3227 value_kind = value_kind,
3228 "outbound header dropped: {}",
3229 drop.reason
3230 );
3231 } else {
3232 debug!(
3233 correlation_id = %exchange.correlation_id(),
3234 header = %drop.name,
3235 "outbound header dropped: {}",
3236 drop.reason
3237 );
3238 }
3239 }
3240 collected_headers.extend(outbound.accepted);
3241
3242 if !config.bridge_endpoint {
3244 match &config.auth {
3245 HttpAuth::None => {}
3246 HttpAuth::Basic { username, password } => {
3247 use base64::Engine;
3248 let credentials = format!("{username}:{password}");
3250 let encoded =
3251 base64::engine::general_purpose::STANDARD.encode(credentials);
3252 match constructed_header("authorization", &format!("Basic {encoded}")) {
3255 Ok((_, val)) => {
3256 collected_headers.push((reqwest::header::AUTHORIZATION, val));
3257 }
3258 Err(drop) => debug!(
3259 correlation_id = %exchange.correlation_id(),
3260 header = %drop.name,
3261 "outbound header dropped: {}",
3262 drop.reason
3263 ),
3264 }
3265 }
3266 HttpAuth::Bearer { token } => {
3267 let bearer = format!("Bearer {token}");
3269 match constructed_header("authorization", &bearer) {
3270 Ok((_, val)) => {
3271 collected_headers.push((reqwest::header::AUTHORIZATION, val));
3272 }
3273 Err(drop) => debug!(
3274 correlation_id = %exchange.correlation_id(),
3275 header = %drop.name,
3276 "outbound header dropped: {}",
3277 drop.reason
3278 ),
3279 }
3280 }
3281 }
3282
3283 if config.connection_close {
3284 collected_headers.push((
3285 reqwest::header::CONNECTION,
3286 reqwest::header::HeaderValue::from_static("close"),
3287 ));
3288 }
3289 }
3290
3291 let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
3293 let materialized_body: Option<Vec<u8>> = if is_stream_body {
3294 if suppress_body {
3295 std::mem::take(&mut exchange.input.body);
3303 tracing::warn!(
3305 correlation_id = %exchange.correlation_id(),
3306 method = %method_str,
3307 "dropping request body for non-entity-enclosing HTTP method"
3308 );
3309 }
3310 None } else {
3312 let body = std::mem::take(&mut exchange.input.body);
3313 let bytes = body.into_bytes(config.max_body_size).await?;
3314 if bytes.is_empty() {
3315 None
3317 } else if suppress_body {
3318 tracing::warn!(
3320 correlation_id = %exchange.correlation_id(),
3321 method = %method_str,
3322 "dropping request body for non-entity-enclosing HTTP method"
3323 );
3324 None
3325 } else {
3326 Some(bytes.to_vec())
3327 }
3328 };
3329
3330 let response = if config.follow_redirects && !is_stream_body {
3331 ssrf::send_with_ssrf_safe_redirects(
3337 &client,
3338 &shared_client,
3339 &pinned_cache,
3340 &http_config,
3341 &config,
3342 method,
3343 &url,
3344 collected_headers,
3345 materialized_body,
3346 config.max_redirects,
3347 config.response_timeout,
3348 )
3349 .await?
3350 } else {
3351 let mut request = client.request(method, &url);
3353
3354 if let Some(timeout) = config.response_timeout {
3355 request = request.timeout(timeout);
3356 }
3357
3358 for (name, value) in &collected_headers {
3359 request = request.header(name, value);
3360 }
3361
3362 if is_stream_body {
3363 if let Body::Stream(ref s) = exchange.input.body {
3364 let mut stream_lock = s.stream.lock().await;
3365 if let Some(stream) = stream_lock.take() {
3366 request = request.body(reqwest::Body::wrap_stream(stream));
3367 } else {
3368 return Err(CamelError::AlreadyConsumed);
3369 }
3370 }
3371 } else if let Some(ref body_bytes) = materialized_body {
3372 request = request.body(body_bytes.clone());
3373 }
3374
3375 request.send().await.map_err(|e| {
3376 CamelError::ProcessorError(format!("HTTP request failed: {e}"))
3377 })?
3378 };
3379
3380 let status_code = response.status().as_u16();
3381 let status_text = response
3382 .status()
3383 .canonical_reason()
3384 .unwrap_or("Unknown")
3385 .to_string();
3386
3387 for (key, value) in response.headers() {
3388 if config
3389 .skip_response_headers
3390 .iter()
3391 .any(|h| h.eq_ignore_ascii_case(key.as_str()))
3392 {
3393 continue;
3394 }
3395 if let Ok(val_str) = value.to_str() {
3396 exchange.input.set_header(
3397 title_case_header(key.as_str()),
3398 serde_json::Value::String(val_str.to_string()),
3399 );
3400 }
3401 }
3402
3403 exchange.input.set_header(
3404 "CamelHttpResponseCode",
3405 serde_json::Value::Number(status_code.into()),
3406 );
3407 exchange.input.set_header(
3408 "CamelHttpResponseText",
3409 serde_json::Value::String(status_text.clone()),
3410 );
3411
3412 let read_timeout = Duration::from_millis(config.read_timeout_ms);
3414 let response_body = tokio::time::timeout(read_timeout, async {
3415 if let Some(content_len) = response.content_length()
3417 && content_len > config.max_response_bytes as u64
3418 {
3419 return Err(CamelError::ProcessorError(format!(
3420 "Response body too large: {} bytes exceeds limit of {} bytes",
3421 content_len, config.max_response_bytes
3422 )));
3423 }
3424 use futures::TryStreamExt;
3426 let mut stream = response.bytes_stream();
3427 let mut total: usize = 0;
3428 let mut collected = Vec::new();
3429 while let Some(chunk) = stream.try_next().await.map_err(|e| {
3430 CamelError::ProcessorError(format!("Failed to read response body: {e}"))
3431 })? {
3432 total += chunk.len();
3433 if total > config.max_response_bytes {
3434 return Err(CamelError::ProcessorError(format!(
3435 "Response body too large: {} bytes exceeds limit of {} bytes",
3436 total, config.max_response_bytes
3437 )));
3438 }
3439 collected.push(chunk);
3440 }
3441 let mut result = bytes::BytesMut::with_capacity(total);
3442 for chunk in collected {
3443 result.extend_from_slice(&chunk);
3444 }
3445 Ok::<bytes::Bytes, CamelError>(result.freeze())
3446 })
3447 .await
3448 .map_err(|_| {
3449 CamelError::ProcessorError(format!(
3450 "Read timeout after {}ms",
3451 config.read_timeout_ms
3452 ))
3453 })??;
3454
3455 if config.throw_exception_on_failure
3456 && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
3457 {
3458 return Err(CamelError::HttpOperationFailed {
3459 method: method_str,
3460 url: redact_url_for_diagnostics(&url),
3463 status_code,
3464 status_text,
3465 response_body: Some(truncate_error_body(&response_body)),
3466 });
3467 }
3468
3469 if !response_body.is_empty() {
3470 exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
3471 }
3472
3473 debug!(
3474 correlation_id = %exchange.correlation_id(),
3475 status = status_code,
3476 url = %redact_url_for_diagnostics(&url),
3477 "HTTP response"
3478 );
3479 Ok(exchange)
3480 }
3481 .await;
3482 component_metrics.observe("http", "request", outcome.is_err());
3489 outcome
3490 })
3491 }
3492}
3493
3494#[cfg(test)]
3508pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
3509
3510#[cfg(test)]
3518pub(crate) fn lock_registry_test_mutex() -> std::sync::MutexGuard<'static, ()> {
3519 REGISTRY_TEST_MUTEX
3520 .lock()
3521 .unwrap_or_else(|poisoned| poisoned.into_inner())
3522}
3523
3524fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
3534 match e {
3535 CamelError::Unauthenticated(msg) => {
3536 tracing::warn!(error = %msg, path = %path, "Authentication failed");
3537 HttpReply {
3538 status: 401,
3539 headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
3540 body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
3541 }
3542 }
3543 CamelError::Unauthorized(msg) => {
3544 tracing::warn!(error = %msg, path = %path, "Authorization failed");
3545 HttpReply {
3546 status: 403,
3547 headers: vec![],
3548 body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
3549 }
3550 }
3551 CamelError::TypeConversionFailed(msg) => {
3552 tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
3553 json_error_reply(400, "bad_request", msg)
3554 }
3555 CamelError::ValidationError(msg) => {
3556 tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
3557 json_error_reply(400, "validation_error", msg)
3558 }
3559 CamelError::ConsumerStopping => {
3560 tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
3561 HttpReply {
3562 status: 503,
3563 headers: vec![],
3564 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
3565 }
3566 }
3567 CamelError::UnsupportedMediaType { consumed, declared } => {
3568 tracing::warn!(error = %consumed, declared = %declared, path = %path, "Unsupported media type (bad request)");
3569 json_error_reply(
3570 415,
3571 "unsupported_media_type",
3572 format!("consumed {consumed}, declared {declared}"),
3573 )
3574 }
3575 CamelError::NotAcceptable { accept, produced } => {
3576 tracing::warn!(error = %accept, produced = %produced, path = %path, "Not acceptable (bad request)");
3577 json_error_reply(
3578 406,
3579 "not_acceptable",
3580 format!("accept {accept}, produced {produced}"),
3581 )
3582 }
3583 e => {
3584 tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
3586 HttpReply {
3587 status: 500,
3588 headers: vec![],
3589 body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
3590 }
3591 }
3592 }
3593}
3594
3595fn json_error_reply(status: u16, code: &str, message: String) -> HttpReply {
3603 let body = serde_json::to_string(&serde_json::json!({
3604 "error": code,
3605 "message": message,
3606 }))
3607 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
3609 status,
3610 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
3611 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
3612 }
3613}
3614
3615const fn json_value_kind(v: &serde_json::Value) -> &'static str {
3619 match v {
3620 serde_json::Value::Null => "null",
3621 serde_json::Value::Bool(_) => "bool",
3622 serde_json::Value::Number(_) => "number",
3623 serde_json::Value::String(_) => "string",
3624 serde_json::Value::Array(_) => "array",
3625 serde_json::Value::Object(_) => "object",
3626 }
3627}
3628
3629fn scalar_string_form(v: &serde_json::Value) -> Option<String> {
3634 match v {
3635 serde_json::Value::String(s) => Some(s.clone()),
3636 serde_json::Value::Number(n) => Some(n.to_string()),
3637 serde_json::Value::Bool(b) => Some(b.to_string()),
3638 _ => None,
3639 }
3640}
3641
3642fn select_response_headers(
3657 headers: &HashMap<String, serde_json::Value>,
3658 user_content_type: Option<String>,
3659 inferred_content_type: Option<String>,
3660) -> Vec<(String, String)> {
3661 let conn_tokens = header_policy::connection_tokens(
3662 headers
3663 .iter()
3664 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3665 .filter_map(|(_, v)| v.as_str()),
3666 );
3667 let mut selected: Vec<(String, String)> = Vec::new();
3668 for (k, v) in headers {
3669 if k.starts_with("Camel") {
3670 debug!(header = %k, "reply header dropped: Camel namespace");
3671 continue;
3672 }
3673 if header_policy::excluded_response(k, &conn_tokens) {
3674 debug!(header = %k, "reply header dropped: emission policy");
3675 continue;
3676 }
3677 match scalar_string_form(v) {
3678 Some(s) => selected.push((k.clone(), s)),
3679 None => debug!(
3680 header = %k,
3681 value_kind = json_value_kind(v),
3682 "reply header dropped: no scalar string form"
3683 ),
3684 }
3685 }
3686 if let Some(ct) = user_content_type.or(inferred_content_type) {
3687 selected.push(("Content-Type".to_string(), ct));
3688 }
3689 selected
3690}
3691
3692#[derive(Debug)]
3697struct OutboundHeaderDrop<'a> {
3698 name: &'a str,
3699 reason: &'static str,
3700 value_kind: Option<&'static str>,
3701}
3702
3703struct OutboundHeaderSelection<'a> {
3706 accepted: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>,
3707 drops: Vec<OutboundHeaderDrop<'a>>,
3708}
3709
3710fn select_outbound_headers<'a>(
3724 headers: &'a HashMap<String, serde_json::Value>,
3725 skip_request_headers: &[String],
3726 conn_tokens: &[String],
3727) -> OutboundHeaderSelection<'a> {
3728 let mut accepted = Vec::new();
3729 let mut drops = Vec::new();
3730 for (key, value) in headers {
3731 if key.starts_with("Camel") {
3732 drops.push(OutboundHeaderDrop {
3733 name: key,
3734 reason: "Camel namespace",
3735 value_kind: None,
3736 });
3737 continue;
3738 }
3739 if skip_request_headers
3740 .iter()
3741 .any(|h| h.eq_ignore_ascii_case(key))
3742 {
3743 drops.push(OutboundHeaderDrop {
3744 name: key,
3745 reason: "skip_request_headers",
3746 value_kind: None,
3747 });
3748 continue;
3749 }
3750 if header_policy::excluded_outbound(key, conn_tokens) {
3751 drops.push(OutboundHeaderDrop {
3752 name: key,
3753 reason: "outbound emission policy",
3754 value_kind: None,
3755 });
3756 continue;
3757 }
3758 let Some(val_str) = scalar_string_form(value) else {
3759 drops.push(OutboundHeaderDrop {
3760 name: key,
3761 reason: "no scalar string form",
3762 value_kind: Some(json_value_kind(value)),
3763 });
3764 continue;
3765 };
3766 match constructed_header(key, &val_str) {
3767 Ok((name, val)) => accepted.push((name, val)),
3768 Err(drop) => drops.push(drop),
3769 }
3770 }
3771 OutboundHeaderSelection { accepted, drops }
3772}
3773
3774fn constructed_header<'a>(
3779 name: &'a str,
3780 value: &str,
3781) -> Result<(reqwest::header::HeaderName, reqwest::header::HeaderValue), OutboundHeaderDrop<'a>> {
3782 let header_name = match reqwest::header::HeaderName::from_bytes(name.as_bytes()) {
3783 Ok(header_name) => header_name,
3784 Err(_) => {
3785 return Err(OutboundHeaderDrop {
3786 name,
3787 reason: "invalid header name",
3788 value_kind: None,
3789 });
3790 }
3791 };
3792 let header_value = match reqwest::header::HeaderValue::from_str(value) {
3793 Ok(header_value) => header_value,
3794 Err(_) => {
3795 return Err(OutboundHeaderDrop {
3796 name,
3797 reason: "invalid header value",
3798 value_kind: None,
3799 });
3800 }
3801 };
3802 Ok((header_name, header_value))
3803}
3804
3805#[cfg(test)]
3806mod tests {
3807 use camel_component_api::test_support::NoopRuntimeObservability;
3808
3809 fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3813 std::sync::Arc::new(NoopRuntimeObservability)
3814 }
3815 fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3816 std::sync::Arc::new(NoopRuntimeObservability)
3817 }
3818 fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3819 std::sync::Arc::new(NoopRuntimeObservability)
3820 }
3821
3822 use super::*;
3823 use crate::config::TlsConfig;
3824 use crate::rest_match::PathSegment;
3825 use camel_component_api::{Message, NoOpComponentContext};
3826 use std::sync::Arc;
3827 use std::time::Duration;
3828
3829 fn test_producer_ctx() -> ProducerContext {
3830 ProducerContext::new()
3831 }
3832
3833 #[test]
3838 fn redact_url_drops_oauth2_fragment_access_token() {
3839 let redacted =
3840 redact_url_for_diagnostics("https://app.example/cb#access_token=SECRET&state=x");
3841 assert!(
3842 !redacted.contains("SECRET"),
3843 "fragment access token leaked: {redacted}"
3844 );
3845 assert!(
3846 !redacted.contains("access_token"),
3847 "fragment key leaked: {redacted}"
3848 );
3849 assert!(
3850 redacted.ends_with("#[redacted]"),
3851 "fragment must be replaced with the sentinel: {redacted}"
3852 );
3853 }
3854
3855 #[test]
3856 fn redact_url_drops_oauth2_fragment_id_token() {
3857 let redacted =
3858 redact_url_for_diagnostics("https://app.example/cb#id_token=eyJhbG.SECRET.SIG&state=y");
3859 assert!(
3860 !redacted.contains("eyJhbG"),
3861 "id token payload leaked: {redacted}"
3862 );
3863 assert!(
3864 !redacted.contains("id_token"),
3865 "id token key leaked: {redacted}"
3866 );
3867 assert!(
3868 !redacted.contains("SECRET"),
3869 "id token signature leaked: {redacted}"
3870 );
3871 assert!(
3872 redacted.ends_with("#[redacted]"),
3873 "fragment must be replaced with the sentinel: {redacted}"
3874 );
3875 }
3876
3877 #[test]
3878 fn redact_url_drops_generic_fragment_kv() {
3879 let redacted = redact_url_for_diagnostics("https://h.example/p/session#session=abc123");
3880 assert!(
3881 !redacted.contains("abc123"),
3882 "fragment value leaked: {redacted}"
3883 );
3884 assert!(
3885 !redacted.contains("session="),
3886 "fragment key leaked: {redacted}"
3887 );
3888 assert!(
3889 redacted.contains("#[redacted]"),
3890 "fragment must be replaced with the sentinel: {redacted}"
3891 );
3892 }
3893
3894 #[test]
3895 fn redact_url_query_and_fragment_sentinels_compose() {
3896 let redacted = redact_url_for_diagnostics("https://h.example/p?a=1#access_token=x");
3897 assert_eq!(
3898 redacted, "https://h.example/p?[redacted]#[redacted]",
3899 "query and fragment sentinels must compose: {redacted}"
3900 );
3901 }
3902
3903 #[test]
3904 fn redact_url_drops_benign_fragment_too() {
3905 let redacted = redact_url_for_diagnostics("https://h.example/docs#section-3");
3908 assert_eq!(
3909 redacted, "https://h.example/docs#[redacted]",
3910 "benign fragment must still be dropped: {redacted}"
3911 );
3912 }
3913
3914 #[test]
3915 fn redact_url_unparseable_fragment_credentials_dropped() {
3916 let raw = "ht tps://app.example/cb#access_token=SECRET";
3917 assert!(
3918 url::Url::parse(raw).is_err(),
3919 "fixture must be unparseable: {raw}"
3920 );
3921 let redacted = redact_url_for_diagnostics(raw);
3922 assert!(
3923 !redacted.contains("SECRET"),
3924 "unparseable fragment token leaked: {redacted}"
3925 );
3926 assert!(
3927 !redacted.contains("access_token"),
3928 "unparseable fragment bytes leaked: {redacted}"
3929 );
3930 assert!(
3931 redacted.contains("#[redacted]"),
3932 "unparseable fragment must end in the sentinel: {redacted}"
3933 );
3934 }
3935
3936 #[test]
3937 fn redact_url_double_slash_evader_sentinel() {
3938 let redacted = redact_url_for_diagnostics("scheme:////user:pass@evil/");
3941 assert_eq!(
3942 redacted, "[redacted]",
3943 "double-slash evader must fail closed: {redacted}"
3944 );
3945 }
3946
3947 #[test]
3948 fn redact_url_triple_slash_evader_sentinel() {
3949 let redacted = redact_url_for_diagnostics("scheme:///user:pass@evil/");
3950 assert_eq!(
3951 redacted, "[redacted]",
3952 "triple-slash evader must fail closed: {redacted}"
3953 );
3954 }
3955
3956 #[test]
3957 fn redact_url_bare_protocol_relative_userinfo_sentinel() {
3958 let redacted = redact_url_for_diagnostics("//user:pass@evil");
3959 assert_eq!(
3960 redacted, "[redacted]",
3961 "protocol-relative userinfo must fail closed: {redacted}"
3962 );
3963 }
3964
3965 #[test]
3966 fn redact_url_empty_host_userinfo_sentinel() {
3967 let redacted = redact_url_for_diagnostics("scheme://user@");
3970 assert_eq!(
3971 redacted, "[redacted]",
3972 "empty-host userinfo must fail closed: {redacted}"
3973 );
3974 }
3975
3976 #[test]
3977 fn redact_url_unparseable_slash_run_evader_sentinel() {
3978 let raw = "schem e:////user:pass@evil/";
3982 assert!(
3983 url::Url::parse(raw).is_err(),
3984 "fixture must be unparseable: {raw}"
3985 );
3986 let redacted = redact_url_for_diagnostics(raw);
3987 assert_eq!(
3988 redacted, "[redacted]",
3989 "unparseable slash-run evader must fail closed: {redacted}"
3990 );
3991 }
3992
3993 #[test]
3994 fn redact_url_unparseable_later_window_userinfo_sentinel() {
3995 let raw = "http://ho st/a//user:pass@evil/";
3999 assert!(
4000 url::Url::parse(raw).is_err(),
4001 "fixture must be unparseable: {raw}"
4002 );
4003 let redacted = redact_url_for_diagnostics(raw);
4004 assert_eq!(
4005 redacted, "[redacted]",
4006 "userinfo in a later // window must fail closed: {redacted}"
4007 );
4008 }
4009
4010 #[test]
4011 fn redact_url_parsed_later_window_userinfo_masked() {
4012 let redacted = redact_url_for_diagnostics("https://h//user:pass@evil/");
4017 assert!(
4018 !redacted.contains("user:pass"),
4019 "parsed later-window userinfo leaked: {redacted}"
4020 );
4021 assert!(
4022 redacted.contains("h//***@evil/"),
4023 "later window must be masked in place: {redacted}"
4024 );
4025 }
4026
4027 #[test]
4028 fn redact_url_parsed_window_mask_idempotent_with_real_userinfo() {
4029 let redacted = redact_url_for_diagnostics("https://user:pass@h//x@y/");
4033 assert!(
4034 redacted.contains("***@h"),
4035 "accessor mask must survive the window surgery: {redacted}"
4036 );
4037 assert!(
4038 !redacted.contains("user:pass"),
4039 "real userinfo leaked: {redacted}"
4040 );
4041 assert!(
4042 !redacted.contains("x@y"),
4043 "later path window leaked: {redacted}"
4044 );
4045 }
4046
4047 #[test]
4048 fn redact_url_backslash_authority_ruling() {
4049 let redacted = redact_url_for_diagnostics("http:\\\\user:pass@evil\\path");
4055 assert!(
4056 redacted.contains("***@"),
4057 "backslash authority must be userinfo-masked: {redacted}"
4058 );
4059 assert!(
4060 !redacted.contains("user:pass"),
4061 "backslash authority must not leak credentials: {redacted}"
4062 );
4063 }
4064
4065 #[test]
4066 fn non_special_backslash_authority_masked() {
4067 let redacted = redact_url_for_diagnostics("foo:\\user:pass@evil/");
4072 assert!(
4073 !redacted.contains("user:pass"),
4074 "non-special backslash authority leaked: {redacted}"
4075 );
4076 assert!(
4077 !redacted.contains("pass"),
4078 "non-special backslash authority leaked a credential byte: {redacted}"
4079 );
4080 assert_eq!(
4082 redact_url_for_diagnostics("foo:\\clean/path"),
4083 "foo:\\clean/path"
4084 );
4085 }
4086
4087 #[test]
4088 fn one_char_scheme_credential_content_masked() {
4089 let redacted = redact_url_for_diagnostics("x:\\user:pass@evil");
4092 assert!(
4093 !redacted.contains("user:pass"),
4094 "one-char-scheme backslash authority leaked: {redacted}"
4095 );
4096 assert!(
4097 !redacted.contains("pass"),
4098 "one-char-scheme backslash authority leaked a credential byte: {redacted}"
4099 );
4100 }
4101
4102 #[test]
4103 fn drive_and_unc_inputs_stay_visible() {
4104 let drive = redact_url_for_diagnostics("C:\\Users\\x@corp\\file");
4110 assert!(
4111 !drive.contains("[redacted]"),
4112 "drive path must not be sentineled: {drive}"
4113 );
4114 assert!(
4115 !drive.contains("***"),
4116 "drive path must not be masked: {drive}"
4117 );
4118 assert!(
4119 drive.contains("x@corp"),
4120 "drive path keeps its at-sign content visible: {drive}"
4121 );
4122 let unc = redact_url_for_diagnostics("\\\\server\\x@y");
4125 assert_eq!(unc, "\\\\server\\x@y");
4126 assert!(
4127 !unc.contains("[redacted]"),
4128 "UNC path must not be sentineled: {unc}"
4129 );
4130 }
4131
4132 #[test]
4133 fn redact_url_masks_userinfo_and_query() {
4134 let redacted =
4135 redact_url_for_diagnostics("http://user:secretpass@internal.example/api?token=abc123");
4136 assert!(
4137 !redacted.contains("secretpass"),
4138 "password must be masked: {redacted}"
4139 );
4140 assert!(
4141 !redacted.contains("token=abc123"),
4142 "query must be masked: {redacted}"
4143 );
4144 assert!(
4145 !redacted.contains("user@"),
4146 "username must be masked: {redacted}"
4147 );
4148 assert!(
4149 redacted.contains("internal.example"),
4150 "host stays visible: {redacted}"
4151 );
4152 assert!(redacted.contains("[redacted]"), "query marked: {redacted}");
4153 }
4154
4155 #[test]
4156 fn redact_url_keeps_clean_urls_visible() {
4157 let redacted = redact_url_for_diagnostics("https://api.example.com/v1/items");
4158 assert_eq!(redacted, "https://api.example.com/v1/items");
4159 }
4160
4161 #[test]
4162 fn redact_url_masks_password_only_userinfo() {
4163 let redacted = redact_url_for_diagnostics("http://:pwsecret@host.example/");
4164 assert!(
4165 !redacted.contains("pwsecret"),
4166 "password-only userinfo leaked: {redacted}"
4167 );
4168 assert_eq!(redacted, "http://***@host.example/");
4169
4170 let redacted = redact_url_for_diagnostics("http://user:pw2@host.example/api");
4171 assert!(!redacted.contains("pw2"), "password leaked: {redacted}");
4172 assert_eq!(redacted, "http://***@host.example/api");
4173
4174 let redacted = redact_url_for_diagnostics("http://host.example/api");
4175 assert_eq!(redacted, "http://host.example/api");
4176 }
4177
4178 #[test]
4179 fn redact_url_truncates_unparseable() {
4180 let long = "x".repeat(1000);
4181 let redacted = redact_url_for_diagnostics(&long);
4182 assert_eq!(redacted.len(), 256, "unparseable URL must be truncated");
4183 }
4184
4185 #[test]
4190 fn redact_url_keeps_sentinels_intact_under_256_cap() {
4191 let parsed = format!("https://example.com/{}?x=1", "a".repeat(230));
4194 assert!(
4195 url::Url::parse(&parsed).is_ok(),
4196 "fixture must parse: {parsed}"
4197 );
4198 let redacted = redact_url_for_diagnostics(&parsed);
4199 assert!(redacted.len() <= 256, "len={}", redacted.len());
4200 assert!(
4201 redacted.ends_with("?[redacted]"),
4202 "parsed-arm sentinel must render intact: {redacted}"
4203 );
4204
4205 let unparseable = format!("http://{} ?x=1", "a".repeat(240));
4208 assert!(
4209 url::Url::parse(&unparseable).is_err(),
4210 "fixture must not parse: {unparseable}"
4211 );
4212 let redacted = redact_url_for_diagnostics(&unparseable);
4213 assert!(redacted.len() <= 256, "len={}", redacted.len());
4214 assert!(
4215 redacted.ends_with("?[redacted]"),
4216 "unparseable-arm sentinel must render intact: {redacted}"
4217 );
4218 }
4219
4220 #[test]
4221 fn redact_url_suppresses_unparseable_authority_credentials() {
4222 let fixtures = [
4223 "http://u:secretpw@/x",
4224 "http://u:secretpw@host:99999/x",
4225 "http://u:secretpw@host:99999",
4226 "//u:secretpw@h/x",
4227 ];
4228 for fixture in fixtures {
4229 assert!(
4230 url::Url::parse(fixture).is_err(),
4231 "fixture must be unparseable: {fixture}"
4232 );
4233 let redacted = redact_url_for_diagnostics(fixture);
4234 assert_eq!(
4235 redacted, "[redacted]",
4236 "credential-bearing authority must be suppressed: {fixture}"
4237 );
4238 }
4239 }
4240
4241 #[test]
4242 fn redact_url_bd_repro_never_leaks_credentials() {
4243 let redacted = redact_url_for_diagnostics("http://user:pa%ss@host/path");
4244 assert!(
4245 !redacted.contains("user:pa%ss"),
4246 "bd rc-2i5c5 repro leaked userinfo: {redacted}"
4247 );
4248 assert!(
4249 !redacted.contains("pa%ss"),
4250 "bd rc-2i5c5 repro leaked password: {redacted}"
4251 );
4252 }
4253
4254 #[test]
4255 fn redact_url_unparseable_query_redacted_short_and_long() {
4256 let short = "http://host:99999/path?token=shortsecret";
4257 assert!(
4258 url::Url::parse(short).is_err(),
4259 "fixture must be unparseable: {short}"
4260 );
4261 let redacted = redact_url_for_diagnostics(short);
4262 assert_eq!(
4263 redacted, "http://host:99999/path?[redacted]",
4264 "short unparseable query must end with the suffix: {redacted}"
4265 );
4266
4267 let mut long = String::from("http://host:99999/");
4268 long.push_str(&"a".repeat(300));
4269 long.push_str("?token=longsecret");
4270 assert!(
4271 url::Url::parse(&long).is_err(),
4272 "fixture must be unparseable: {long}"
4273 );
4274 let redacted = redact_url_for_diagnostics(&long);
4275 assert!(
4276 !redacted.contains("longsecret"),
4277 "long unparseable query leaked a query byte: {redacted}"
4278 );
4279 assert!(
4280 redacted.len() <= 256,
4281 "long unparseable query must be capped: {} bytes",
4282 redacted.len()
4283 );
4284 }
4285
4286 #[test]
4287 fn redact_url_unparseable_sentinels_compose_both() {
4288 let raw = "ht tp://h.example/p?a=1#tok=x";
4291 assert!(
4292 url::Url::parse(raw).is_err(),
4293 "fixture must be unparseable: {raw}"
4294 );
4295 assert_eq!(
4296 redact_url_for_diagnostics(raw),
4297 "ht tp://h.example/p?[redacted]#[redacted]",
4298 "query and fragment sentinels must compose: {raw}"
4299 );
4300 }
4301
4302 #[test]
4303 fn redact_url_unparseable_sentinels_compose_fragment_first() {
4304 let raw = "ht tp://h.example/p#tok=x?a=1";
4305 assert!(
4306 url::Url::parse(raw).is_err(),
4307 "fixture must be unparseable: {raw}"
4308 );
4309 assert_eq!(
4310 redact_url_for_diagnostics(raw),
4311 "ht tp://h.example/p#[redacted]?[redacted]",
4312 "sentinels must follow the introducers' first-occurrence order: {raw}"
4313 );
4314 }
4315
4316 #[test]
4317 fn redact_url_unparseable_utf8_straddle_no_panic() {
4318 let fixture = format!("a{}", "é".repeat(200));
4319 let redacted = redact_url_for_diagnostics(&fixture);
4320 assert!(
4321 redacted.len() <= 256,
4322 "straddle fixture must be capped: {} bytes",
4323 redacted.len()
4324 );
4325 assert!(
4326 redacted.len() >= 253,
4327 "straddle fixture must not over-truncate: {} bytes",
4328 redacted.len()
4329 );
4330 assert!(
4331 fixture.is_char_boundary(redacted.len()),
4332 "cut must land on a UTF-8 char boundary: {} bytes",
4333 redacted.len()
4334 );
4335 }
4336
4337 #[test]
4338 fn redact_url_at_sign_outside_authority_window_visible() {
4339 let at_sign_in_path = "http://host:99999/x@y";
4340 assert!(
4341 url::Url::parse(at_sign_in_path).is_err(),
4342 "fixture must be unparseable: {at_sign_in_path}"
4343 );
4344 assert_eq!(
4345 redact_url_for_diagnostics(at_sign_in_path),
4346 at_sign_in_path,
4347 "at-sign in path must not be suppressed"
4348 );
4349 assert_eq!(
4351 redact_url_for_diagnostics("mailto:user@example.com"),
4352 "mailto:user@example.com",
4353 "at-sign in mailto must round-trip byte-identically"
4354 );
4355 }
4356
4357 #[test]
4358 fn parse_success_fragment_composes() {
4359 assert_eq!(
4362 redact_url_for_diagnostics("https://h/p#access_token=x"),
4363 "https://h/p#[redacted]"
4364 );
4365 assert_eq!(
4368 redact_url_for_diagnostics("https://h/cb#f?state=x"),
4369 "https://h/cb#[redacted]?[redacted]"
4370 );
4371 }
4372
4373 #[test]
4374 fn err_arm_delegation_pin() {
4375 assert_eq!(
4379 redact_url_for_diagnostics("http://u:secretpw@host:99999/x"),
4380 "[redacted]"
4381 );
4382 assert_eq!(
4385 redact_url_for_diagnostics("http://h:99999/p?token=secret"),
4386 "http://h:99999/p?[redacted]"
4387 );
4388 }
4389
4390 #[test]
4391 fn truncate_error_body_caps_attacker_body() {
4392 let big = vec![b'A'; 10 * 1024 * 1024];
4393 let truncated = truncate_error_body(&big);
4394 assert!(
4395 truncated.len() <= MAX_ERROR_RESPONSE_BODY_BYTES + 20,
4396 "body must be capped near {} bytes, got {}",
4397 MAX_ERROR_RESPONSE_BODY_BYTES,
4398 truncated.len()
4399 );
4400 assert!(truncated.ends_with("...[truncated]"));
4401 }
4402
4403 #[test]
4404 fn truncate_error_body_keeps_small_body() {
4405 assert_eq!(truncate_error_body(b"boom"), "boom");
4406 }
4407
4408 #[test]
4409 fn test_http_config_defaults() {
4410 let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
4411 assert_eq!(config.base_url, "http://localhost:8080/api");
4412 assert!(config.http_method.is_none());
4413 assert!(config.throw_exception_on_failure);
4414 assert_eq!(config.ok_status_code_range, (200, 299));
4415 assert!(config.response_timeout.is_none());
4416 assert!(matches!(config.auth, HttpAuth::None));
4417 assert!(!config.bridge_endpoint);
4418 assert!(!config.connection_close);
4419 }
4420
4421 #[test]
4422 fn test_http_config_scheme() {
4423 assert_eq!(HttpEndpointConfig::scheme(), "http");
4425 }
4426
4427 #[test]
4428 fn test_http_config_from_components() {
4429 let components = camel_component_api::UriComponents {
4431 scheme: "https".to_string(),
4432 path: "//api.example.com/v1".to_string(),
4433 params: std::collections::HashMap::from([(
4434 "httpMethod".to_string(),
4435 "POST".to_string(),
4436 )]),
4437 raw_query: None,
4438 };
4439 let config = HttpEndpointConfig::from_components(components).unwrap();
4440 assert_eq!(config.base_url, "https://api.example.com/v1");
4441 assert_eq!(config.http_method, Some("POST".to_string()));
4442 }
4443
4444 #[test]
4445 fn test_http_config_with_options() {
4446 let config = HttpEndpointConfig::from_uri(
4447 "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
4448 ).unwrap();
4449 assert_eq!(config.base_url, "https://api.example.com/v1");
4450 assert_eq!(config.http_method, Some("PUT".to_string()));
4451 assert!(!config.throw_exception_on_failure);
4452 assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
4453 }
4454
4455 #[test]
4456 fn test_http_endpoint_config_auth_and_headers_options() {
4457 let config = HttpEndpointConfig::from_uri(
4458 "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
4459 )
4460 .unwrap();
4461
4462 assert!(matches!(
4463 config.auth,
4464 HttpAuth::Basic { username, password } if username == "u" && password == "p"
4465 ));
4466 assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
4467 assert!(config.bridge_endpoint);
4468 assert!(config.connection_close);
4469 assert_eq!(
4470 config.skip_request_headers,
4471 vec!["authorization".to_string(), "x-secret".to_string()]
4472 );
4473 assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
4474 }
4475
4476 #[test]
4477 fn test_http_endpoint_config_bearer_auth() {
4478 let config = HttpEndpointConfig::from_uri(
4479 "http://localhost/api?authMethod=Bearer&authBearerToken=t",
4480 )
4481 .unwrap();
4482 assert!(matches!(
4483 config.auth,
4484 HttpAuth::Bearer { token } if token == "t"
4485 ));
4486 }
4487
4488 #[test]
4489 fn rejects_cookie_handling_inmemory() {
4490 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
4491 match result {
4492 Err(CamelError::InvalidUri(msg)) => {
4493 assert!(
4494 msg.contains("cookieHandling is not supported"),
4495 "expected rejection message, got: {msg}"
4496 );
4497 }
4498 other => panic!("expected InvalidUri error, got: {other:?}"),
4499 }
4500 }
4501
4502 #[test]
4503 fn rejects_cookie_handling_disabled() {
4504 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
4505 match result {
4506 Err(CamelError::InvalidUri(msg)) => {
4507 assert!(
4508 msg.contains("cookieHandling is not supported"),
4509 "expected rejection message, got: {msg}"
4510 );
4511 }
4512 other => panic!("expected InvalidUri error, got: {other:?}"),
4513 }
4514 }
4515
4516 #[test]
4517 fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
4518 let config = HttpConfig::default()
4519 .with_response_timeout_ms(999)
4520 .with_allow_internal(true)
4521 .with_blocked_hosts(vec!["evil.com".to_string()])
4522 .with_max_body_size(12345);
4523 let endpoint =
4524 HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
4525 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
4526 assert!(endpoint.allow_internal);
4527 assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
4528 assert_eq!(endpoint.max_body_size, 12345);
4529 }
4530
4531 #[test]
4532 fn test_from_uri_with_defaults_uri_overrides_config() {
4533 let config = HttpConfig::default()
4534 .with_response_timeout_ms(999)
4535 .with_allow_internal(true)
4536 .with_blocked_hosts(vec!["evil.com".to_string()])
4537 .with_max_body_size(12345);
4538 let endpoint = HttpEndpointConfig::from_uri_with_defaults(
4539 "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
4540 &config,
4541 )
4542 .unwrap();
4543 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
4544 assert!(!endpoint.allow_internal);
4545 assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
4546 assert_eq!(endpoint.max_body_size, 99);
4547 }
4548
4549 #[test]
4550 fn test_http_config_ok_status_range() {
4551 let config =
4552 HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
4553 assert_eq!(config.ok_status_code_range, (200, 204));
4554 }
4555
4556 #[test]
4557 fn test_http_config_wrong_scheme() {
4558 let result = HttpEndpointConfig::from_uri("file:/tmp");
4559 assert!(result.is_err());
4560 }
4561
4562 #[test]
4563 fn test_http_component_scheme() {
4564 let component = HttpComponent::new();
4565 assert_eq!(component.scheme(), "http");
4566 }
4567
4568 #[test]
4575 fn tls_strict_defaults_false_on_deserialize() {
4576 let tls: TlsConfig = serde_json::from_value(serde_json::json!({
4577 "enabled": true
4578 }))
4579 .unwrap();
4580 assert!(!tls.strict, "absent strict must default to false");
4581 }
4582
4583 fn strict_config(ca_path: Option<&str>, strict: bool) -> HttpConfig {
4584 HttpConfig {
4585 tls: Some(TlsConfig {
4586 enabled: true,
4587 strict,
4588 ca_cert_path: ca_path.map(|p| p.to_string()),
4589 ..TlsConfig::default()
4590 }),
4591 ..HttpConfig::default()
4592 }
4593 }
4594
4595 #[test]
4596 fn strict_tls_missing_ca_fails_endpoint_creation() {
4597 let component =
4598 HttpComponent::with_config(strict_config(Some("/nonexistent/ca.pem"), true));
4599 let err = component
4600 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4601 .err()
4602 .expect("strict + missing CA must fail endpoint creation");
4603 assert!(
4604 err.to_string().contains("tls.strict"),
4605 "must name the strict knob: {err}"
4606 );
4607 assert!(
4608 err.to_string().contains("unreadable"),
4609 "must name the failure class: {err}"
4610 );
4611 }
4612
4613 #[test]
4614 fn strict_tls_unparseable_ca_fails_endpoint_creation() {
4615 let path = camel_component_api::test_support::tls::write_pem_tmp(
4616 "strict-bad-ca.pem",
4617 "not a certificate",
4618 );
4619 let component =
4620 HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
4621 let err = component
4622 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4623 .err()
4624 .expect("strict + unparseable CA must fail endpoint creation");
4625 assert!(
4626 err.to_string()
4627 .contains("no parseable PEM CERTIFICATE section"),
4628 "must name the failure class: {err}"
4629 );
4630 }
4631
4632 #[test]
4633 fn strict_tls_der_file_rejected_not_certified() {
4634 let path = camel_component_api::test_support::tls::write_pem_tmp(
4639 "strict-der-ca.pem",
4640 "00garbage-bytes",
4641 );
4642 let component =
4643 HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
4644 let err = component
4645 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4646 .err()
4647 .expect("strict + DER file must fail endpoint creation");
4648 assert!(
4649 err.to_string().contains("convert to PEM"),
4650 "must tell the operator to convert: {err}"
4651 );
4652 }
4653
4654 #[test]
4655 fn strict_tls_half_mtls_pair_rejected() {
4656 let cfg = strict_mtls_config(Some("/any/cert.pem"), None);
4659 let component = HttpComponent::with_config(cfg);
4660 let err = component
4661 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4662 .err()
4663 .expect("strict + half mTLS pair must fail endpoint creation");
4664 assert!(
4665 err.to_string().contains("BOTH"),
4666 "must name the pair requirement: {err}"
4667 );
4668 }
4669
4670 #[test]
4671 fn strict_tls_valid_material_allows_endpoint_creation() {
4672 let (ca, _cert, _key) = camel_component_api::test_support::tls::gen_server_cert();
4673 let path = camel_component_api::test_support::tls::write_pem_tmp("strict-ok-ca.pem", &ca);
4674 let component =
4675 HttpComponent::with_config(strict_config(Some(path.to_str().unwrap()), true));
4676 assert!(
4677 component
4678 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4679 .is_ok(),
4680 "valid CA under strict must create the endpoint"
4681 );
4682 }
4683
4684 #[test]
4685 fn permissive_missing_ca_keeps_back_compat() {
4686 let component =
4689 HttpComponent::with_config(strict_config(Some("/nonexistent/ca.pem"), false));
4690 assert!(
4691 component
4692 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4693 .is_ok(),
4694 "permissive mode must keep the back-compat fallback"
4695 );
4696 }
4697
4698 fn strict_mtls_config(cert_path: Option<&str>, key_path: Option<&str>) -> HttpConfig {
4699 HttpConfig {
4700 tls: Some(TlsConfig {
4701 enabled: true,
4702 strict: true,
4703 client_cert_path: cert_path.map(|p| p.to_string()),
4704 client_key_path: key_path.map(|p| p.to_string()),
4705 ..TlsConfig::default()
4706 }),
4707 ..HttpConfig::default()
4708 }
4709 }
4710
4711 #[test]
4712 fn strict_tls_missing_mtls_cert_fails_endpoint_creation() {
4713 let (_ca, _cert, key) = camel_component_api::test_support::tls::gen_server_cert();
4716 let key_path =
4717 camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-key.pem", &key);
4718 let component = HttpComponent::with_config(strict_mtls_config(
4719 Some("/nonexistent/cert.pem"),
4720 Some(key_path.to_str().unwrap()),
4721 ));
4722 let err = component
4723 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4724 .err()
4725 .expect("strict + unreadable mTLS pair must fail endpoint creation");
4726 assert!(
4727 err.to_string().contains("tls.strict"),
4728 "must name the strict knob: {err}"
4729 );
4730 assert!(
4731 err.to_string().contains("unreadable"),
4732 "must name the failure class: {err}"
4733 );
4734 }
4735
4736 #[test]
4737 fn strict_tls_valid_mtls_pair_allows_endpoint_creation() {
4738 let (_ca, cert, key) = camel_component_api::test_support::tls::gen_server_cert();
4739 let cert_path =
4740 camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-cert.pem", &cert);
4741 let key_path =
4742 camel_component_api::test_support::tls::write_pem_tmp("strict-mtls-key2.pem", &key);
4743 let component = HttpComponent::with_config(strict_mtls_config(
4744 Some(cert_path.to_str().unwrap()),
4745 Some(key_path.to_str().unwrap()),
4746 ));
4747 assert!(
4748 component
4749 .create_endpoint("http://localhost/api", &NoOpComponentContext)
4750 .is_ok(),
4751 "valid mTLS pair under strict must create the endpoint"
4752 );
4753 }
4754
4755 #[test]
4756 fn test_https_component_scheme() {
4757 let component = HttpsComponent::new();
4758 assert_eq!(component.scheme(), "https");
4759 }
4760
4761 #[test]
4762 fn test_http_endpoint_creates_consumer() {
4763 let component = HttpComponent::new();
4764 let ctx = NoOpComponentContext;
4765 let endpoint = component
4766 .create_endpoint("http://0.0.0.0:19100/test", &ctx)
4767 .unwrap();
4768 assert!(endpoint.create_consumer(rt()).is_ok());
4769 }
4770
4771 #[test]
4772 fn test_https_endpoint_creates_consumer_errors_without_tls() {
4773 let component = HttpsComponent::new();
4774 let ctx = NoOpComponentContext;
4775 let endpoint = component
4776 .create_endpoint("https://0.0.0.0:8443/test", &ctx)
4777 .unwrap();
4778 assert!(endpoint.create_consumer(rt()).is_err());
4780 }
4781
4782 #[test]
4783 fn test_http_endpoint_creates_producer() {
4784 let ctx = test_producer_ctx();
4785 let component = HttpComponent::new();
4786 let endpoint_ctx = NoOpComponentContext;
4787 let endpoint = component
4788 .create_endpoint("http://localhost/api", &endpoint_ctx)
4789 .unwrap();
4790 assert!(endpoint.create_producer(rt(), &ctx).is_ok());
4791 }
4792
4793 #[tokio::test]
4798 async fn test_producer_with_token_provider() {
4799 use camel_auth::oauth2::TokenProvider;
4800 use tower::ServiceExt;
4801
4802 let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
4803 Arc::new(std::sync::Mutex::new(None));
4804 let captured_clone = Arc::clone(&captured_auth);
4805
4806 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4807 let port = listener.local_addr().unwrap().port();
4808
4809 let _handle = tokio::spawn(async move {
4810 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4811 if let Ok((mut stream, _)) = listener.accept().await {
4812 let mut buf = vec![0u8; 8192];
4813 let n = stream.read(&mut buf).await.unwrap_or(0);
4814 let request = String::from_utf8_lossy(&buf[..n]).to_string();
4815 let auth = request
4816 .lines()
4817 .find(|l| l.to_lowercase().starts_with("authorization:"))
4818 .map(|l| {
4819 l.split(':')
4820 .nth(1)
4821 .map(|s| s.trim().to_string())
4822 .unwrap_or_default()
4823 });
4824 *captured_clone.lock().unwrap() = auth;
4825 let body = r#"{"echo":"ok"}"#;
4826 let resp = format!(
4827 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
4828 body.len(),
4829 body
4830 );
4831 let _ = stream.write_all(resp.as_bytes()).await;
4832 }
4833 });
4834
4835 #[derive(Debug)]
4836 struct StaticProvider;
4837 #[async_trait::async_trait]
4838 impl TokenProvider for StaticProvider {
4839 async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
4840 Ok("injected-token".into())
4841 }
4842 }
4843
4844 let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
4845 let ctx = test_producer_ctx();
4846 let component = HttpComponent::new();
4847 let endpoint_ctx = NoOpComponentContext;
4848 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
4849 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4850
4851 let exchange = Exchange::new(Message::new("hello"));
4852
4853 let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
4854 let mut layered = layer.layer(producer);
4855 let result = layered.ready().await.unwrap().call(exchange).await;
4856 assert!(result.is_ok(), "producer call failed: {:?}", result);
4857
4858 tokio::time::sleep(Duration::from_millis(100)).await;
4859 let auth = captured_auth.lock().unwrap().take();
4860 assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
4861 }
4862
4863 async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
4864 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4865 let addr = listener.local_addr().unwrap();
4866 let url = format!("http://127.0.0.1:{}", addr.port());
4867
4868 let handle = tokio::spawn(async move {
4869 loop {
4870 if let Ok((mut stream, _)) = listener.accept().await {
4871 tokio::spawn(async move {
4872 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4873 let mut buf = vec![0u8; 4096];
4874 let n = stream.read(&mut buf).await.unwrap_or(0);
4875 let request = String::from_utf8_lossy(&buf[..n]).to_string();
4876
4877 let method = request.split_whitespace().next().unwrap_or("GET");
4878
4879 let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
4880 let response = format!(
4881 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
4882 body.len(),
4883 body
4884 );
4885 let _ = stream.write_all(response.as_bytes()).await;
4886 });
4887 }
4888 }
4889 });
4890
4891 (url, handle)
4892 }
4893
4894 async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
4895 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4896 let addr = listener.local_addr().unwrap();
4897 let url = format!("http://127.0.0.1:{}", addr.port());
4898
4899 let handle = tokio::spawn(async move {
4900 loop {
4901 if let Ok((mut stream, _)) = listener.accept().await {
4902 let status = status;
4903 tokio::spawn(async move {
4904 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4905 let mut buf = vec![0u8; 4096];
4906 let _ = stream.read(&mut buf).await;
4907
4908 let status_text = match status {
4909 404 => "Not Found",
4910 500 => "Internal Server Error",
4911 _ => "Error",
4912 };
4913 let body = "error body";
4914 let response = format!(
4915 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
4916 status,
4917 status_text,
4918 body.len(),
4919 body
4920 );
4921 let _ = stream.write_all(response.as_bytes()).await;
4922 });
4923 }
4924 }
4925 });
4926
4927 (url, handle)
4928 }
4929
4930 async fn start_request_capturing_server() -> (
4931 String,
4932 Arc<std::sync::Mutex<Option<String>>>,
4933 tokio::task::JoinHandle<()>,
4934 ) {
4935 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4936 let port = listener.local_addr().unwrap().port();
4937 let url = format!("http://127.0.0.1:{port}");
4938 let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
4939 let captured_clone = Arc::clone(&captured);
4940 let handle = tokio::spawn(async move {
4941 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4942 if let Ok((mut stream, _)) = listener.accept().await {
4943 let mut buf = vec![0u8; 16384];
4944 let n = stream.read(&mut buf).await.unwrap_or(0);
4945 let request = String::from_utf8_lossy(&buf[..n]).to_string();
4946 if request.contains("\r\n\r\n") {
4947 *captured_clone.lock().unwrap() = Some(request);
4948 }
4949 let body = r#"{"echo":"ok"}"#;
4950 let resp = format!(
4951 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
4952 body.len(),
4953 body
4954 );
4955 let _ = stream.write_all(resp.as_bytes()).await;
4956 }
4957 });
4958 (url, captured, handle)
4959 }
4960
4961 #[tokio::test]
4962 async fn test_http_producer_get_request() {
4963 use tower::ServiceExt;
4964
4965 let (url, _handle) = start_test_server().await;
4966 let ctx = test_producer_ctx();
4967
4968 let component = HttpComponent::new();
4969 let endpoint_ctx = NoOpComponentContext;
4970 let endpoint = component
4971 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4972 .unwrap();
4973 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4974
4975 let exchange = Exchange::new(Message::default());
4976 let result = producer.oneshot(exchange).await.unwrap();
4977
4978 let status = result
4979 .input
4980 .header("CamelHttpResponseCode")
4981 .and_then(|v| v.as_u64())
4982 .unwrap();
4983 assert_eq!(status, 200);
4984
4985 assert!(!result.input.body.is_empty());
4986 }
4987
4988 #[tokio::test]
4989 async fn producer_excludes_host_and_framing() {
4990 use tower::ServiceExt;
4991
4992 let (url, captured, _handle) = start_request_capturing_server().await;
4993 let ctx = test_producer_ctx();
4994 let component = HttpComponent::new();
4995 let endpoint_ctx = NoOpComponentContext;
4996 let endpoint = component
4997 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4998 .unwrap();
4999 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5000
5001 let mut exchange = Exchange::new(Message::default());
5002 exchange.input.set_header("Host", "localhost");
5003 exchange.input.set_header("Content-Length", "42");
5004 exchange.input.set_header("Connection", "keep-alive");
5005 exchange.input.set_header("Upgrade", "h2c");
5006
5007 let result = producer.oneshot(exchange).await;
5008 assert!(result.is_ok(), "producer call failed: {:?}", result);
5009
5010 tokio::time::sleep(Duration::from_millis(100)).await;
5011 let request = captured
5012 .lock()
5013 .unwrap()
5014 .take()
5015 .expect("no outbound request captured");
5016 let lower = request.to_ascii_lowercase();
5017 assert!(
5018 !lower.contains("\r\nhost: localhost"),
5019 "forwarded Host: localhost must be stripped\n{request}"
5020 );
5021 assert!(
5022 !lower.contains("content-length: 42"),
5023 "exchange Content-Length must not be copied\n{request}"
5024 );
5025 assert!(
5026 !lower.lines().any(|l| l.starts_with("connection:")),
5027 "Connection header must not be forwarded\n{request}"
5028 );
5029 assert!(
5030 !lower.lines().any(|l| l.starts_with("upgrade:")),
5031 "Upgrade header must not be forwarded\n{request}"
5032 );
5033 let host_header = lower
5034 .lines()
5035 .find(|l| l.starts_with("host:"))
5036 .map(|l| l.split_once(':').map(|(_, v)| v).unwrap_or("").trim())
5037 .expect("outbound Host header must be set by reqwest");
5038 assert!(
5039 host_header.starts_with("127.0.0.1:"),
5040 "outbound Host '{host_header}' must match the capture-server address"
5041 );
5042 }
5043
5044 #[tokio::test]
5045 async fn producer_forwards_request_only_headers() {
5046 use tower::ServiceExt;
5047
5048 let (url, captured, _handle) = start_request_capturing_server().await;
5049 let ctx = test_producer_ctx();
5050 let component = HttpComponent::new();
5051 let endpoint_ctx = NoOpComponentContext;
5052 let endpoint = component
5053 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5054 .unwrap();
5055 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5056
5057 let mut exchange = Exchange::new(Message::default());
5058 exchange.input.set_header("Accept", "application/json");
5059 exchange.input.set_header("User-Agent", "myclient/1.0");
5060
5061 let result = producer.oneshot(exchange).await;
5062 assert!(result.is_ok(), "producer call failed: {:?}", result);
5063
5064 tokio::time::sleep(Duration::from_millis(100)).await;
5065 let request = captured
5066 .lock()
5067 .unwrap()
5068 .take()
5069 .expect("no outbound request captured");
5070 let lower = request.to_ascii_lowercase();
5071 assert!(
5072 lower.contains("accept: application/json"),
5073 "request-only Accept header must be forwarded\n{request}"
5074 );
5075 assert!(
5076 lower.contains("user-agent: myclient/1.0"),
5077 "request-only User-Agent header must be forwarded\n{request}"
5078 );
5079 }
5080
5081 fn endpoint_with_config_overrides(
5090 base_url: &str,
5091 user_agent: Option<String>,
5092 auth: HttpAuth,
5093 ) -> HttpEndpoint {
5094 let uri = format!("{base_url}/api/test?allowInternal=true");
5095 let mut config =
5096 HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses");
5097 config.user_agent = user_agent;
5098 config.auth = auth;
5099 HttpEndpoint {
5100 uri: uri.clone(),
5101 config,
5102 server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
5103 client: reqwest::Client::new(),
5104 pinned_cache: Arc::new(PinnedClientCache::new(
5105 PINNED_CLIENT_TTL,
5106 PINNED_CLIENT_MAX_ENTRIES,
5107 )),
5108 http_config: HttpConfig::default(),
5109 }
5110 }
5111
5112 #[tracing_test::traced_test]
5117 #[tokio::test]
5118 async fn producer_invalid_configured_headers_surfaced() {
5119 use tower::ServiceExt;
5120
5121 let (bad_url, bad_captured, _bad_handle) = start_request_capturing_server().await;
5122 let (ok_url, ok_captured, _ok_handle) = start_request_capturing_server().await;
5123 let ctx = test_producer_ctx();
5124
5125 let bad_producer = endpoint_with_config_overrides(
5126 &bad_url,
5127 Some("bad\r\nua".to_string()),
5128 HttpAuth::Bearer {
5129 token: "tok\r\nen".to_string(),
5130 },
5131 )
5132 .create_producer(rt(), &ctx)
5133 .unwrap();
5134 let ok_producer = endpoint_with_config_overrides(
5135 &ok_url,
5136 Some("httpsweep-ok/1".to_string()),
5137 HttpAuth::Bearer {
5138 token: "valid-token".to_string(),
5139 },
5140 )
5141 .create_producer(rt(), &ctx)
5142 .unwrap();
5143
5144 let bad_exchange = Exchange::new(Message::default());
5145 let ok_exchange = Exchange::new(Message::default());
5146 let bad_cid = bad_exchange.correlation_id().to_string();
5147 let ok_cid = ok_exchange.correlation_id().to_string();
5148
5149 let bad_result = bad_producer.oneshot(bad_exchange).await;
5150 assert!(
5151 bad_result.is_ok(),
5152 "invalid-config producer call failed: {bad_result:?}"
5153 );
5154 let ok_result = ok_producer.oneshot(ok_exchange).await;
5155 assert!(
5156 ok_result.is_ok(),
5157 "valid-config producer call failed: {ok_result:?}"
5158 );
5159
5160 tokio::time::sleep(Duration::from_millis(100)).await;
5161 let bad_request = bad_captured
5162 .lock()
5163 .unwrap()
5164 .take()
5165 .expect("no outbound request captured");
5166 let ok_request = ok_captured
5167 .lock()
5168 .unwrap()
5169 .take()
5170 .expect("no outbound request captured");
5171
5172 let bad_lower = bad_request.to_ascii_lowercase();
5175 assert!(
5176 !bad_lower.lines().any(|l| l.starts_with("authorization:")),
5177 "invalid Bearer token must not reach the wire\n{bad_request}"
5178 );
5179 assert!(
5180 !bad_request.contains("bad\r\nua"),
5181 "invalid configured user-agent must not reach the wire\n{bad_request}"
5182 );
5183
5184 logs_assert(|lines: &[&str]| {
5185 let drops: Vec<&&str> = lines
5186 .iter()
5187 .filter(|l| {
5188 l.contains("outbound header dropped")
5189 && l.contains(&format!("correlation_id={bad_cid}"))
5190 })
5191 .collect();
5192 if drops.len() != 2 {
5193 return Err(format!(
5194 "expected exactly 2 drop records for {bad_cid}, found {}",
5195 drops.len()
5196 ));
5197 }
5198 let has_ua = drops.iter().any(|l| l.contains("header=user-agent"));
5199 let has_auth = drops.iter().any(|l| l.contains("header=authorization"));
5200 let reason_ok = drops
5201 .iter()
5202 .all(|l| l.contains("outbound header dropped: invalid header value"));
5203 match (has_ua, has_auth, reason_ok) {
5204 (true, true, true) => Ok(()),
5205 _ => Err(format!(
5206 "drop records mismatched: user-agent={has_ua} \
5207 authorization={has_auth} reason-ok={reason_ok}"
5208 )),
5209 }
5210 });
5211 logs_assert(|lines: &[&str]| {
5212 if lines
5213 .iter()
5214 .any(|l| l.contains("bad\r\nua") || l.contains("tok\r\nen"))
5215 {
5216 Err("sentinel CRLF values leaked into logs".to_string())
5217 } else {
5218 Ok(())
5219 }
5220 });
5221
5222 let ok_lower = ok_request.to_ascii_lowercase();
5225 assert!(
5226 ok_lower.contains("user-agent: httpsweep-ok/1"),
5227 "valid configured user-agent must reach the wire\n{ok_request}"
5228 );
5229 assert!(
5230 ok_lower.contains("authorization: bearer valid-token"),
5231 "valid Bearer token must reach the wire\n{ok_request}"
5232 );
5233 logs_assert(|lines: &[&str]| {
5234 let hits = lines
5235 .iter()
5236 .filter(|l| {
5237 l.contains("outbound header dropped")
5238 && l.contains(&format!("correlation_id={ok_cid}"))
5239 })
5240 .count();
5241 match hits {
5242 0 => Ok(()),
5243 n => Err(format!("expected no drop records for {ok_cid}, found {n}")),
5244 }
5245 });
5246 }
5247
5248 #[tokio::test]
5249 async fn producer_honours_skip_request_headers() {
5250 use tower::ServiceExt;
5251
5252 let (url, captured, _handle) = start_request_capturing_server().await;
5253 let ctx = test_producer_ctx();
5254 let component = HttpComponent::new();
5255 let endpoint_ctx = NoOpComponentContext;
5256 let endpoint = component
5257 .create_endpoint(
5258 &format!("{url}/api/test?allowInternal=true&skipRequestHeaders=Authorization"),
5259 &endpoint_ctx,
5260 )
5261 .unwrap();
5262 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5263
5264 let mut exchange = Exchange::new(Message::default());
5265 exchange.input.set_header("Authorization", "Bearer x");
5266
5267 let result = producer.oneshot(exchange).await;
5268 assert!(result.is_ok(), "producer call failed: {:?}", result);
5269
5270 tokio::time::sleep(Duration::from_millis(100)).await;
5271 let request = captured
5272 .lock()
5273 .unwrap()
5274 .take()
5275 .expect("no outbound request captured");
5276 assert!(
5277 !request.to_ascii_lowercase().contains("authorization"),
5278 "Authorization must be stripped by skipRequestHeaders\n{request}"
5279 );
5280 }
5281
5282 #[tokio::test]
5283 async fn producer_stringifies_scalar_header_values_on_wire() {
5284 use tower::ServiceExt;
5285
5286 let (url, captured, _handle) = start_request_capturing_server().await;
5287 let ctx = test_producer_ctx();
5288 let component = HttpComponent::new();
5289 let endpoint_ctx = NoOpComponentContext;
5290 let endpoint = component
5291 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
5292 .unwrap();
5293 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5294
5295 let mut exchange = Exchange::new(Message::default());
5296 exchange.input.set_header("X-Retries", serde_json::json!(3));
5297 exchange
5298 .input
5299 .set_header("X-Enabled", serde_json::json!(true));
5300 exchange
5301 .input
5302 .set_header("X-Obj", serde_json::json!({"a": 1}));
5303
5304 let result = producer.oneshot(exchange).await;
5305 assert!(result.is_ok(), "producer call failed: {:?}", result);
5306
5307 tokio::time::sleep(Duration::from_millis(100)).await;
5308 let request = captured
5309 .lock()
5310 .unwrap()
5311 .take()
5312 .expect("no outbound request captured");
5313 let lower = request.to_ascii_lowercase();
5314 assert!(
5315 lower.contains("x-retries: 3"),
5316 "numeric header must reach the wire stringified\n{request}"
5317 );
5318 assert!(
5319 lower.contains("x-enabled: true"),
5320 "bool header must reach the wire stringified\n{request}"
5321 );
5322 assert!(
5323 !lower.contains("x-obj:"),
5324 "object header has no single-value form and must not reach the wire\n{request}"
5325 );
5326 }
5327
5328 #[tokio::test]
5329 async fn test_http_producer_post_with_body() {
5330 use tower::ServiceExt;
5331
5332 let (url, _handle) = start_test_server().await;
5333 let ctx = test_producer_ctx();
5334
5335 let component = HttpComponent::new();
5336 let endpoint_ctx = NoOpComponentContext;
5337 let endpoint = component
5338 .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
5339 .unwrap();
5340 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5341
5342 let exchange = Exchange::new(Message::new("request body"));
5343 let result = producer.oneshot(exchange).await.unwrap();
5344
5345 let status = result
5346 .input
5347 .header("CamelHttpResponseCode")
5348 .and_then(|v| v.as_u64())
5349 .unwrap();
5350 assert_eq!(status, 200);
5351 }
5352
5353 #[tokio::test]
5354 async fn test_http_producer_method_from_header() {
5355 use tower::ServiceExt;
5356
5357 let (url, _handle) = start_test_server().await;
5358 let ctx = test_producer_ctx();
5359
5360 let component = HttpComponent::new();
5361 let endpoint_ctx = NoOpComponentContext;
5362 let endpoint = component
5363 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5364 .unwrap();
5365 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5366
5367 let mut exchange = Exchange::new(Message::default());
5368 exchange.input.set_header(
5369 "CamelHttpMethod",
5370 serde_json::Value::String("DELETE".to_string()),
5371 );
5372
5373 let result = producer.oneshot(exchange).await.unwrap();
5374 let status = result
5375 .input
5376 .header("CamelHttpResponseCode")
5377 .and_then(|v| v.as_u64())
5378 .unwrap();
5379 assert_eq!(status, 200);
5380 }
5381
5382 #[tokio::test]
5383 async fn test_http_producer_forced_method() {
5384 use tower::ServiceExt;
5385
5386 let (url, _handle) = start_test_server().await;
5387 let ctx = test_producer_ctx();
5388
5389 let component = HttpComponent::new();
5390 let endpoint_ctx = NoOpComponentContext;
5391 let endpoint = component
5392 .create_endpoint(
5393 &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
5394 &endpoint_ctx,
5395 )
5396 .unwrap();
5397 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5398
5399 let exchange = Exchange::new(Message::default());
5400 let result = producer.oneshot(exchange).await.unwrap();
5401
5402 let status = result
5403 .input
5404 .header("CamelHttpResponseCode")
5405 .and_then(|v| v.as_u64())
5406 .unwrap();
5407 assert_eq!(status, 200);
5408 }
5409
5410 #[tokio::test]
5411 async fn test_http_producer_throw_exception_on_failure() {
5412 use tower::ServiceExt;
5413
5414 let (url, _handle) = start_status_server(404).await;
5415 let ctx = test_producer_ctx();
5416
5417 let component = HttpComponent::new();
5418 let endpoint_ctx = NoOpComponentContext;
5419 let endpoint = component
5420 .create_endpoint(
5421 &format!("{url}/not-found?allowInternal=true"),
5422 &endpoint_ctx,
5423 )
5424 .unwrap();
5425 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5426
5427 let exchange = Exchange::new(Message::default());
5428 let result = producer.oneshot(exchange).await;
5429 assert!(result.is_err());
5430
5431 match result.unwrap_err() {
5432 CamelError::HttpOperationFailed { status_code, .. } => {
5433 assert_eq!(status_code, 404);
5434 }
5435 e => panic!("Expected HttpOperationFailed, got: {e}"),
5436 }
5437 }
5438
5439 #[tokio::test]
5440 async fn test_http_producer_no_throw_on_failure() {
5441 use tower::ServiceExt;
5442
5443 let (url, _handle) = start_status_server(500).await;
5444 let ctx = test_producer_ctx();
5445
5446 let component = HttpComponent::new();
5447 let endpoint_ctx = NoOpComponentContext;
5448 let endpoint = component
5449 .create_endpoint(
5450 &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
5451 &endpoint_ctx,
5452 )
5453 .unwrap();
5454 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5455
5456 let exchange = Exchange::new(Message::default());
5457 let result = producer.oneshot(exchange).await.unwrap();
5458
5459 let status = result
5460 .input
5461 .header("CamelHttpResponseCode")
5462 .and_then(|v| v.as_u64())
5463 .unwrap();
5464 assert_eq!(status, 500);
5465 }
5466
5467 #[tokio::test]
5468 async fn test_http_producer_uri_override() {
5469 use tower::ServiceExt;
5470
5471 let (url, _handle) = start_test_server().await;
5472 let ctx = test_producer_ctx();
5473
5474 let component = HttpComponent::new();
5475 let endpoint_ctx = NoOpComponentContext;
5476 let endpoint = component
5477 .create_endpoint(
5478 "http://localhost:1/does-not-exist?allowInternal=true",
5479 &endpoint_ctx,
5480 )
5481 .unwrap();
5482 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5483
5484 let mut exchange = Exchange::new(Message::default());
5485 exchange.input.set_header(
5486 "CamelHttpUri",
5487 serde_json::Value::String(format!("{url}/api")),
5488 );
5489
5490 let result = producer.oneshot(exchange).await.unwrap();
5491 let status = result
5492 .input
5493 .header("CamelHttpResponseCode")
5494 .and_then(|v| v.as_u64())
5495 .unwrap();
5496 assert_eq!(status, 200);
5497 }
5498
5499 #[tokio::test]
5500 async fn test_http_producer_response_headers_mapped() {
5501 use tower::ServiceExt;
5502
5503 let (url, _handle) = start_test_server().await;
5504 let ctx = test_producer_ctx();
5505
5506 let component = HttpComponent::new();
5507 let endpoint_ctx = NoOpComponentContext;
5508 let endpoint = component
5509 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5510 .unwrap();
5511 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5512
5513 let exchange = Exchange::new(Message::default());
5514 let result = producer.oneshot(exchange).await.unwrap();
5515
5516 assert!(
5517 result.input.header("Content-Type").is_some(),
5518 "Response should have Content-Type header"
5519 );
5520 assert!(result.input.header("CamelHttpResponseText").is_some());
5521 }
5522
5523 async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
5528 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5529 let addr = listener.local_addr().unwrap();
5530 let url = format!("http://127.0.0.1:{}", addr.port());
5531
5532 let handle = tokio::spawn(async move {
5533 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5534 loop {
5535 if let Ok((mut stream, _)) = listener.accept().await {
5536 tokio::spawn(async move {
5537 let mut buf = vec![0u8; 4096];
5538 let n = stream.read(&mut buf).await.unwrap_or(0);
5539 let request = String::from_utf8_lossy(&buf[..n]).to_string();
5540
5541 if request.contains("GET /final") {
5543 let body = r#"{"status":"final"}"#;
5544 let response = format!(
5545 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
5546 body.len(),
5547 body
5548 );
5549 let _ = stream.write_all(response.as_bytes()).await;
5550 } else {
5551 let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
5556 let _ = stream.write_all(response.as_bytes()).await;
5557 }
5558 });
5559 }
5560 }
5561 });
5562
5563 (url, handle)
5564 }
5565
5566 struct CapturedRequest {
5567 method: String,
5568 path: String,
5569 body: Vec<u8>,
5570 content_length: Option<String>,
5571 transfer_encoding: Option<String>,
5572 }
5573
5574 async fn capture_request(stream: &mut tokio::net::TcpStream) -> Option<CapturedRequest> {
5580 use tokio::io::AsyncReadExt;
5581
5582 let mut buf: Vec<u8> = Vec::new();
5584 let mut chunk = [0u8; 4096];
5585 let head_end: usize;
5586 loop {
5587 let n = stream.read(&mut chunk).await.unwrap_or(0);
5588 if n == 0 {
5589 return None;
5590 }
5591 buf.extend_from_slice(&chunk[..n]);
5592 if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
5593 head_end = pos + 4;
5594 break;
5595 }
5596 }
5597
5598 let head = String::from_utf8_lossy(&buf[..head_end]);
5600 let mut lines = head.split("\r\n");
5601 let request_line = lines.next().unwrap_or("");
5602 let mut parts = request_line.split_whitespace();
5603 let method = parts.next().unwrap_or("").to_string();
5604 let path = parts.next().unwrap_or("").to_string();
5605
5606 let mut content_length: Option<String> = None;
5607 let mut transfer_encoding: Option<String> = None;
5608 for line in lines {
5609 if let Some((name, value)) = line.split_once(':') {
5610 let name = name.trim().to_ascii_lowercase();
5611 let value = value.trim().to_string();
5612 if name == "content-length" {
5613 content_length = Some(value);
5614 } else if name == "transfer-encoding" {
5615 transfer_encoding = Some(value);
5616 }
5617 }
5618 }
5619
5620 let body_len: usize = content_length
5622 .as_deref()
5623 .and_then(|v| v.parse::<usize>().ok())
5624 .unwrap_or(0);
5625
5626 let mut body: Vec<u8> = buf[head_end..].to_vec();
5627 while body.len() < body_len {
5628 let n = stream.read(&mut chunk).await.unwrap_or(0);
5629 if n == 0 {
5630 break;
5631 }
5632 body.extend_from_slice(&chunk[..n]);
5633 }
5634 body.truncate(body_len);
5635
5636 Some(CapturedRequest {
5637 method,
5638 path,
5639 body,
5640 content_length,
5641 transfer_encoding,
5642 })
5643 }
5644
5645 async fn start_capture_server() -> (
5650 String,
5651 tokio::task::JoinHandle<()>,
5652 Arc<Mutex<Vec<CapturedRequest>>>,
5653 ) {
5654 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5655 let addr = listener.local_addr().unwrap();
5656 let url = format!("http://127.0.0.1:{}", addr.port());
5657
5658 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
5659 let captured_for_return = Arc::clone(&captured);
5660
5661 let handle = tokio::spawn(async move {
5662 use tokio::io::AsyncWriteExt;
5663 loop {
5664 if let Ok((mut stream, _)) = listener.accept().await {
5665 let captured = Arc::clone(&captured);
5666 tokio::spawn(async move {
5667 let Some(req) = capture_request(&mut stream).await else {
5668 return;
5669 };
5670 captured.lock().unwrap().push(req);
5671
5672 let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
5675 let _ = stream.write_all(response.as_bytes()).await;
5676 });
5677 }
5678 }
5679 });
5680
5681 (url, handle, captured_for_return)
5682 }
5683
5684 async fn start_redirect_capture_server() -> (
5690 String,
5691 tokio::task::JoinHandle<()>,
5692 Arc<Mutex<Vec<CapturedRequest>>>,
5693 ) {
5694 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5695 let addr = listener.local_addr().unwrap();
5696 let url = format!("http://127.0.0.1:{}", addr.port());
5697
5698 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
5699 let captured_for_return = Arc::clone(&captured);
5700
5701 let handle = tokio::spawn(async move {
5702 use tokio::io::AsyncWriteExt;
5703 loop {
5704 if let Ok((mut stream, _)) = listener.accept().await {
5705 let captured = Arc::clone(&captured);
5706 tokio::spawn(async move {
5707 let Some(req) = capture_request(&mut stream).await else {
5708 return;
5709 };
5710 let path = req.path.clone();
5711 captured.lock().unwrap().push(req);
5712
5713 let (status_line, location) = match path.as_str() {
5714 "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
5715 "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
5716 "/final" => ("HTTP/1.1 200 OK", None),
5717 _ => ("HTTP/1.1 404 Not Found", None),
5718 };
5719
5720 let response = match location {
5721 Some(loc) => format!(
5725 "{status_line}\r\nLocation: {loc}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
5726 ),
5727 None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
5728 };
5729 let _ = stream.write_all(response.as_bytes()).await;
5730 });
5731 }
5732 }
5733 });
5734
5735 (url, handle, captured_for_return)
5736 }
5737
5738 #[tokio::test]
5739 async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
5740 use tower::ServiceExt;
5741
5742 let (url, _handle, captured) = start_capture_server().await;
5743 let ctx = test_producer_ctx();
5744
5745 let component = HttpComponent::with_config(HttpConfig::default());
5746 let endpoint_ctx = NoOpComponentContext;
5747 let endpoint = component
5748 .create_endpoint(
5749 &format!("{url}?httpMethod=GET&allowInternal=true"),
5750 &endpoint_ctx,
5751 )
5752 .unwrap();
5753 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5754
5755 let mut exchange = Exchange::new(Message::default());
5756 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5757
5758 let result = producer.oneshot(exchange).await.unwrap();
5759
5760 let status = result
5761 .input
5762 .header("CamelHttpResponseCode")
5763 .and_then(|v| v.as_u64())
5764 .unwrap();
5765 assert_eq!(status, 200);
5766
5767 let captured = captured.lock().unwrap();
5768 assert_eq!(captured.len(), 1, "expected exactly one captured request");
5769 let req = &captured[0];
5770 assert_eq!(req.method, "GET");
5771 assert_eq!(req.path, "/");
5774 assert!(req.body.is_empty(), "GET must not carry a body");
5775 assert!(
5776 req.content_length.is_none(),
5777 "suppressed request must not carry Content-Length"
5778 );
5779 assert!(
5780 req.transfer_encoding.is_none(),
5781 "suppressed request must not carry Transfer-Encoding"
5782 );
5783
5784 assert!(
5786 result.input.body.is_empty(),
5787 "exchange body must be consumed"
5788 );
5789 }
5790
5791 #[tokio::test]
5792 async fn test_head_with_body_suppressed_via_header() {
5793 use tower::ServiceExt;
5794
5795 let (url, _handle, captured) = start_capture_server().await;
5796 let ctx = test_producer_ctx();
5797
5798 let component = HttpComponent::with_config(HttpConfig::default());
5799 let endpoint_ctx = NoOpComponentContext;
5800 let endpoint = component
5801 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5802 .unwrap();
5803 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5804
5805 let mut exchange = Exchange::new(Message::default());
5806 exchange.input.set_header(
5807 "CamelHttpMethod",
5808 serde_json::Value::String("HEAD".to_string()),
5809 );
5810 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5811
5812 let result = producer.oneshot(exchange).await.unwrap();
5813 let status = result
5814 .input
5815 .header("CamelHttpResponseCode")
5816 .and_then(|v| v.as_u64())
5817 .unwrap();
5818 assert_eq!(status, 200);
5819
5820 let captured = captured.lock().unwrap();
5821 assert_eq!(captured.len(), 1);
5822 let req = &captured[0];
5823 assert_eq!(req.method, "HEAD");
5824 assert!(req.body.is_empty(), "HEAD must not carry a body");
5825 }
5826
5827 #[tokio::test]
5828 async fn test_delete_options_trace_with_body_suppressed() {
5829 use tower::ServiceExt;
5830
5831 let (url, _handle, captured) = start_capture_server().await;
5832 let ctx = test_producer_ctx();
5833 let component = HttpComponent::with_config(HttpConfig::default());
5834 let endpoint_ctx = NoOpComponentContext;
5835
5836 for method in ["DELETE", "OPTIONS", "TRACE"] {
5837 let endpoint = component
5838 .create_endpoint(
5839 &format!("{url}?httpMethod={method}&allowInternal=true"),
5840 &endpoint_ctx,
5841 )
5842 .unwrap();
5843 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5844
5845 let mut exchange = Exchange::new(Message::default());
5846 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5847
5848 let result = producer.oneshot(exchange).await.unwrap();
5849 let status = result
5850 .input
5851 .header("CamelHttpResponseCode")
5852 .and_then(|v| v.as_u64())
5853 .unwrap();
5854 assert_eq!(status, 200, "method {method} should succeed");
5855 }
5856
5857 let captured = captured.lock().unwrap();
5858 assert_eq!(captured.len(), 3, "expected three captured requests");
5859 for method in ["DELETE", "OPTIONS", "TRACE"] {
5860 let req = captured
5861 .iter()
5862 .find(|r| r.method == method)
5863 .unwrap_or_else(|| panic!("missing captured request for {method}"));
5864 assert!(req.body.is_empty(), "{} must not carry a body", req.method);
5865 }
5866 }
5867
5868 #[tokio::test]
5869 async fn test_post_put_patch_with_body_still_sent() {
5870 use tower::ServiceExt;
5871
5872 let (url, _handle, captured) = start_capture_server().await;
5873 let ctx = test_producer_ctx();
5874 let component = HttpComponent::with_config(HttpConfig::default());
5875 let endpoint_ctx = NoOpComponentContext;
5876
5877 for method in ["POST", "PUT", "PATCH"] {
5878 let endpoint = component
5879 .create_endpoint(
5880 &format!("{url}?httpMethod={method}&allowInternal=true"),
5881 &endpoint_ctx,
5882 )
5883 .unwrap();
5884 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5885
5886 let payload = format!("body-for-{method}");
5887 let mut exchange = Exchange::new(Message::default());
5888 exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
5889
5890 let result = producer.oneshot(exchange).await.unwrap();
5891 let status = result
5892 .input
5893 .header("CamelHttpResponseCode")
5894 .and_then(|v| v.as_u64())
5895 .unwrap();
5896 assert_eq!(status, 200, "method {method} should succeed");
5897 }
5898
5899 let captured = captured.lock().unwrap();
5900 assert_eq!(captured.len(), 3, "expected three captured requests");
5901 for method in ["POST", "PUT", "PATCH"] {
5902 let req = captured
5903 .iter()
5904 .find(|r| r.method == method)
5905 .unwrap_or_else(|| panic!("missing captured request for {method}"));
5906 let expected = format!("body-for-{method}");
5907 assert!(!req.body.is_empty(), "{method} must still carry its body");
5908 assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
5909 }
5910 }
5911
5912 #[tokio::test]
5916 async fn test_stream_body_under_get_not_attached() {
5917 use tower::ServiceExt;
5918
5919 let (url, _handle, captured) = start_capture_server().await;
5920 let ctx = test_producer_ctx();
5921
5922 let component = HttpComponent::with_config(HttpConfig::default());
5923 let endpoint_ctx = NoOpComponentContext;
5924 let endpoint = component
5925 .create_endpoint(
5926 &format!("{url}?httpMethod=GET&allowInternal=true"),
5927 &endpoint_ctx,
5928 )
5929 .unwrap();
5930 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5931
5932 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
5933 vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
5934 let stream = Box::pin(futures::stream::iter(chunks));
5935 let mut exchange = Exchange::new(Message::default());
5936 exchange.input.body = Body::Stream(StreamBody {
5937 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
5938 metadata: StreamMetadata::default(),
5939 });
5940
5941 let result = producer.oneshot(exchange).await.unwrap();
5942
5943 let status = result
5944 .input
5945 .header("CamelHttpResponseCode")
5946 .and_then(|v| v.as_u64())
5947 .unwrap();
5948 assert_eq!(status, 200);
5949
5950 let captured = captured.lock().unwrap();
5951 assert_eq!(captured.len(), 1, "expected exactly one captured request");
5952 assert!(
5953 captured[0].body.is_empty(),
5954 "GET must not carry a stream body"
5955 );
5956 assert!(
5957 captured[0].transfer_encoding.is_none(),
5958 "suppressed request must not carry Transfer-Encoding"
5959 );
5960 assert!(
5961 captured[0].content_length.is_none(),
5962 "suppressed request must not carry Content-Length"
5963 );
5964 assert!(
5965 result.input.body.is_empty(),
5966 "exchange body must be consumed to Empty, not left as a stream"
5967 );
5968 }
5969
5970 #[tokio::test]
5974 async fn test_redirect_hops_never_replay_suppressed_body() {
5975 use tower::ServiceExt;
5976
5977 let (url, _handle, captured) = start_redirect_capture_server().await;
5978 let ctx = test_producer_ctx();
5979
5980 let component =
5981 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5982 let endpoint_ctx = NoOpComponentContext;
5983
5984 for path in ["/hop307", "/hop308"] {
5985 let endpoint = component
5986 .create_endpoint(
5987 &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
5988 &endpoint_ctx,
5989 )
5990 .unwrap();
5991 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5992
5993 let mut exchange = Exchange::new(Message::default());
5994 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
5995
5996 let result = producer.oneshot(exchange).await.unwrap();
5997 let status = result
5998 .input
5999 .header("CamelHttpResponseCode")
6000 .and_then(|v| v.as_u64())
6001 .unwrap();
6002 assert_eq!(
6003 status, 200,
6004 "redirect chain for {path} should end at /final"
6005 );
6006 }
6007
6008 let captured = captured.lock().unwrap();
6010 assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
6011 for req in captured.iter() {
6012 assert!(
6013 req.body.is_empty(),
6014 "hop {} {} must not carry a body",
6015 req.method,
6016 req.path
6017 );
6018 }
6019 }
6020
6021 #[tracing_test::traced_test]
6030 #[tokio::test]
6031 async fn test_suppressed_body_logs_exactly_one_warn() {
6032 use tower::ServiceExt;
6033
6034 let (url, _handle, _captured) = start_capture_server().await;
6035 let ctx = test_producer_ctx();
6036
6037 let component = HttpComponent::with_config(HttpConfig::default());
6038 let endpoint_ctx = NoOpComponentContext;
6039 let endpoint = component
6040 .create_endpoint(
6041 &format!("{url}?httpMethod=GET&allowInternal=true"),
6042 &endpoint_ctx,
6043 )
6044 .unwrap();
6045 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6046
6047 let mut exchange = Exchange::new(Message::default());
6048 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
6049 let correlation_id = exchange.correlation_id().to_string();
6050
6051 let result = producer.oneshot(exchange).await.unwrap();
6052 let status = result
6053 .input
6054 .header("CamelHttpResponseCode")
6055 .and_then(|v| v.as_u64())
6056 .unwrap();
6057 assert_eq!(status, 200);
6058
6059 logs_assert(|lines: &[&str]| {
6060 let hits = lines
6061 .iter()
6062 .filter(|l| {
6063 l.contains("dropping request body")
6064 && l.contains("method=GET")
6065 && l.contains(&format!("correlation_id={correlation_id}"))
6066 })
6067 .count();
6068 match hits {
6069 1 => Ok(()),
6070 n => Err(format!("expected exactly one body-drop warn, found {n}")),
6071 }
6072 });
6073 }
6074
6075 #[tracing_test::traced_test]
6076 #[tokio::test]
6077 async fn test_empty_body_get_emits_no_warn() {
6078 use tower::ServiceExt;
6079
6080 let (url, _handle, _captured) = start_capture_server().await;
6081 let ctx = test_producer_ctx();
6082
6083 let component = HttpComponent::with_config(HttpConfig::default());
6084 let endpoint_ctx = NoOpComponentContext;
6085 let endpoint = component
6086 .create_endpoint(
6087 &format!("{url}?httpMethod=GET&allowInternal=true"),
6088 &endpoint_ctx,
6089 )
6090 .unwrap();
6091 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6092
6093 let exchange = Exchange::new(Message::default());
6094 let result = producer.oneshot(exchange).await.unwrap();
6095 let status = result
6096 .input
6097 .header("CamelHttpResponseCode")
6098 .and_then(|v| v.as_u64())
6099 .unwrap();
6100 assert_eq!(status, 200);
6101
6102 logs_assert(|lines: &[&str]| {
6103 let hits = lines
6104 .iter()
6105 .filter(|l| l.contains("dropping request body"))
6106 .count();
6107 match hits {
6108 0 => Ok(()),
6109 n => Err(format!("expected no body-drop warn, found {n}")),
6110 }
6111 });
6112 }
6113
6114 #[tokio::test]
6115 async fn test_follow_redirects_false_does_not_follow() {
6116 use tower::ServiceExt;
6117
6118 let (url, _handle) = start_redirect_server().await;
6119 let ctx = test_producer_ctx();
6120
6121 let component =
6122 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
6123 let endpoint_ctx = NoOpComponentContext;
6124 let endpoint = component
6125 .create_endpoint(
6126 &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
6127 &endpoint_ctx,
6128 )
6129 .unwrap();
6130 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6131
6132 let exchange = Exchange::new(Message::default());
6133 let result = producer.oneshot(exchange).await.unwrap();
6134
6135 let status = result
6137 .input
6138 .header("CamelHttpResponseCode")
6139 .and_then(|v| v.as_u64())
6140 .unwrap();
6141 assert_eq!(
6142 status, 302,
6143 "Should NOT follow redirect when followRedirects=false"
6144 );
6145 }
6146
6147 #[tokio::test]
6148 async fn test_follow_redirects_true_follows_redirect() {
6149 use tower::ServiceExt;
6150
6151 let (url, _handle) = start_redirect_server().await;
6152 let ctx = test_producer_ctx();
6153
6154 let component =
6155 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6156 let endpoint_ctx = NoOpComponentContext;
6157 let endpoint = component
6158 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6159 .unwrap();
6160 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6161
6162 let exchange = Exchange::new(Message::default());
6163 let result = producer.oneshot(exchange).await.unwrap();
6164
6165 let status = result
6167 .input
6168 .header("CamelHttpResponseCode")
6169 .and_then(|v| v.as_u64())
6170 .unwrap();
6171 assert_eq!(
6172 status, 200,
6173 "Should follow redirect when followRedirects=true"
6174 );
6175 }
6176
6177 #[tokio::test]
6180 async fn test_redirect_to_private_ip_is_ssrf_blocked() {
6181 use tower::ServiceExt;
6182
6183 let (url, _handle) = start_redirect_server().await;
6185 let ctx = test_producer_ctx();
6186
6187 let component =
6188 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6189 let endpoint_ctx = NoOpComponentContext;
6190 let endpoint = component
6191 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6192 .unwrap();
6193 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6194
6195 let exchange = Exchange::new(Message::default());
6196 let result = producer.oneshot(exchange).await;
6197
6198 assert!(
6200 result.is_ok(),
6201 "Redirect should succeed with allowInternal=true, got: {:?}",
6202 result
6203 );
6204 let exchange = result.unwrap();
6205 let status = exchange
6206 .input
6207 .header("CamelHttpResponseCode")
6208 .and_then(|v| v.as_u64())
6209 .unwrap();
6210 assert_eq!(status, 200, "Should follow redirect to /final");
6211 }
6212
6213 #[tokio::test]
6215 async fn test_redirect_to_private_ip_allowed_when_configured() {
6216 use tower::ServiceExt;
6217
6218 let (url, _handle) = start_redirect_server().await;
6220 let ctx = test_producer_ctx();
6221
6222 let component =
6223 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6224 let endpoint_ctx = NoOpComponentContext;
6225 let endpoint = component
6226 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
6227 .unwrap();
6228 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6229
6230 let exchange = Exchange::new(Message::default());
6231 let result = producer.oneshot(exchange).await.unwrap();
6232
6233 let status = result
6234 .input
6235 .header("CamelHttpResponseCode")
6236 .and_then(|v| v.as_u64())
6237 .unwrap();
6238 assert_eq!(
6239 status, 200,
6240 "Should follow redirect to private IP when allowInternal=true"
6241 );
6242 }
6243
6244 #[tokio::test]
6247 async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
6248 use tower::ServiceExt;
6249
6250 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6252 let addr = listener.local_addr().unwrap();
6253 let url = format!("http://127.0.0.1:{}", addr.port());
6254
6255 let handle = tokio::spawn(async move {
6256 use tokio::io::{AsyncReadExt, AsyncWriteExt};
6257 loop {
6258 if let Ok((mut stream, _)) = listener.accept().await {
6259 tokio::spawn(async move {
6260 let mut buf = vec![0u8; 4096];
6261 let _ = stream.read(&mut buf).await;
6262 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";
6264 let _ = stream.write_all(response.as_bytes()).await;
6265 });
6266 }
6267 }
6268 });
6269
6270 let ctx = test_producer_ctx();
6271 let component =
6272 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6273 let endpoint_ctx = NoOpComponentContext;
6274 let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
6276 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6277
6278 let exchange = Exchange::new(Message::default());
6279 let result = producer.oneshot(exchange).await;
6280
6281 assert!(
6283 result.is_err(),
6284 "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
6285 );
6286 let err = result.unwrap_err().to_string();
6287 assert!(
6288 err.contains("blocked IP")
6289 || err.contains("private IP")
6290 || err.contains("SSRF")
6291 || err.contains("not allowed"),
6292 "Error should mention SSRF/IP blocking, got: {err}"
6293 );
6294
6295 handle.abort();
6296 }
6297
6298 #[tokio::test]
6300 async fn test_too_many_redirects_returns_error() {
6301 use tower::ServiceExt;
6302
6303 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6305 let addr = listener.local_addr().unwrap();
6306 let url = format!("http://127.0.0.1:{}", addr.port());
6307
6308 let handle = tokio::spawn(async move {
6309 use tokio::io::{AsyncReadExt, AsyncWriteExt};
6310 loop {
6311 if let Ok((mut stream, _)) = listener.accept().await {
6312 tokio::spawn(async move {
6313 let mut buf = vec![0u8; 4096];
6314 let _ = stream.read(&mut buf).await;
6315 let response = "HTTP/1.1 302 Found\r\nLocation: /loop\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
6320 let _ = stream.write_all(response.as_bytes()).await;
6321 });
6322 }
6323 }
6324 });
6325
6326 let ctx = test_producer_ctx();
6327 let component =
6328 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
6329 let endpoint_ctx = NoOpComponentContext;
6330 let endpoint = component
6331 .create_endpoint(
6332 &format!("{url}?allowInternal=true&maxRedirects=2"),
6333 &endpoint_ctx,
6334 )
6335 .unwrap();
6336 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6337
6338 let exchange = Exchange::new(Message::default());
6339 let result = producer.oneshot(exchange).await;
6340
6341 match result {
6349 Err(e) => {
6350 let msg = e.to_string();
6352 assert!(
6353 msg.contains("HTTP operation failed") || msg.contains("302"),
6354 "expected redirect-after-exhaustion error, got: {msg}"
6355 );
6356 }
6357 Ok(ex) => {
6358 let response_code = ex
6359 .input
6360 .header("CamelHttpResponseCode")
6361 .and_then(|v| v.as_u64());
6362 assert_eq!(
6363 response_code,
6364 Some(302),
6365 "expected 302 after exhausting redirects"
6366 );
6367 }
6368 }
6369
6370 handle.abort();
6371 }
6372
6373 #[tokio::test]
6374 async fn test_query_params_forwarded_to_http_request() {
6375 use tower::ServiceExt;
6376
6377 let (url, _handle) = start_test_server().await;
6378 let ctx = test_producer_ctx();
6379
6380 let component = HttpComponent::new();
6381 let endpoint_ctx = NoOpComponentContext;
6382 let endpoint = component
6384 .create_endpoint(
6385 &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
6386 &endpoint_ctx,
6387 )
6388 .unwrap();
6389 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6390
6391 let exchange = Exchange::new(Message::default());
6392 let result = producer.oneshot(exchange).await.unwrap();
6393
6394 let status = result
6397 .input
6398 .header("CamelHttpResponseCode")
6399 .and_then(|v| v.as_u64())
6400 .unwrap();
6401 assert_eq!(status, 200);
6402 }
6403
6404 #[test]
6405 fn test_non_camel_query_params_are_forwarded() {
6406 let config = HttpEndpointConfig::from_uri(
6409 "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
6410 )
6411 .unwrap();
6412
6413 assert_eq!(
6416 config.raw_query.as_deref(),
6417 Some("apiKey=secret123&httpMethod=GET&token=abc456")
6418 );
6419 assert!(config.query_params.is_empty());
6420 }
6421
6422 #[test]
6423 fn test_authored_query_bytes_survive_resolve_url() {
6424 let config =
6425 HttpEndpointConfig::from_uri("http://example.com/api?q=hello%20world&tag=a+b").unwrap();
6426 let exchange = Exchange::new(Message::default());
6427
6428 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
6429
6430 assert!(url.contains("q=hello%20world"), "url was: {url}");
6433 assert!(url.contains("tag=a+b"), "url was: {url}");
6434 }
6435
6436 async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
6441 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6442 let addr = listener.local_addr().unwrap();
6443 let url = format!("http://127.0.0.1:{}", addr.port());
6444
6445 let handle = tokio::spawn(async move {
6446 loop {
6447 if let Ok((mut stream, _)) = listener.accept().await {
6448 let delay = delay_ms;
6449 tokio::spawn(async move {
6450 use tokio::io::{AsyncReadExt, AsyncWriteExt};
6451 let mut buf = vec![0u8; 4096];
6452 let _ = stream.read(&mut buf).await;
6453 let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
6455 let _ = stream.write_all(headers.as_bytes()).await;
6456 tokio::time::sleep(Duration::from_millis(delay)).await;
6458 let body = r#"{"status":"slow"}"#;
6459 let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
6460 let _ = stream.write_all(chunk.as_bytes()).await;
6461 });
6462 }
6463 }
6464 });
6465
6466 (url, handle)
6467 }
6468
6469 #[tokio::test]
6470 async fn test_http_producer_timeout() {
6471 use tower::ServiceExt;
6472
6473 let (url, _handle) = start_slow_server(500).await;
6475 let ctx = test_producer_ctx();
6476
6477 let component = HttpComponent::with_config(
6478 HttpConfig::default()
6479 .with_read_timeout_ms(100)
6480 .with_response_timeout_ms(30_000), );
6482 let endpoint_ctx = NoOpComponentContext;
6483 let endpoint = component
6484 .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
6485 .unwrap();
6486 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6487
6488 let exchange = Exchange::new(Message::default());
6489 let result = producer.oneshot(exchange).await;
6490
6491 assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
6492 let err = result.unwrap_err().to_string();
6493 assert!(
6494 err.contains("Read timeout") || err.contains("timeout"),
6495 "Error should mention timeout, got: {}",
6496 err
6497 );
6498 }
6499
6500 #[tokio::test]
6501 async fn test_http_producer_no_timeout_when_fast() {
6502 use tower::ServiceExt;
6503
6504 let (url, _handle) = start_test_server().await;
6505 let ctx = test_producer_ctx();
6506
6507 let component =
6508 HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
6509 let endpoint_ctx = NoOpComponentContext;
6510 let endpoint = component
6511 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
6512 .unwrap();
6513 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6514
6515 let exchange = Exchange::new(Message::default());
6516 let result = producer.oneshot(exchange).await.unwrap();
6517
6518 let status = result
6519 .input
6520 .header("CamelHttpResponseCode")
6521 .and_then(|v| v.as_u64())
6522 .unwrap();
6523 assert_eq!(status, 200);
6524 }
6525
6526 #[tokio::test]
6531 async fn test_http_producer_blocks_metadata_endpoint() {
6532 use tower::ServiceExt;
6533
6534 let ctx = test_producer_ctx();
6535 let component = HttpComponent::new();
6536 let endpoint_ctx = NoOpComponentContext;
6537 let endpoint = component
6538 .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
6539 .unwrap();
6540 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6541
6542 let mut exchange = Exchange::new(Message::default());
6543 exchange.input.set_header(
6544 "CamelHttpUri",
6545 serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
6546 );
6547
6548 let result = producer.oneshot(exchange).await;
6549 assert!(result.is_err(), "Should block AWS metadata endpoint");
6550
6551 let err = result.unwrap_err();
6552 assert!(
6553 err.to_string().contains("Private IP"),
6554 "Error should mention private IP blocking, got: {}",
6555 err
6556 );
6557 }
6558
6559 #[test]
6560 fn test_ssrf_config_defaults() {
6561 let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
6562 assert!(
6563 !config.allow_internal,
6564 "Private IPs should be blocked by default"
6565 );
6566 assert!(
6567 config.blocked_hosts.is_empty(),
6568 "Blocked hosts should be empty by default"
6569 );
6570 }
6571
6572 #[test]
6573 fn test_ssrf_config_allow_internal() {
6574 let config =
6575 HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
6576 assert!(
6577 config.allow_internal,
6578 "Private IPs should be allowed when explicitly set"
6579 );
6580 }
6581
6582 #[test]
6583 fn test_ssrf_config_blocked_hosts() {
6584 let config = HttpEndpointConfig::from_uri(
6585 "http://example.com/api?blockedHosts=evil.com,malware.net",
6586 )
6587 .unwrap();
6588 assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
6589 }
6590
6591 #[tokio::test]
6592 async fn test_http_producer_blocks_localhost() {
6593 use tower::ServiceExt;
6594
6595 let ctx = test_producer_ctx();
6596 let component = HttpComponent::new();
6597 let endpoint_ctx = NoOpComponentContext;
6598 let endpoint = component
6599 .create_endpoint("http://example.com/api", &endpoint_ctx)
6600 .unwrap();
6601 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6602
6603 let mut exchange = Exchange::new(Message::default());
6604 exchange.input.set_header(
6605 "CamelHttpUri",
6606 serde_json::Value::String("http://localhost:8080/internal".to_string()),
6607 );
6608
6609 let result = producer.oneshot(exchange).await;
6610 assert!(result.is_err(), "Should block localhost");
6611 }
6612
6613 #[tokio::test]
6614 async fn test_http_producer_blocks_loopback_ip() {
6615 use tower::ServiceExt;
6616
6617 let ctx = test_producer_ctx();
6618 let component = HttpComponent::new();
6619 let endpoint_ctx = NoOpComponentContext;
6620 let endpoint = component
6621 .create_endpoint("http://example.com/api", &endpoint_ctx)
6622 .unwrap();
6623 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6624
6625 let mut exchange = Exchange::new(Message::default());
6626 exchange.input.set_header(
6627 "CamelHttpUri",
6628 serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
6629 );
6630
6631 let result = producer.oneshot(exchange).await;
6632 assert!(result.is_err(), "Should block loopback IP");
6633 }
6634
6635 #[tokio::test]
6636 async fn test_http_producer_allows_private_ip_when_enabled() {
6637 use tower::ServiceExt;
6638
6639 let ctx = test_producer_ctx();
6640 let component = HttpComponent::new();
6641 let endpoint_ctx = NoOpComponentContext;
6642 let endpoint = component
6645 .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
6646 .unwrap();
6647 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6648
6649 let exchange = Exchange::new(Message::default());
6650
6651 let result = producer.oneshot(exchange).await;
6654 if let Err(ref e) = result {
6656 let err_str = e.to_string();
6657 assert!(
6658 !err_str.contains("Private IP") && !err_str.contains("not allowed"),
6659 "Should not be SSRF error, got: {}",
6660 err_str
6661 );
6662 }
6663 }
6664
6665 #[test]
6670 fn test_http_server_config_parse() {
6671 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
6672 assert_eq!(cfg.host, "0.0.0.0");
6673 assert_eq!(cfg.port, 8080);
6674 assert_eq!(cfg.path, "/orders");
6675 assert_eq!(cfg.max_inflight_requests, 1024);
6676 }
6677
6678 #[test]
6679 fn test_http_server_config_scheme() {
6680 assert_eq!(HttpServerConfig::scheme(), "http");
6682 }
6683
6684 #[test]
6685 fn test_http_server_config_from_components() {
6686 let components = camel_component_api::UriComponents {
6688 scheme: "https".to_string(),
6689 path: "//0.0.0.0:8443/api".to_string(),
6690 params: std::collections::HashMap::from([
6691 ("maxRequestBody".to_string(), "5242880".to_string()),
6692 ("maxInflightRequests".to_string(), "7".to_string()),
6693 ]),
6694 raw_query: None,
6695 };
6696 let cfg = HttpServerConfig::from_components(components).unwrap();
6697 assert_eq!(cfg.host, "0.0.0.0");
6698 assert_eq!(cfg.port, 8443);
6699 assert_eq!(cfg.path, "/api");
6700 assert_eq!(cfg.max_request_body, 5242880);
6701 assert_eq!(cfg.max_inflight_requests, 7);
6702 }
6703
6704 #[test]
6705 fn test_http_server_config_default_path() {
6706 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
6707 assert_eq!(cfg.path, "/");
6708 }
6709
6710 #[test]
6711 fn test_http_server_config_wrong_scheme() {
6712 assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
6713 }
6714
6715 #[test]
6716 fn test_http_server_config_invalid_port() {
6717 assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
6718 }
6719
6720 #[test]
6721 fn test_http_server_config_default_port_by_scheme() {
6722 let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
6724 assert_eq!(cfg_http.port, 80);
6725
6726 let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
6728 assert_eq!(cfg_https.port, 443);
6729 }
6730
6731 #[test]
6732 fn test_request_envelope_and_reply_are_send() {
6733 fn assert_send<T: Send>() {}
6734 assert_send::<RequestEnvelope>();
6735 assert_send::<HttpReply>();
6736 }
6737
6738 #[test]
6743 fn test_server_registry_global_is_singleton() {
6744 let r1 = ServerRegistry::global();
6745 let r2 = ServerRegistry::global();
6746 assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
6747 }
6748
6749 #[allow(clippy::await_holding_lock)]
6750 #[tokio::test]
6751 async fn test_concurrent_get_or_spawn_returns_same_registry() {
6752 let _guard = lock_registry_test_mutex();
6753 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6754 let port = listener.local_addr().unwrap().port();
6755 drop(listener);
6756
6757 let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
6758 Arc::new(std::sync::Mutex::new(Vec::new()));
6759
6760 let mut handles = Vec::new();
6761 for _ in 0..4 {
6762 let results = results.clone();
6763 handles.push(tokio::spawn(async move {
6764 let registry = ServerRegistry::global()
6765 .get_or_spawn(
6766 "127.0.0.1",
6767 port,
6768 2 * 1024 * 1024,
6769 10 * 1024 * 1024,
6770 1024,
6771 test_rt(),
6772 "test-route".into(),
6773 None,
6774 )
6775 .await
6776 .unwrap();
6777 results.lock().unwrap().push(registry);
6778 }));
6779 }
6780
6781 for h in handles {
6782 h.await.unwrap();
6783 }
6784
6785 let registries = results.lock().unwrap();
6786 assert_eq!(registries.len(), 4);
6787 for i in 1..registries.len() {
6788 assert!(
6789 Arc::ptr_eq(®istries[0].inner, ®istries[i].inner),
6790 "all concurrent callers should get same route registry"
6791 );
6792 }
6793 }
6794
6795 #[test]
6796 fn test_server_registry_distinguishes_host_and_port() {
6797 let _guard = lock_registry_test_mutex();
6798 let rt = tokio::runtime::Runtime::new().expect("runtime");
6799 rt.block_on(async {
6800 let registry = ServerRegistry::global();
6801 let d1 = registry
6805 .get_or_spawn(
6806 "127.0.0.1",
6807 0,
6808 1024 * 1024,
6809 10 * 1024 * 1024,
6810 1024,
6811 test_rt(),
6812 "test-route-1".into(),
6813 None,
6814 )
6815 .await;
6816 let d2 = registry
6817 .get_or_spawn(
6818 "0.0.0.0",
6819 0,
6820 1024 * 1024,
6821 10 * 1024 * 1024,
6822 1024,
6823 test_rt(),
6824 "test-route-2".into(),
6825 None,
6826 )
6827 .await;
6828 assert!(d1.is_ok());
6829 assert!(d2.is_ok());
6830 assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
6831 });
6832 }
6833
6834 #[allow(clippy::await_holding_lock)]
6835 #[tokio::test]
6836 async fn test_shared_server_max_request_body_policy_is_deterministic() {
6837 let _guard = lock_registry_test_mutex();
6838 let registry = ServerRegistry::global();
6839 let d1 = registry
6841 .get_or_spawn(
6842 "127.0.0.1",
6843 9991,
6844 1024 * 1024,
6845 10 * 1024 * 1024,
6846 1024,
6847 test_rt(),
6848 "test-route".into(),
6849 None,
6850 )
6851 .await;
6852 assert!(d1.is_ok());
6853
6854 let d2 = registry
6857 .get_or_spawn(
6858 "127.0.0.1",
6859 9991,
6860 2 * 1024 * 1024,
6861 10 * 1024 * 1024,
6862 1024,
6863 test_rt(),
6864 "test-route-2".into(),
6865 None,
6866 )
6867 .await;
6868 assert!(d2.is_err());
6869 let err = d2.unwrap_err();
6870 assert!(
6871 err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
6872 "Expected incompatible maxRequestBody error, got: {}",
6873 err
6874 );
6875 }
6876
6877 #[test]
6878 fn test_server_registry_reset_clears_entries() {
6879 let _guard = lock_registry_test_mutex();
6880 let rt = tokio::runtime::Runtime::new().expect("runtime");
6881 rt.block_on(async {
6882 let d1 = ServerRegistry::global()
6884 .get_or_spawn(
6885 "127.0.0.1",
6886 9992,
6887 1024 * 1024,
6888 10 * 1024 * 1024,
6889 1024,
6890 test_rt(),
6891 "test-route".into(),
6892 None,
6893 )
6894 .await;
6895 assert!(d1.is_ok());
6896
6897 let guard = ServerRegistry::global().inner.lock().expect("lock");
6899 assert!(guard.entries.contains_key(&("127.0.0.1".to_string(), 9992)));
6900 drop(guard);
6901
6902 ServerRegistry::reset();
6904
6905 let guard = ServerRegistry::global().inner.lock().expect("lock");
6907 assert!(
6908 guard.entries.is_empty(),
6909 "registry should be empty after reset, has {} entries",
6910 guard.entries.len()
6911 );
6912 });
6913 }
6914
6915 #[allow(clippy::await_holding_lock)]
6916 #[tokio::test]
6917 async fn registry_rejects_tls_on_plain_port() {
6918 let _guard = lock_registry_test_mutex();
6923 ServerRegistry::reset();
6924 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
6925
6926 let _r1 = ServerRegistry::global()
6928 .get_or_spawn(
6929 "127.0.0.1",
6930 0,
6931 1024,
6932 1024,
6933 16,
6934 Arc::clone(&rt),
6935 "route-1".into(),
6936 None, )
6938 .await;
6939
6940 let result = ServerRegistry::global()
6942 .get_or_spawn(
6943 "127.0.0.1",
6944 0,
6945 1024,
6946 1024,
6947 16,
6948 Arc::clone(&rt),
6949 "route-2".into(),
6950 Some(crate::config::ServerTlsConfig {
6951 cert_path: "/x.pem".into(),
6952 key_path: "/y.pem".into(),
6953 }),
6954 )
6955 .await;
6956 assert!(result.is_err(), "must reject TLS on plain port");
6957 }
6958
6959 #[allow(clippy::await_holding_lock)]
6965 #[tokio::test]
6966 async fn test_unregister_last_http_route_keeps_server_alive() {
6967 let _guard = lock_registry_test_mutex();
6968 ServerRegistry::reset();
6969 let registry = ServerRegistry::global();
6970
6971 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6972 let port = listener.local_addr().unwrap().port();
6973 drop(listener); let rt = test_rt();
6975
6976 let _r1 = registry
6979 .get_or_spawn(
6980 "127.0.0.1",
6981 port,
6982 1024 * 1024,
6983 10 * 1024 * 1024,
6984 16,
6985 rt.clone(),
6986 "test-route-1".into(),
6987 None,
6988 )
6989 .await
6990 .unwrap();
6991 let _r2 = registry
6992 .get_or_spawn(
6993 "127.0.0.1",
6994 port,
6995 1024 * 1024,
6996 10 * 1024 * 1024,
6997 16,
6998 rt,
6999 "test-route-2".into(),
7000 None,
7001 )
7002 .await
7003 .unwrap();
7004
7005 let key = ("127.0.0.1".to_string(), port);
7006 let cell = {
7007 let guard = registry.inner.lock().expect("lock");
7008 guard.entries.get(&key).expect("entry should exist").clone()
7009 };
7010
7011 registry.unregister("127.0.0.1", port).await;
7013 {
7014 let handle = cell
7015 .get()
7016 .expect("handle should still exist after first unregister");
7017 assert!(
7018 !handle.monitor_task.is_finished(),
7019 "monitor task should still be alive after first unregister"
7020 );
7021 }
7022
7023 registry.unregister("127.0.0.1", port).await;
7025 tokio::time::sleep(Duration::from_millis(20)).await;
7026 {
7027 let handle = cell
7028 .get()
7029 .expect("handle should still exist after last unregister");
7030 assert!(
7031 !handle.monitor_task.is_finished(),
7032 "monitor task should still be alive — server is process-lifetime"
7033 );
7034 }
7035
7036 {
7038 let guard = registry.inner.lock().expect("lock");
7039 assert!(
7040 guard.entries.contains_key(&key),
7041 "entry should remain in registry — server kept alive for restart"
7042 );
7043 }
7044 }
7045
7046 async fn clone_fixture_listener() -> (
7055 tokio::net::TcpListener,
7056 std::net::TcpListener,
7057 std::net::SocketAddr,
7058 ) {
7059 let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind std listener");
7060 let probe = l.try_clone().expect("clone probe");
7061 l.set_nonblocking(true).expect("set_nonblocking");
7062 let listener = tokio::net::TcpListener::from_std(l).expect("from_std");
7063 let addr = listener.local_addr().expect("local_addr");
7064 (listener, probe, addr)
7065 }
7066
7067 fn staged_limits() -> (usize, usize, usize) {
7069 (1024 * 1024, 10 * 1024 * 1024, 1024)
7070 }
7071
7072 #[allow(clippy::await_holding_lock)]
7073 #[tokio::test]
7074 async fn staged_listener_first_spawn_serves_without_second_bind() {
7075 let _guard = lock_registry_test_mutex();
7076 ServerRegistry::reset();
7077 let registry = ServerRegistry::global();
7078 let (listener, _probe, addr) = clone_fixture_listener().await;
7079 let port = addr.port();
7080 registry
7081 .stage_listener(listener)
7082 .await
7083 .expect("stage listener");
7084
7085 let (max_req, max_res, max_inflight) = staged_limits();
7086 let routes = registry
7087 .get_or_spawn(
7088 "127.0.0.1",
7089 port,
7090 max_req,
7091 max_res,
7092 max_inflight,
7093 test_rt(),
7094 "staged-first-spawn".into(),
7095 None,
7096 )
7097 .await
7098 .expect("spawn from staged listener must succeed");
7099
7100 assert_eq!(
7101 registry.bound_addr("127.0.0.1", port),
7102 Some(addr),
7103 "served socket must be the staged listener's addr"
7104 );
7105 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__staged_probe__"))
7108 .await
7109 .expect("http request against staged listener must connect");
7110 assert!(
7111 resp.status().as_u16() >= 200,
7112 "any status proves the staged socket serves"
7113 );
7114 drop(routes);
7115 }
7116
7117 #[allow(clippy::await_holding_lock)]
7118 #[tokio::test]
7119 async fn staged_entry_reused_by_second_caller() {
7120 let _guard = lock_registry_test_mutex();
7121 ServerRegistry::reset();
7122 let registry = ServerRegistry::global();
7123 let (listener, _probe, addr) = clone_fixture_listener().await;
7124 let port = addr.port();
7125 registry
7126 .stage_listener(listener)
7127 .await
7128 .expect("stage listener");
7129
7130 let (max_req, max_res, max_inflight) = staged_limits();
7131 let first = registry
7132 .get_or_spawn(
7133 "127.0.0.1",
7134 port,
7135 max_req,
7136 max_res,
7137 max_inflight,
7138 test_rt(),
7139 "staged-reuse-1".into(),
7140 None,
7141 )
7142 .await
7143 .expect("first spawn from staged listener");
7144 let second = registry
7145 .get_or_spawn(
7146 "127.0.0.1",
7147 port,
7148 max_req,
7149 max_res,
7150 max_inflight,
7151 test_rt(),
7152 "staged-reuse-2".into(),
7153 None,
7154 )
7155 .await
7156 .expect("second caller must reuse the entry");
7157 assert_eq!(
7158 registry.bound_addr("127.0.0.1", port),
7159 Some(addr),
7160 "entry reused — bound addr unchanged, no second bind"
7161 );
7162 drop(first);
7163 drop(second);
7164 }
7165
7166 #[allow(clippy::await_holding_lock)]
7167 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7168 async fn staged_race_two_callers_single_resolver() {
7169 let _guard = lock_registry_test_mutex();
7170 ServerRegistry::reset();
7171 let registry = ServerRegistry::global();
7172 let (listener, _probe, addr) = clone_fixture_listener().await;
7173 let port = addr.port();
7174 registry
7175 .stage_listener(listener)
7176 .await
7177 .expect("stage listener");
7178
7179 let (max_req, max_res, max_inflight) = staged_limits();
7184 let (first, second) = tokio::join!(
7185 registry.get_or_spawn(
7186 "127.0.0.1",
7187 port,
7188 max_req,
7189 max_res,
7190 max_inflight,
7191 test_rt(),
7192 "staged-race-1".into(),
7193 None,
7194 ),
7195 registry.get_or_spawn(
7196 "127.0.0.1",
7197 port,
7198 max_req,
7199 max_res,
7200 max_inflight,
7201 test_rt(),
7202 "staged-race-2".into(),
7203 None,
7204 ),
7205 );
7206 let first = first.expect("first racing caller must succeed");
7207 let second = second.expect("second racing caller must succeed");
7208 assert_eq!(
7209 registry.bound_addr("127.0.0.1", port),
7210 Some(addr),
7211 "single entry must be served from the staged socket — no EADDRINUSE path"
7212 );
7213 drop(first);
7214 drop(second);
7215 }
7216
7217 #[allow(clippy::await_holding_lock)]
7218 #[tokio::test]
7219 async fn unstaged_spawn_binds_legacy() {
7220 let _guard = lock_registry_test_mutex();
7221 ServerRegistry::reset();
7222 let registry = ServerRegistry::global();
7223 let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe bind");
7225 let port = probe.local_addr().expect("local addr").port();
7226 drop(probe);
7227
7228 let (max_req, max_res, max_inflight) = staged_limits();
7229 registry
7230 .get_or_spawn(
7231 "127.0.0.1",
7232 port,
7233 max_req,
7234 max_res,
7235 max_inflight,
7236 test_rt(),
7237 "legacy-bind".into(),
7238 None,
7239 )
7240 .await
7241 .expect("legacy bind spawn");
7242 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__legacy_probe__"))
7243 .await
7244 .expect("connect to freshly bound port must succeed");
7245 assert!(resp.status().as_u16() >= 200);
7246 assert_eq!(
7247 registry.bound_addr("127.0.0.1", port),
7248 Some(std::net::SocketAddr::from(([127, 0, 0, 1], port))),
7249 "bound addr must be the legacy bound (host, port)"
7250 );
7251 }
7252
7253 #[allow(clippy::await_holding_lock)]
7254 #[tokio::test]
7255 async fn wrong_host_staged_port_fails_deterministically() {
7256 let _guard = lock_registry_test_mutex();
7257 ServerRegistry::reset();
7258 let registry = ServerRegistry::global();
7259 let (listener, _probe, addr) = clone_fixture_listener().await;
7260 let port = addr.port();
7261 registry
7262 .stage_listener(listener)
7263 .await
7264 .expect("stage listener under 127.0.0.1");
7265
7266 let (max_req, max_res, max_inflight) = staged_limits();
7267 let err = registry
7268 .get_or_spawn(
7269 "localhost",
7270 port,
7271 max_req,
7272 max_res,
7273 max_inflight,
7274 test_rt(),
7275 "conflict-probe".into(),
7276 None,
7277 )
7278 .await
7279 .expect_err("wrong host on staged port must fail deterministically");
7280 assert!(
7281 err.to_string().contains("staged listener conflict on port"),
7282 "unexpected error: {err}"
7283 );
7284
7285 registry
7287 .get_or_spawn(
7288 "127.0.0.1",
7289 port,
7290 max_req,
7291 max_res,
7292 max_inflight,
7293 test_rt(),
7294 "conflict-after".into(),
7295 None,
7296 )
7297 .await
7298 .expect("correct host must serve the staged listener");
7299 assert_eq!(
7300 registry.bound_addr("127.0.0.1", port),
7301 Some(addr),
7302 "staged slot must be untouched by the conflicting call"
7303 );
7304 }
7305
7306 #[allow(clippy::await_holding_lock)]
7307 #[tokio::test]
7308 async fn duplicate_stage_same_key_rejected() {
7309 let _guard = lock_registry_test_mutex();
7310 ServerRegistry::reset();
7311 let registry = ServerRegistry::global();
7312 let (listener, probe, addr) = clone_fixture_listener().await;
7313 registry
7314 .stage_listener(listener)
7315 .await
7316 .expect("stage listener A");
7317
7318 let dup = probe.try_clone().expect("clone2");
7320 dup.set_nonblocking(true).expect("set_nonblocking2");
7321 let b = tokio::net::TcpListener::from_std(dup).expect("from_std2");
7322
7323 let err = registry
7324 .stage_listener(b)
7325 .await
7326 .expect_err("duplicate stage must be rejected");
7327 assert!(
7328 err.to_string().contains("listener already staged"),
7329 "unexpected error: {err}"
7330 );
7331
7332 let (max_req, max_res, max_inflight) = staged_limits();
7333 registry
7334 .get_or_spawn(
7335 "127.0.0.1",
7336 addr.port(),
7337 max_req,
7338 max_res,
7339 max_inflight,
7340 test_rt(),
7341 "dup-stage-after".into(),
7342 None,
7343 )
7344 .await
7345 .expect("spawn from first staged listener");
7346 assert_eq!(
7347 registry.bound_addr("127.0.0.1", addr.port()),
7348 Some(addr),
7349 "first staged listener retained"
7350 );
7351 }
7352
7353 #[allow(clippy::await_holding_lock)]
7354 #[tokio::test]
7355 async fn distinct_keys_stage_independently() {
7356 let _guard = lock_registry_test_mutex();
7357 ServerRegistry::reset();
7358 let registry = ServerRegistry::global();
7359 let (l1, _p1, addr1) = clone_fixture_listener().await;
7360 let (l2, _p2, addr2) = clone_fixture_listener().await;
7361 registry.stage_listener(l1).await.expect("stage P1");
7362 registry.stage_listener(l2).await.expect("stage P2");
7363
7364 let (max_req, max_res, max_inflight) = staged_limits();
7365 registry
7366 .get_or_spawn(
7367 "127.0.0.1",
7368 addr1.port(),
7369 max_req,
7370 max_res,
7371 max_inflight,
7372 test_rt(),
7373 "distinct-1".into(),
7374 None,
7375 )
7376 .await
7377 .expect("spawn P1");
7378 registry
7379 .get_or_spawn(
7380 "127.0.0.1",
7381 addr2.port(),
7382 max_req,
7383 max_res,
7384 max_inflight,
7385 test_rt(),
7386 "distinct-2".into(),
7387 None,
7388 )
7389 .await
7390 .expect("spawn P2");
7391 assert_eq!(
7392 registry.bound_addr("127.0.0.1", addr1.port()),
7393 Some(addr1),
7394 "P1 bound addr must be its own listener"
7395 );
7396 assert_eq!(
7397 registry.bound_addr("127.0.0.1", addr2.port()),
7398 Some(addr2),
7399 "P2 bound addr must be its own listener"
7400 );
7401 let r1 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr1.port()))
7402 .await
7403 .expect("connect P1");
7404 assert!(r1.status().as_u16() >= 200);
7405 let r2 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr2.port()))
7406 .await
7407 .expect("connect P2");
7408 assert!(r2.status().as_u16() >= 200);
7409 }
7410
7411 #[allow(clippy::await_holding_lock)]
7412 #[tokio::test]
7413 async fn tls_prebound_listener_served() {
7414 use camel_component_api::test_support::tls;
7415
7416 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7419
7420 let _guard = lock_registry_test_mutex();
7421 ServerRegistry::reset();
7422 let registry = ServerRegistry::global();
7423 let (listener, _probe, addr) = clone_fixture_listener().await;
7424 let port = addr.port();
7425
7426 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
7427 let cert_path = tls::write_pem_tmp("http-staged-tls-cert.pem", &cert_pem);
7428 let key_path = tls::write_pem_tmp("http-staged-tls-key.pem", &key_pem);
7429 let ca_path = tls::write_pem_tmp("http-staged-tls-ca.pem", &ca_pem);
7430
7431 let (max_req, max_res, max_inflight) = staged_limits();
7432 let routes = registry
7433 .get_or_spawn_with_listener(
7434 listener,
7435 max_req,
7436 max_res,
7437 max_inflight,
7438 test_rt(),
7439 "staged-tls".into(),
7440 Some(crate::config::ServerTlsConfig {
7441 cert_path: cert_path.to_string_lossy().into_owned(),
7442 key_path: key_path.to_string_lossy().into_owned(),
7443 }),
7444 )
7445 .await
7446 .expect("spawn TLS server from pre-bound listener");
7447
7448 let ca_bytes = std::fs::read(&ca_path).expect("read ca pem");
7451 let client = reqwest::Client::builder()
7452 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).expect("parse ca pem"))
7453 .build()
7454 .expect("build tls client");
7455
7456 let resp = client
7457 .get(format!("https://127.0.0.1:{port}/__staged_tls__"))
7458 .send()
7459 .await
7460 .expect("TLS handshake + request must succeed");
7461 assert_eq!(resp.status().as_u16(), 404, "unknown path 404s through TLS");
7462 assert_eq!(
7463 registry.bound_addr("127.0.0.1", port),
7464 Some(addr),
7465 "bound addr equals the pre-bound listener addr"
7466 );
7467 drop(routes);
7468 }
7469
7470 #[allow(clippy::await_holding_lock)]
7471 #[tokio::test]
7472 async fn with_listener_direct_spawn_keyed_by_actual_addr() {
7473 let _guard = lock_registry_test_mutex();
7474 ServerRegistry::reset();
7475 let registry = ServerRegistry::global();
7476 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
7477 .await
7478 .expect("bind un-staged listener");
7479 let addr = listener.local_addr().expect("local addr");
7480 let port = addr.port();
7481
7482 let (max_req, max_res, max_inflight) = staged_limits();
7483 registry
7484 .get_or_spawn_with_listener(
7485 listener,
7486 max_req,
7487 max_res,
7488 max_inflight,
7489 test_rt(),
7490 "with-listener".into(),
7491 None,
7492 )
7493 .await
7494 .expect("direct spawn from un-staged listener");
7495 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__with_listener__"))
7496 .await
7497 .expect("connect on actual port");
7498 assert!(resp.status().as_u16() >= 200);
7499 assert_eq!(
7500 registry.bound_addr("127.0.0.1", port),
7501 Some(addr),
7502 "registry key is the listener's actual port"
7503 );
7504
7505 registry
7506 .get_or_spawn(
7507 "127.0.0.1",
7508 port,
7509 max_req,
7510 max_res,
7511 max_inflight,
7512 test_rt(),
7513 "with-listener-reuse".into(),
7514 None,
7515 )
7516 .await
7517 .expect("legacy caller must reuse the entry");
7518 assert_eq!(
7519 registry.bound_addr("127.0.0.1", port),
7520 Some(addr),
7521 "entry reused — no second bind"
7522 );
7523 }
7524
7525 #[tokio::test]
7530 async fn test_dispatch_handler_returns_404_for_unknown_path() {
7531 let registry = HttpRouteRegistry::new();
7532 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7534 let port = listener.local_addr().unwrap().port();
7535 tokio::spawn(run_axum_server(
7536 listener,
7537 registry,
7538 2 * 1024 * 1024,
7539 10 * 1024 * 1024,
7540 Arc::new(tokio::sync::Semaphore::new(1024)),
7541 test_rt(),
7542 "test-route".into(),
7543 ));
7544
7545 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
7547
7548 let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
7549 .await
7550 .unwrap();
7551 assert_eq!(resp.status().as_u16(), 404);
7552 }
7553
7554 #[tokio::test]
7559 async fn test_http_consumer_start_registers_path() {
7560 use camel_component_api::ConsumerContext;
7561
7562 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7564 let port = listener.local_addr().unwrap().port();
7565 drop(listener); let consumer_cfg = HttpServerConfig {
7568 scheme: "http".to_string(),
7569 host: "127.0.0.1".to_string(),
7570 port,
7571 path: "/ping".to_string(),
7572 max_request_body: 2 * 1024 * 1024,
7573 max_response_body: 10 * 1024 * 1024,
7574 max_inflight_requests: 1024,
7575 method: None,
7576 tls_config: None,
7577 };
7578 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7579
7580 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7581 let token = tokio_util::sync::CancellationToken::new();
7582 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7583
7584 tokio::spawn(async move {
7585 consumer.start(ctx).await.unwrap();
7586 });
7587
7588 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7589
7590 let client = reqwest::Client::new();
7591 let resp_future = client
7592 .post(format!("http://127.0.0.1:{port}/ping"))
7593 .body("hello world")
7594 .send();
7595
7596 let (http_result, _) = tokio::join!(resp_future, async {
7597 if let Some(mut envelope) = rx.recv().await {
7598 envelope.exchange.input.set_header(
7600 "CamelHttpResponseCode",
7601 serde_json::Value::Number(201.into()),
7602 );
7603 if let Some(reply_tx) = envelope.reply_tx {
7604 let _ = reply_tx.send(Ok(envelope.exchange));
7605 }
7606 }
7607 });
7608
7609 let resp = http_result.unwrap();
7610 assert_eq!(resp.status().as_u16(), 201);
7611
7612 token.cancel();
7613 }
7614
7615 #[test]
7619 fn test_envelope_channel_capacity_follows_max_inflight() {
7620 assert_eq!(envelope_channel_capacity(0), 1);
7621 assert_eq!(envelope_channel_capacity(1), 1);
7622 assert_eq!(envelope_channel_capacity(7), 7);
7623 assert_eq!(envelope_channel_capacity(64), 64);
7624 assert_eq!(envelope_channel_capacity(1024), 1024);
7625 }
7626
7627 #[tokio::test]
7631 async fn test_http_consumer_start_with_zero_max_inflight_rejects_503() {
7632 use camel_component_api::ConsumerContext;
7633
7634 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7635 let port = listener.local_addr().unwrap().port();
7636 drop(listener);
7637
7638 let consumer_cfg = HttpServerConfig {
7639 scheme: "http".to_string(),
7640 host: "127.0.0.1".to_string(),
7641 port,
7642 path: "/ping".to_string(),
7643 max_request_body: 2 * 1024 * 1024,
7644 max_response_body: 10 * 1024 * 1024,
7645 max_inflight_requests: 0,
7646 method: None,
7647 tls_config: None,
7648 };
7649 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7650
7651 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7652 let token = tokio_util::sync::CancellationToken::new();
7653 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7654
7655 let start_handle = tokio::spawn(async move {
7656 consumer.start(ctx).await.unwrap();
7657 });
7658
7659 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7660
7661 let client = reqwest::Client::new();
7662 let resp = client
7663 .post(format!("http://127.0.0.1:{port}/ping"))
7664 .body("hello world")
7665 .send()
7666 .await
7667 .unwrap();
7668 assert_eq!(resp.status().as_u16(), 503);
7669
7670 token.cancel();
7671 let _ = start_handle.await;
7672 }
7673
7674 #[test]
7677 fn test_http_consumer_startup_mode_is_explicit() {
7678 use camel_component_api::ConsumerStartupMode;
7679 let consumer_cfg = HttpServerConfig {
7680 scheme: "http".to_string(),
7681 host: "127.0.0.1".to_string(),
7682 port: 0,
7683 path: "/x".to_string(),
7684 max_request_body: 2 * 1024 * 1024,
7685 max_response_body: 10 * 1024 * 1024,
7686 max_inflight_requests: 1024,
7687 method: None,
7688 tls_config: None,
7689 };
7690 let consumer = HttpConsumer::new(consumer_cfg, test_rt());
7691 assert_eq!(
7692 consumer.startup_mode(),
7693 ConsumerStartupMode::Explicit,
7694 "HttpConsumer must opt into Explicit startup"
7695 );
7696 }
7697
7698 #[allow(clippy::await_holding_lock)]
7704 #[tokio::test]
7705 async fn test_http_consumer_emits_mark_ready_after_bind() {
7706 use camel_component_api::{ConsumerContext, StartupSignal};
7707
7708 let _guard = lock_registry_test_mutex();
7709
7710 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7711 let port = listener.local_addr().unwrap().port();
7712 drop(listener);
7713
7714 let consumer_cfg = HttpServerConfig {
7715 scheme: "http".to_string(),
7716 host: "127.0.0.1".to_string(),
7717 port,
7718 path: "/ready-probe".to_string(),
7719 max_request_body: 2 * 1024 * 1024,
7720 max_response_body: 10 * 1024 * 1024,
7721 max_inflight_requests: 1024,
7722 method: None,
7723 tls_config: None,
7724 };
7725 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7726
7727 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7728 let token = tokio_util::sync::CancellationToken::new();
7729 let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
7730
7731 let (signal, startup_rx) = StartupSignal::pair();
7733 let ctx = ctx.with_startup(signal);
7734
7735 tokio::spawn(async move {
7738 let _ = consumer.start(ctx).await;
7739 });
7740
7741 let result =
7746 tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
7747 .await
7748 .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
7749 assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
7750
7751 token.cancel();
7753 }
7754
7755 #[derive(Default, Clone)]
7762 struct ErrorRecordingRuntime {
7763 errors: std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>>,
7764 }
7765
7766 impl camel_api::MetricsCollector for ErrorRecordingRuntime {
7767 fn record_exchange_duration(&self, _route_id: &str, _duration: std::time::Duration) {}
7768 fn increment_errors(&self, route_id: &str, error_type: &str) {
7769 self.errors
7770 .lock()
7771 .expect("error recorder lock")
7772 .push((route_id.to_string(), error_type.to_string()));
7773 }
7774 fn increment_exchanges(&self, _route_id: &str) {}
7775 fn set_queue_depth(&self, _queue: &str, _depth: usize) {}
7776 fn record_circuit_breaker_change(&self, _route_id: &str, _from: &str, _to: &str) {}
7777 }
7778
7779 impl camel_component_api::HealthCheckRegistry for ErrorRecordingRuntime {
7780 fn force_unhealthy_for_route(&self, _route_id: &str, _name: &str, _reason: &str) {}
7781 }
7782
7783 impl camel_component_api::RuntimeObservability for ErrorRecordingRuntime {
7784 fn metrics(&self) -> std::sync::Arc<dyn camel_api::MetricsCollector> {
7785 std::sync::Arc::new(self.clone())
7786 }
7787 fn health(&self) -> std::sync::Arc<dyn camel_component_api::HealthCheckRegistry> {
7788 std::sync::Arc::new(self.clone())
7789 }
7790 }
7791
7792 #[allow(clippy::await_holding_lock)]
7807 #[tokio::test]
7808 async fn shared_server_death_fails_every_hosted_consumer() {
7809 use camel_component_api::{ConsumerContext, StartupSignal};
7810
7811 let _guard = lock_registry_test_mutex();
7812 ServerRegistry::reset();
7813
7814 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7816 let port = listener.local_addr().unwrap().port();
7817 drop(listener);
7818
7819 let rt = ErrorRecordingRuntime::default();
7820
7821 let make_consumer = |path: &str| {
7822 HttpConsumer::new(
7823 HttpServerConfig {
7824 scheme: "http".to_string(),
7825 host: "127.0.0.1".to_string(),
7826 port,
7827 path: path.to_string(),
7828 max_request_body: 2 * 1024 * 1024,
7829 max_response_body: 10 * 1024 * 1024,
7830 max_inflight_requests: 16,
7831 method: None,
7832 tls_config: None,
7833 },
7834 std::sync::Arc::new(rt.clone()),
7835 )
7836 };
7837
7838 let spawn_consumer = |path: &str, route_id: &str| {
7839 let mut consumer = make_consumer(path);
7840 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7841 let token = tokio_util::sync::CancellationToken::new();
7842 let ctx = ConsumerContext::new(tx, token, route_id.to_string());
7843 let (signal, startup_rx) = StartupSignal::pair();
7844 let ctx = ctx.with_startup(signal);
7845 let task = tokio::spawn(async move { consumer.start(ctx).await });
7846 (task, startup_rx)
7847 };
7848
7849 let (task_a, ready_a) = spawn_consumer("/zombie-a", "zombie-route-a");
7851 let (task_b, ready_b) = spawn_consumer("/zombie-b", "zombie-route-b");
7852
7853 for (name, ready) in [("a", ready_a), ("b", ready_b)] {
7855 let result =
7856 tokio::time::timeout(std::time::Duration::from_secs(2), ready.await_ready())
7857 .await
7858 .unwrap_or_else(|_| panic!("consumer {name} never became ready"));
7859 assert!(
7860 result.is_ok(),
7861 "consumer {name} readiness must resolve Ok (bind + registration complete)"
7862 );
7863 }
7864
7865 {
7868 let registry = ServerRegistry::global();
7869 let guard = registry.inner.lock().expect("ServerRegistry lock");
7870 let cell = guard
7871 .entries
7872 .get(&("127.0.0.1".to_string(), port))
7873 .expect("shared server entry must exist");
7874 let handle = cell.get().expect("server handle must be initialized");
7875 handle.server_abort.abort();
7876 }
7877
7878 let outcome_a = tokio::time::timeout(std::time::Duration::from_secs(2), task_a)
7881 .await
7882 .expect("ZOMBIE: consumer-a still running after shared server death (rc-szmob)");
7883 let outcome_b = tokio::time::timeout(std::time::Duration::from_secs(2), task_b)
7884 .await
7885 .expect("ZOMBIE: consumer-b still running after shared server death (rc-szmob)");
7886
7887 let err_a = outcome_a
7888 .expect("consumer-a task must join")
7889 .expect_err("consumer-a start() must return Err when the shared server dies");
7890 let err_b = outcome_b
7891 .expect("consumer-b task must join")
7892 .expect_err("consumer-b start() must return Err when the shared server dies");
7893
7894 for (name, err) in [("a", &err_a), ("b", &err_b)] {
7897 assert!(
7898 err.to_string().contains("127.0.0.1")
7899 && err.to_string().contains(&port.to_string()),
7900 "consumer-{name} error must name the dead shared server, got: {err}"
7901 );
7902 }
7903
7904 let recorded = rt.errors.lock().expect("error recorder lock").clone();
7907 assert!(
7908 recorded
7909 .iter()
7910 .any(|(route, label)| label == "e:http:server-task-exited"
7911 && route == "zombie-route-a"),
7912 "expected e:http:server-task-exited for the spawning route, got: {recorded:?}"
7913 );
7914 }
7915
7916 #[tokio::test]
7917 async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
7918 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7919
7920 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7921 let port = listener.local_addr().unwrap().port();
7922 drop(listener);
7923
7924 let consumer_cfg = HttpServerConfig {
7925 scheme: "http".to_string(),
7926 host: "127.0.0.1".to_string(),
7927 port,
7928 path: "/saturation".to_string(),
7929 max_request_body: 2 * 1024 * 1024,
7930 max_response_body: 10 * 1024 * 1024,
7931 max_inflight_requests: 1,
7932 method: None,
7933 tls_config: None,
7934 };
7935 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7936
7937 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7938 let token = tokio_util::sync::CancellationToken::new();
7939 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7940 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7941 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7942
7943 let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
7944 let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
7945
7946 tokio::spawn(async move {
7947 let mut first_seen_tx = Some(first_seen_tx);
7948 let mut unblock_first_rx = Some(unblock_first_rx);
7949
7950 while let Some(envelope) = rx.recv().await {
7951 if let Some(tx) = first_seen_tx.take() {
7952 let _ = tx.send(());
7953 if let Some(rx_unblock) = unblock_first_rx.take() {
7954 let _ = rx_unblock.await;
7955 }
7956 }
7957
7958 if let Some(reply_tx) = envelope.reply_tx {
7959 let _ = reply_tx.send(Ok(envelope.exchange));
7960 }
7961 }
7962 });
7963
7964 let client = reqwest::Client::new();
7965 let first_req = {
7966 let client = client.clone();
7967 async move {
7968 client
7969 .get(format!("http://127.0.0.1:{port}/saturation"))
7970 .send()
7971 .await
7972 .unwrap()
7973 }
7974 };
7975
7976 let first_handle = tokio::spawn(first_req);
7977 first_seen_rx.await.unwrap();
7978
7979 let second_resp = client
7980 .get(format!("http://127.0.0.1:{port}/saturation"))
7981 .send()
7982 .await
7983 .unwrap();
7984
7985 assert_eq!(second_resp.status().as_u16(), 503);
7986
7987 let _ = unblock_first_tx.send(());
7988 let first_resp = first_handle.await.unwrap();
7989 assert_eq!(first_resp.status().as_u16(), 200);
7990
7991 token.cancel();
7992 }
7993
7994 #[tokio::test]
7998 async fn test_http_consumer_chunked_body_is_capped() {
7999 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8000
8001 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8002 let port = listener.local_addr().unwrap().port();
8003 drop(listener);
8004
8005 let consumer_cfg = HttpServerConfig {
8006 scheme: "http".to_string(),
8007 host: "127.0.0.1".to_string(),
8008 port,
8009 path: "/chunked-cap".to_string(),
8010 max_request_body: 1024, max_response_body: 10 * 1024 * 1024,
8012 max_inflight_requests: 16,
8013 method: None,
8014 tls_config: None,
8015 };
8016 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8017
8018 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8019 let token = tokio_util::sync::CancellationToken::new();
8020 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8021 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8022 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8023
8024 let chunks: Vec<Result<bytes::Bytes, std::io::Error>> = (0..8)
8026 .map(|_| Ok(bytes::Bytes::from(vec![b'A'; 512])))
8027 .collect();
8028 let stream_body = reqwest::Body::wrap_stream(futures::stream::iter(chunks));
8029
8030 let client = reqwest::Client::new();
8031 let send_fut = client
8032 .post(format!("http://127.0.0.1:{port}/chunked-cap"))
8033 .body(stream_body)
8034 .send();
8035
8036 let (http_result, _) = tokio::join!(send_fut, async {
8037 if let Some(mut envelope) = rx.recv().await {
8038 let materialized = envelope
8040 .exchange
8041 .input
8042 .body
8043 .clone()
8044 .into_bytes(64 * 1024)
8045 .await;
8046 assert!(
8047 materialized.is_err(),
8048 "materializing a 4 KiB chunked body under a 1 KiB cap must fail"
8049 );
8050 let err = materialized.unwrap_err().to_string();
8051 assert!(
8052 err.contains("limit") || err.contains("exceeds"),
8053 "error should mention the limit: {err}"
8054 );
8055 if let Some(reply_tx) = envelope.reply_tx {
8056 envelope.exchange.input.body =
8057 camel_component_api::Body::Text("handled".to_string());
8058 let _ = reply_tx.send(Ok(envelope.exchange));
8059 }
8060 }
8061 });
8062
8063 let resp = http_result.unwrap();
8064 assert_eq!(resp.status().as_u16(), 200);
8065
8066 token.cancel();
8067 }
8068
8069 #[tokio::test]
8070 #[allow(clippy::await_holding_lock)]
8071 async fn test_http_consumer_enforces_max_response_body_for_bytes() {
8072 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8073
8074 let _guard = lock_registry_test_mutex();
8075
8076 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8077 let port = listener.local_addr().unwrap().port();
8078 drop(listener);
8079
8080 let consumer_cfg = HttpServerConfig {
8081 scheme: "http".to_string(),
8082 host: "127.0.0.1".to_string(),
8083 port,
8084 path: "/limit-bytes".to_string(),
8085 max_request_body: 2 * 1024 * 1024,
8086 max_response_body: 16,
8087 max_inflight_requests: 1024,
8088 method: None,
8089 tls_config: None,
8090 };
8091 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8092
8093 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8094 let token = tokio_util::sync::CancellationToken::new();
8095 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8096 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8097 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8098
8099 let client = reqwest::Client::new();
8100 let send_fut = client
8101 .get(format!("http://127.0.0.1:{port}/limit-bytes"))
8102 .send();
8103
8104 let (http_result, _) = tokio::join!(send_fut, async {
8105 if let Some(mut envelope) = rx.recv().await {
8106 envelope.exchange.input.body =
8107 camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
8108 if let Some(reply_tx) = envelope.reply_tx {
8109 let _ = reply_tx.send(Ok(envelope.exchange));
8110 }
8111 }
8112 });
8113
8114 let resp = http_result.unwrap();
8115 assert_eq!(resp.status().as_u16(), 500);
8116 let body = resp.text().await.unwrap();
8117 assert_eq!(body, "Response body exceeds configured limit");
8118 token.cancel();
8119 }
8120
8121 #[tokio::test]
8122 #[allow(clippy::await_holding_lock)]
8123 async fn test_http_consumer_enforces_max_response_body_for_json() {
8124 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8125
8126 let _guard = lock_registry_test_mutex();
8127
8128 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8129 let port = listener.local_addr().unwrap().port();
8130 drop(listener);
8131
8132 let consumer_cfg = HttpServerConfig {
8133 scheme: "http".to_string(),
8134 host: "127.0.0.1".to_string(),
8135 port,
8136 path: "/limit-json".to_string(),
8137 max_request_body: 2 * 1024 * 1024,
8138 max_response_body: 16,
8139 max_inflight_requests: 1024,
8140 method: None,
8141 tls_config: None,
8142 };
8143 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8144
8145 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8146 let token = tokio_util::sync::CancellationToken::new();
8147 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8148 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8149 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8150
8151 let client = reqwest::Client::new();
8152 let send_fut = client
8153 .get(format!("http://127.0.0.1:{port}/limit-json"))
8154 .send();
8155
8156 let (http_result, _) = tokio::join!(send_fut, async {
8157 if let Some(mut envelope) = rx.recv().await {
8158 envelope.exchange.input.body = camel_component_api::Body::Json(
8159 serde_json::json!({"message":"this response is bigger than sixteen"}),
8160 );
8161 if let Some(reply_tx) = envelope.reply_tx {
8162 let _ = reply_tx.send(Ok(envelope.exchange));
8163 }
8164 }
8165 });
8166
8167 let resp = http_result.unwrap();
8168 assert_eq!(resp.status().as_u16(), 500);
8169 let body = resp.text().await.unwrap();
8170 assert_eq!(body, "Response body exceeds configured limit");
8171 token.cancel();
8172 }
8173
8174 #[tokio::test]
8175 #[allow(clippy::await_holding_lock)]
8176 async fn test_http_consumer_enforces_max_response_body_for_xml() {
8177 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8178
8179 let _guard = lock_registry_test_mutex();
8180
8181 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8182 let port = listener.local_addr().unwrap().port();
8183 drop(listener);
8184
8185 let consumer_cfg = HttpServerConfig {
8186 scheme: "http".to_string(),
8187 host: "127.0.0.1".to_string(),
8188 port,
8189 path: "/limit-xml".to_string(),
8190 max_request_body: 2 * 1024 * 1024,
8191 max_response_body: 16,
8192 max_inflight_requests: 1024,
8193 method: None,
8194 tls_config: None,
8195 };
8196 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8197
8198 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8199 let token = tokio_util::sync::CancellationToken::new();
8200 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8201 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8202 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8203
8204 let client = reqwest::Client::new();
8205 let send_fut = client
8206 .get(format!("http://127.0.0.1:{port}/limit-xml"))
8207 .send();
8208
8209 let (http_result, _) = tokio::join!(send_fut, async {
8210 if let Some(mut envelope) = rx.recv().await {
8211 envelope.exchange.input.body = camel_component_api::Body::Xml(
8212 "<root><value>way-too-large</value></root>".into(),
8213 );
8214 if let Some(reply_tx) = envelope.reply_tx {
8215 let _ = reply_tx.send(Ok(envelope.exchange));
8216 }
8217 }
8218 });
8219
8220 let resp = http_result.unwrap();
8221 assert_eq!(resp.status().as_u16(), 500);
8222 let body = resp.text().await.unwrap();
8223 assert_eq!(body, "Response body exceeds configured limit");
8224 token.cancel();
8225 }
8226
8227 #[tokio::test]
8228 #[allow(clippy::await_holding_lock)]
8229 async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
8230 use camel_component_api::{
8231 CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
8232 };
8233 use futures::stream;
8234
8235 let _guard = lock_registry_test_mutex();
8236
8237 let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
8238 let port = listener.local_addr().unwrap().port();
8239 drop(listener);
8240
8241 let consumer_cfg = HttpServerConfig {
8242 scheme: "http".to_string(),
8243 host: "0.0.0.0".to_string(),
8244 port,
8245 path: "/limit-stream".to_string(),
8246 max_request_body: 2 * 1024 * 1024,
8247 max_response_body: 16,
8248 max_inflight_requests: 1024,
8249 method: None,
8250 tls_config: None,
8251 };
8252 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8253
8254 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8255 let token = tokio_util::sync::CancellationToken::new();
8256 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8257 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8258 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8259
8260 let client = reqwest::Client::new();
8261 let send_fut = client
8262 .get(format!("http://127.0.0.1:{port}/limit-stream"))
8263 .send();
8264
8265 let (http_result, _) = tokio::join!(send_fut, async {
8266 if let Some(mut envelope) = rx.recv().await {
8267 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
8268 vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
8269 let stream = Box::pin(stream::iter(chunks));
8270 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
8271 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
8272 metadata: StreamMetadata {
8273 size_hint: Some(32),
8274 content_type: Some("application/octet-stream".into()),
8275 origin: None,
8276 },
8277 });
8278 if let Some(reply_tx) = envelope.reply_tx {
8279 let _ = reply_tx.send(Ok(envelope.exchange));
8280 }
8281 }
8282 });
8283
8284 let resp = http_result.unwrap();
8285 assert_eq!(resp.status().as_u16(), 200);
8286 let body = resp.bytes().await.unwrap();
8287 assert_eq!(body.len(), 32);
8288 token.cancel();
8289 }
8290
8291 #[tokio::test]
8296 #[allow(clippy::await_holding_lock)]
8297 async fn test_integration_single_consumer_round_trip() {
8298 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8299
8300 let _guard = lock_registry_test_mutex();
8304
8305 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8307 let port = listener.local_addr().unwrap().port();
8308 drop(listener); let component = HttpComponent::new();
8311 let endpoint_ctx = NoOpComponentContext;
8312 let endpoint = component
8313 .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
8314 .unwrap();
8315 let mut consumer = endpoint.create_consumer(rt()).unwrap();
8316
8317 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8318 let token = tokio_util::sync::CancellationToken::new();
8319 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8320
8321 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8322 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8323
8324 let client = reqwest::Client::new();
8325 let send_fut = client
8326 .post(format!("http://127.0.0.1:{port}/echo"))
8327 .header("Content-Type", "text/plain")
8328 .body("ping")
8329 .send();
8330
8331 let (http_result, _) = tokio::join!(send_fut, async {
8332 if let Some(mut envelope) = rx.recv().await {
8333 assert_eq!(
8334 envelope.exchange.input.header("CamelHttpMethod"),
8335 Some(&serde_json::Value::String("POST".into()))
8336 );
8337 assert_eq!(
8338 envelope.exchange.input.header("CamelHttpPath"),
8339 Some(&serde_json::Value::String("/echo".into()))
8340 );
8341 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
8342 if let Some(reply_tx) = envelope.reply_tx {
8343 let _ = reply_tx.send(Ok(envelope.exchange));
8344 }
8345 }
8346 });
8347
8348 let resp = http_result.unwrap();
8349 assert_eq!(resp.status().as_u16(), 200);
8350 let body = resp.text().await.unwrap();
8351 assert_eq!(body, "pong");
8352
8353 token.cancel();
8354 }
8355
8356 #[tokio::test]
8357 #[allow(clippy::await_holding_lock)]
8358 async fn test_integration_two_consumers_shared_port() {
8359 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8360
8361 let _guard = lock_registry_test_mutex();
8362
8363 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8365 let port = listener.local_addr().unwrap().port();
8366 drop(listener);
8367
8368 let component = HttpComponent::new();
8369 let endpoint_ctx = NoOpComponentContext;
8370
8371 let endpoint_a = component
8373 .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
8374 .unwrap();
8375 let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
8376
8377 let endpoint_b = component
8379 .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
8380 .unwrap();
8381 let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
8382
8383 let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8384 let token_a = tokio_util::sync::CancellationToken::new();
8385 let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
8386
8387 let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8388 let token_b = tokio_util::sync::CancellationToken::new();
8389 let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
8390
8391 tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
8392 tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
8393 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8394
8395 let client = reqwest::Client::new();
8396
8397 let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
8399 let (resp_hello, _) = tokio::join!(fut_hello, async {
8400 if let Some(mut envelope) = rx_a.recv().await {
8401 envelope.exchange.input.body =
8402 camel_component_api::Body::Text("hello-response".to_string());
8403 if let Some(reply_tx) = envelope.reply_tx {
8404 let _ = reply_tx.send(Ok(envelope.exchange));
8405 }
8406 }
8407 });
8408
8409 let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
8411 let (resp_world, _) = tokio::join!(fut_world, async {
8412 if let Some(mut envelope) = rx_b.recv().await {
8413 envelope.exchange.input.body =
8414 camel_component_api::Body::Text("world-response".to_string());
8415 if let Some(reply_tx) = envelope.reply_tx {
8416 let _ = reply_tx.send(Ok(envelope.exchange));
8417 }
8418 }
8419 });
8420
8421 let body_a = resp_hello.unwrap().text().await.unwrap();
8422 let body_b = resp_world.unwrap().text().await.unwrap();
8423
8424 assert_eq!(body_a, "hello-response");
8425 assert_eq!(body_b, "world-response");
8426
8427 token_a.cancel();
8428 token_b.cancel();
8429 }
8430
8431 #[tokio::test]
8432 #[allow(clippy::await_holding_lock)]
8433 async fn test_integration_unregistered_path_returns_404() {
8434 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8435
8436 let _guard = lock_registry_test_mutex();
8437
8438 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8440 let port = listener.local_addr().unwrap().port();
8441 drop(listener);
8442
8443 let component = HttpComponent::new();
8444 let endpoint_ctx = NoOpComponentContext;
8445 let endpoint = component
8446 .create_endpoint(
8447 &format!("http://127.0.0.1:{port}/registered"),
8448 &endpoint_ctx,
8449 )
8450 .unwrap();
8451 let mut consumer = endpoint.create_consumer(rt()).unwrap();
8452
8453 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8454 let token = tokio_util::sync::CancellationToken::new();
8455 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8456
8457 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8458
8459 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
8461 loop {
8462 if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
8463 .await
8464 .is_ok()
8465 {
8466 break;
8467 }
8468 if std::time::Instant::now() >= deadline {
8469 panic!("HTTP server did not start within 5s on port {port}");
8470 }
8471 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
8472 }
8473
8474 let client = reqwest::Client::new();
8475 let resp = client
8476 .get(format!("http://127.0.0.1:{port}/not-there"))
8477 .send()
8478 .await
8479 .unwrap();
8480 assert_eq!(resp.status().as_u16(), 404);
8481
8482 token.cancel();
8483 }
8484
8485 #[test]
8486 fn test_http_consumer_declares_concurrent() {
8487 use camel_component_api::ConcurrencyModel;
8488
8489 let config = HttpServerConfig {
8490 scheme: "http".to_string(),
8491 host: "127.0.0.1".to_string(),
8492 port: 19999,
8493 path: "/test".to_string(),
8494 max_request_body: 2 * 1024 * 1024,
8495 max_response_body: 10 * 1024 * 1024,
8496 max_inflight_requests: 1024,
8497 method: None,
8498 tls_config: None,
8499 };
8500 let consumer = HttpConsumer::new(config, test_rt());
8501 assert_eq!(
8502 consumer.concurrency_model(),
8503 ConcurrencyModel::Concurrent { max: None }
8504 );
8505 }
8506
8507 #[test]
8508 fn server_config_parses_tls_cert_and_key() {
8509 let cfg = HttpServerConfig::from_uri(
8510 "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
8511 )
8512 .unwrap();
8513 assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
8514 assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
8515 }
8516
8517 #[test]
8518 fn server_config_no_tls_when_params_absent() {
8519 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
8520 assert!(cfg.tls_config.is_none());
8521 }
8522
8523 #[tokio::test]
8528 async fn test_http_reply_body_stream_variant_exists() {
8529 use bytes::Bytes;
8530 use camel_component_api::CamelError;
8531 use futures::stream;
8532
8533 let chunks: Vec<Result<Bytes, CamelError>> =
8534 vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
8535 let stream = Box::pin(stream::iter(chunks));
8536 let reply_body = HttpReplyBody::Stream(stream);
8537 match reply_body {
8539 HttpReplyBody::Stream(_) => {}
8540 HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
8541 }
8542 }
8543
8544 #[cfg(feature = "otel")]
8549 mod otel_tests {
8550 use super::*;
8551 use camel_component_api::Message;
8552 use tower::ServiceExt;
8553
8554 #[tokio::test]
8555 async fn test_producer_injects_traceparent_header() {
8556 let (url, _handle) = start_test_server_with_header_capture().await;
8557 let ctx = test_producer_ctx();
8558
8559 let component = HttpComponent::new();
8560 let endpoint_ctx = NoOpComponentContext;
8561 let endpoint = component
8562 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
8563 .unwrap();
8564 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
8565
8566 let mut exchange = Exchange::new(Message::default());
8568 let mut headers = std::collections::HashMap::new();
8569 headers.insert(
8570 "traceparent".to_string(),
8571 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
8572 );
8573 camel_otel::extract_into_exchange(&mut exchange, &headers);
8574
8575 let result = producer.oneshot(exchange).await.unwrap();
8576
8577 let status = result
8579 .input
8580 .header("CamelHttpResponseCode")
8581 .and_then(|v| v.as_u64())
8582 .unwrap();
8583 assert_eq!(status, 200);
8584
8585 let traceparent = result.input.header("X-Received-Traceparent");
8587 assert!(
8588 traceparent.is_some(),
8589 "traceparent header should have been sent"
8590 );
8591
8592 let traceparent_str = traceparent.unwrap().as_str().unwrap();
8593 let parts: Vec<&str> = traceparent_str.split('-').collect();
8595 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
8596 assert_eq!(parts[0], "00", "version should be 00");
8597 assert_eq!(
8598 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
8599 "trace-id should match"
8600 );
8601 assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
8602 assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
8603 }
8604
8605 #[tokio::test]
8606 async fn test_consumer_extracts_traceparent_header() {
8607 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8608
8609 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8611 let port = listener.local_addr().unwrap().port();
8612 drop(listener);
8613
8614 let component = HttpComponent::new();
8615 let endpoint_ctx = NoOpComponentContext;
8616 let endpoint = component
8617 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
8618 .unwrap();
8619 let mut consumer = endpoint.create_consumer(rt()).unwrap();
8620
8621 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8622 let token = tokio_util::sync::CancellationToken::new();
8623 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8624
8625 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8626 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8627
8628 let client = reqwest::Client::new();
8630 let send_fut = client
8631 .post(format!("http://127.0.0.1:{port}/trace"))
8632 .header(
8633 "traceparent",
8634 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
8635 )
8636 .body("test")
8637 .send();
8638
8639 let (http_result, _) = tokio::join!(send_fut, async {
8640 if let Some(envelope) = rx.recv().await {
8641 let mut injected_headers = std::collections::HashMap::new();
8644 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
8645
8646 assert!(
8647 injected_headers.contains_key("traceparent"),
8648 "Exchange should have traceparent after extraction"
8649 );
8650
8651 let traceparent = injected_headers.get("traceparent").unwrap();
8652 let parts: Vec<&str> = traceparent.split('-').collect();
8653 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
8654 assert_eq!(
8655 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
8656 "Trace ID should match the original traceparent header"
8657 );
8658
8659 if let Some(reply_tx) = envelope.reply_tx {
8660 let _ = reply_tx.send(Ok(envelope.exchange));
8661 }
8662 }
8663 });
8664
8665 let resp = http_result.unwrap();
8666 assert_eq!(resp.status().as_u16(), 200);
8667
8668 token.cancel();
8669 }
8670
8671 #[tokio::test]
8672 async fn test_consumer_extracts_mixed_case_traceparent_header() {
8673 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8674
8675 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8677 let port = listener.local_addr().unwrap().port();
8678 drop(listener);
8679
8680 let component = HttpComponent::new();
8681 let endpoint_ctx = NoOpComponentContext;
8682 let endpoint = component
8683 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
8684 .unwrap();
8685 let mut consumer = endpoint.create_consumer(rt()).unwrap();
8686
8687 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8688 let token = tokio_util::sync::CancellationToken::new();
8689 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8690
8691 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8692 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8693
8694 let client = reqwest::Client::new();
8696 let send_fut = client
8697 .post(format!("http://127.0.0.1:{port}/trace"))
8698 .header(
8699 "TraceParent",
8700 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
8701 )
8702 .body("test")
8703 .send();
8704
8705 let (http_result, _) = tokio::join!(send_fut, async {
8706 if let Some(envelope) = rx.recv().await {
8707 let mut injected_headers = HashMap::new();
8710 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
8711
8712 assert!(
8713 injected_headers.contains_key("traceparent"),
8714 "Exchange should have traceparent after extraction from mixed-case header"
8715 );
8716
8717 let traceparent = injected_headers.get("traceparent").unwrap();
8718 let parts: Vec<&str> = traceparent.split('-').collect();
8719 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
8720 assert_eq!(
8721 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
8722 "Trace ID should match the original mixed-case TraceParent header"
8723 );
8724
8725 if let Some(reply_tx) = envelope.reply_tx {
8726 let _ = reply_tx.send(Ok(envelope.exchange));
8727 }
8728 }
8729 });
8730
8731 let resp = http_result.unwrap();
8732 assert_eq!(resp.status().as_u16(), 200);
8733
8734 token.cancel();
8735 }
8736
8737 #[tokio::test]
8738 async fn test_producer_no_trace_context_no_crash() {
8739 let (url, _handle) = start_test_server().await;
8740 let ctx = test_producer_ctx();
8741
8742 let component = HttpComponent::new();
8743 let endpoint_ctx = NoOpComponentContext;
8744 let endpoint = component
8745 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
8746 .unwrap();
8747 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
8748
8749 let exchange = Exchange::new(Message::default());
8751
8752 let result = producer.oneshot(exchange).await.unwrap();
8754
8755 let status = result
8757 .input
8758 .header("CamelHttpResponseCode")
8759 .and_then(|v| v.as_u64())
8760 .unwrap();
8761 assert_eq!(status, 200);
8762 }
8763
8764 async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
8766 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8767 let addr = listener.local_addr().unwrap();
8768 let url = format!("http://127.0.0.1:{}", addr.port());
8769
8770 let handle = tokio::spawn(async move {
8771 loop {
8772 if let Ok((mut stream, _)) = listener.accept().await {
8773 tokio::spawn(async move {
8774 use tokio::io::{AsyncReadExt, AsyncWriteExt};
8775 let mut buf = vec![0u8; 8192];
8776 let n = stream.read(&mut buf).await.unwrap_or(0);
8777 let request = String::from_utf8_lossy(&buf[..n]).to_string();
8778
8779 let traceparent = request
8781 .lines()
8782 .find(|line| line.to_lowercase().starts_with("traceparent:"))
8783 .map(|line| {
8784 line.split(':')
8785 .nth(1)
8786 .map(|s| s.trim().to_string())
8787 .unwrap_or_default()
8788 })
8789 .unwrap_or_default();
8790
8791 let body =
8792 format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
8793 let response = format!(
8794 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
8795 body.len(),
8796 traceparent,
8797 body
8798 );
8799 let _ = stream.write_all(response.as_bytes()).await;
8800 });
8801 }
8802 }
8803 });
8804
8805 (url, handle)
8806 }
8807 }
8808
8809 #[tokio::test]
8818 async fn test_request_body_arrives_as_stream() {
8819 use camel_component_api::Body;
8820 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8821
8822 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8823 let port = listener.local_addr().unwrap().port();
8824 drop(listener);
8825
8826 let component = HttpComponent::new();
8827 let endpoint_ctx = NoOpComponentContext;
8828 let endpoint = component
8829 .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
8830 .unwrap();
8831 let mut consumer = endpoint.create_consumer(rt()).unwrap();
8832
8833 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8834 let token = tokio_util::sync::CancellationToken::new();
8835 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8836
8837 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8838 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8839
8840 let client = reqwest::Client::new();
8841 let send_fut = client
8842 .post(format!("http://127.0.0.1:{port}/upload"))
8843 .body("hello streaming world")
8844 .send();
8845
8846 let (http_result, _) = tokio::join!(send_fut, async {
8847 if let Some(mut envelope) = rx.recv().await {
8848 assert!(
8850 matches!(envelope.exchange.input.body, Body::Stream(_)),
8851 "expected Body::Stream, got discriminant {:?}",
8852 std::mem::discriminant(&envelope.exchange.input.body)
8853 );
8854 let bytes = envelope
8856 .exchange
8857 .input
8858 .body
8859 .into_bytes(1024 * 1024)
8860 .await
8861 .unwrap();
8862 assert_eq!(&bytes[..], b"hello streaming world");
8863
8864 envelope.exchange.input.body = camel_component_api::Body::Empty;
8865 if let Some(reply_tx) = envelope.reply_tx {
8866 let _ = reply_tx.send(Ok(envelope.exchange));
8867 }
8868 }
8869 });
8870
8871 let resp = http_result.unwrap();
8872 assert_eq!(resp.status().as_u16(), 200);
8873
8874 token.cancel();
8875 }
8876
8877 #[tokio::test]
8882 async fn test_streaming_response_chunked() {
8883 use bytes::Bytes;
8884 use camel_component_api::Body;
8885 use camel_component_api::CamelError;
8886 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8887 use camel_component_api::{StreamBody, StreamMetadata};
8888 use futures::stream;
8889 use std::sync::Arc;
8890 use tokio::sync::Mutex;
8891
8892 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8893 let port = listener.local_addr().unwrap().port();
8894 drop(listener);
8895
8896 let component = HttpComponent::new();
8897 let endpoint_ctx = NoOpComponentContext;
8898 let endpoint = component
8899 .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
8900 .unwrap();
8901 let mut consumer = endpoint.create_consumer(rt()).unwrap();
8902
8903 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8904 let token = tokio_util::sync::CancellationToken::new();
8905 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8906
8907 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8908 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8909
8910 let client = reqwest::Client::new();
8911 let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
8912
8913 let (http_result, _) = tokio::join!(send_fut, async {
8914 if let Some(mut envelope) = rx.recv().await {
8915 let chunks: Vec<Result<Bytes, CamelError>> =
8917 vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
8918 let stream = Box::pin(stream::iter(chunks));
8919 envelope.exchange.input.body = Body::Stream(StreamBody {
8920 stream: Arc::new(Mutex::new(Some(stream))),
8921 metadata: StreamMetadata::default(),
8922 });
8923 if let Some(reply_tx) = envelope.reply_tx {
8924 let _ = reply_tx.send(Ok(envelope.exchange));
8925 }
8926 }
8927 });
8928
8929 let resp = http_result.unwrap();
8930 assert_eq!(resp.status().as_u16(), 200);
8931 let body = resp.text().await.unwrap();
8932 assert_eq!(body, "chunk1chunk2");
8933
8934 token.cancel();
8935 }
8936
8937 #[tokio::test]
8942 async fn test_413_when_content_length_exceeds_limit() {
8943 use camel_component_api::ConsumerContext;
8944
8945 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8946 let port = listener.local_addr().unwrap().port();
8947 drop(listener);
8948
8949 let component = HttpComponent::new();
8951 let endpoint_ctx = NoOpComponentContext;
8952 let endpoint = component
8953 .create_endpoint(
8954 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
8955 &endpoint_ctx,
8956 )
8957 .unwrap();
8958 let mut consumer = endpoint.create_consumer(rt()).unwrap();
8959
8960 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8961 let token = tokio_util::sync::CancellationToken::new();
8962 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8963
8964 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8965 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8966
8967 let client = reqwest::Client::new();
8968 let resp = client
8969 .post(format!("http://127.0.0.1:{port}/upload"))
8970 .header("Content-Length", "1000") .body("x".repeat(1000))
8972 .send()
8973 .await
8974 .unwrap();
8975
8976 assert_eq!(resp.status().as_u16(), 413);
8977
8978 token.cancel();
8979 }
8980
8981 #[tokio::test]
8985 async fn test_chunked_upload_without_content_length_bypasses_limit() {
8986 use bytes::Bytes;
8987 use camel_component_api::Body;
8988 use camel_component_api::ConsumerContext;
8989 use futures::stream;
8990
8991 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8992 let port = listener.local_addr().unwrap().port();
8993 drop(listener);
8994
8995 let component = HttpComponent::new();
8997 let endpoint_ctx = NoOpComponentContext;
8998 let endpoint = component
8999 .create_endpoint(
9000 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
9001 &endpoint_ctx,
9002 )
9003 .unwrap();
9004 let mut consumer = endpoint.create_consumer(rt()).unwrap();
9005
9006 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
9007 let token = tokio_util::sync::CancellationToken::new();
9008 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9009
9010 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9011 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9012
9013 let client = reqwest::Client::new();
9014
9015 let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
9019 Ok(Bytes::from("y".repeat(50))),
9020 Ok(Bytes::from("y".repeat(50))),
9021 ];
9022 let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
9023 let send_fut = client
9024 .post(format!("http://127.0.0.1:{port}/upload"))
9025 .body(stream_body)
9026 .send();
9027
9028 let consumer_fut = async {
9029 match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
9031 Ok(Some(mut envelope)) => {
9032 assert!(
9033 matches!(envelope.exchange.input.body, Body::Stream(_)),
9034 "expected Body::Stream"
9035 );
9036 envelope.exchange.input.body = camel_component_api::Body::Empty;
9037 if let Some(reply_tx) = envelope.reply_tx {
9038 let _ = reply_tx.send(Ok(envelope.exchange));
9039 }
9040 }
9041 Ok(None) => panic!("consumer channel closed unexpectedly"),
9042 Err(_) => {
9043 }
9046 }
9047 };
9048
9049 let (http_result, _) = tokio::join!(send_fut, consumer_fut);
9050
9051 let resp = http_result.unwrap();
9052 assert_ne!(
9059 resp.status().as_u16(),
9060 413,
9061 "chunked upload has no Content-Length to pre-check"
9062 );
9063 assert_eq!(resp.status().as_u16(), 200);
9064
9065 token.cancel();
9066 }
9067
9068 #[test]
9069 fn test_is_private_ip_ranges() {
9070 use camel_api::is_ssrf_blocked_ip;
9071 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(
9090 &"2001:4860:4860::8888".parse().unwrap()
9091 )); }
9093
9094 #[test]
9095 fn test_title_case_header() {
9096 assert_eq!(title_case_header("content-type"), "Content-Type");
9097 assert_eq!(title_case_header("authorization"), "Authorization");
9098 assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
9099 assert_eq!(title_case_header("host"), "Host");
9100 assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
9101 assert_eq!(title_case_header("single"), "Single");
9102 assert_eq!(title_case_header(""), "");
9103 }
9104
9105 #[test]
9106 fn test_resolve_url_combines_path_and_query_sources() {
9107 let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
9108 let mut exchange = Exchange::new(Message::default());
9109 exchange.input.set_header(
9110 "CamelHttpPath",
9111 serde_json::Value::String("next".to_string()),
9112 );
9113 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9114 assert!(url.starts_with("http://example.com/base/next?"));
9115 assert!(url.contains("foo=bar"));
9116
9117 exchange.input.set_header(
9118 "CamelHttpUri",
9119 serde_json::Value::String("http://other.test/root".to_string()),
9120 );
9121 exchange.input.set_header(
9122 "CamelHttpQuery",
9123 serde_json::Value::String("a=1&b=2".to_string()),
9124 );
9125
9126 let override_url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9127 assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
9128 }
9129
9130 fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
9131 let mut exchange = Exchange::new(Message::default());
9132 exchange
9133 .input
9134 .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
9135 exchange.input.set_header(
9136 "CamelHttpQuery",
9137 serde_json::Value::String(query.to_string()),
9138 );
9139 exchange
9140 }
9141
9142 #[test]
9143 fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
9144 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9145 cfg.bridge_endpoint = true;
9146 cfg.query_params
9147 .push(("token".to_string(), "secret".to_string()));
9148 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
9149 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9150 assert_eq!(url, "http://x?token=secret");
9154 assert!(!url.contains("/foo"));
9155 assert!(!url.contains("dropme"));
9156 }
9157
9158 #[test]
9159 fn resolve_url_bridge_endpoint_false_merges_path() {
9160 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9161 cfg.bridge_endpoint = false;
9162 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
9163 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9164 assert!(url.contains("/foo"), "url should contain /foo: {url}");
9165 assert!(
9166 url.contains("dropme=1"),
9167 "url should contain dropme=1: {url}"
9168 );
9169 }
9170
9171 #[test]
9172 fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
9173 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9174 cfg.bridge_endpoint = true;
9175 let mut exchange = Exchange::new(Message::default());
9176 exchange.input.set_header(
9177 "CamelHttpPath",
9178 serde_json::Value::String("/foo".to_string()),
9179 );
9180 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9181 assert_eq!(url, "http://x");
9182 assert!(!url.contains("/foo"));
9183 }
9184
9185 #[test]
9186 fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
9187 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9188 cfg.bridge_endpoint = true;
9189 let mut exchange = Exchange::new(Message::default());
9191 exchange.input.set_header(
9192 "CamelHttpUri",
9193 serde_json::Value::String("http://dest/explicit".to_string()),
9194 );
9195 exchange.input.set_header(
9196 "CamelHttpPath",
9197 serde_json::Value::String("/foo".to_string()),
9198 );
9199 exchange.input.set_header(
9200 "CamelHttpQuery",
9201 serde_json::Value::String("x=1".to_string()),
9202 );
9203 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9204 assert_eq!(url, "http://x");
9208 }
9209
9210 #[test]
9211 fn bridge_programmatic_params_use_percent20() {
9212 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9213 cfg.bridge_endpoint = true;
9214 cfg.query_params = vec![("b".to_string(), "x y".to_string())];
9215 let exchange = Exchange::new(Message::default());
9216
9217 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9218
9219 assert_eq!(url, "http://x?b=x%20y");
9223 assert!(!url.contains('+'));
9224 }
9225
9226 #[test]
9227 fn bridge_arm_carries_authored_raw_query() {
9228 let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
9229 let exchange = exchange_with_path_and_query("/ignored", "dropme=1");
9232
9233 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9234
9235 assert_eq!(url, "http://h/p?a=1");
9238 assert!(!url.contains("dropme"), "exchange query leaked: {url}");
9239 assert!(!url.contains("/ignored"), "exchange path leaked: {url}");
9240 }
9241
9242 #[test]
9249 fn resolve_url_bridge_preserves_dot_segments() {
9250 let mut cfg = HttpEndpointConfig::from_uri("http://h/a/../b").unwrap();
9251 cfg.bridge_endpoint = true;
9252 cfg.query_params.push(("k".to_string(), "1".to_string()));
9253 let exchange = Exchange::new(Message::default());
9254
9255 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9256
9257 assert_eq!(url, "http://h/a/../b?k=1");
9260 }
9261
9262 #[test]
9263 fn resolve_url_bridge_preserves_default_port() {
9264 let mut cfg = HttpEndpointConfig::from_uri("http://h:80/p").unwrap();
9265 cfg.bridge_endpoint = true;
9266 cfg.query_params.push(("k".to_string(), "1".to_string()));
9267 let exchange = Exchange::new(Message::default());
9268
9269 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9270
9271 assert_eq!(url, "http://h:80/p?k=1");
9274 }
9275
9276 #[test]
9277 fn resolve_url_bridge_preserves_scheme_and_host_case() {
9278 let mut cfg = HttpEndpointConfig::from_uri("http://ExAMPLE.COM/p").unwrap();
9279 cfg.bridge_endpoint = true;
9280 cfg.query_params.push(("k".to_string(), "1".to_string()));
9281 cfg.base_url = "HTTP://ExAMPLE.COM/p".to_string();
9285 let exchange = Exchange::new(Message::default());
9286
9287 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9288
9289 assert_eq!(url, "HTTP://ExAMPLE.COM/p?k=1");
9292 }
9293
9294 #[test]
9295 fn resolve_url_bridge_no_query_emits_base_verbatim() {
9296 let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9297 cfg.bridge_endpoint = true;
9298 let exchange = Exchange::new(Message::default());
9299
9300 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9301
9302 assert_eq!(url, "http://h/p");
9305 }
9306
9307 #[test]
9308 fn resolve_url_bridge_and_non_bridge_byte_identical() {
9309 let mut bridged = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
9312 bridged.bridge_endpoint = true;
9313 bridged
9314 .query_params
9315 .push(("k".to_string(), "1".to_string()));
9316 let bridge_url =
9317 HttpProducer::resolve_url(&Exchange::new(Message::default()), &bridged).unwrap();
9318
9319 let plain = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
9322 let mut exchange = Exchange::new(Message::default());
9323 exchange.input.set_header(
9324 "CamelHttpQuery",
9325 serde_json::Value::String("k=1".to_string()),
9326 );
9327 let plain_url = HttpProducer::resolve_url(&exchange, &plain).unwrap();
9328
9329 assert_eq!(bridge_url, plain_url);
9330 assert_eq!(bridge_url, "http://H:80/a/../b?k=1");
9331 }
9332
9333 #[test]
9334 fn resolve_url_bridge_preserves_ipv6_authority_verbatim() {
9335 let mut cfg = HttpEndpointConfig::from_uri("http://[::1]:8080/p").unwrap();
9336 cfg.bridge_endpoint = true;
9337 cfg.query_params.push(("k".to_string(), "1".to_string()));
9338 let exchange = Exchange::new(Message::default());
9339
9340 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9341
9342 assert_eq!(url, "http://[::1]:8080/p?k=1");
9343 }
9344
9345 #[test]
9346 fn resolve_url_bridge_empty_base_path_keeps_no_synthetic_slash() {
9347 let cfg = HttpEndpointConfig::from_uri("http://h?x=1&bridgeEndpoint=true").unwrap();
9348 let exchange = Exchange::new(Message::default());
9349
9350 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9351
9352 assert_eq!(url, "http://h?x=1");
9355 }
9356
9357 #[test]
9362 fn resolve_url_preserves_authored_query_order_and_bytes() {
9363 let config =
9364 HttpEndpointConfig::from_uri("http://h/p?a=1&b=x,y&c=t:1&connectTimeout=5000").unwrap();
9365 let exchange = Exchange::new(Message::default());
9366
9367 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9368
9369 assert_eq!(url, "http://h/p?a=1&b=x,y&c=t:1");
9372 }
9373
9374 #[test]
9375 fn resolve_url_consumes_encoded_option_key() {
9376 let config = HttpEndpointConfig::from_uri("http://h/p?connect%54imeout=5000&a=1").unwrap();
9377 let exchange = Exchange::new(Message::default());
9378
9379 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9380
9381 assert_eq!(url, "http://h/p?a=1");
9383 }
9384
9385 #[test]
9386 fn resolve_url_all_options_consumed_drops_query() {
9387 let config = HttpEndpointConfig::from_uri("http://h/p?connectTimeout=5000").unwrap();
9388 let exchange = Exchange::new(Message::default());
9389
9390 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9391
9392 assert_eq!(url, "http://h/p");
9395 assert!(!url.contains('?'));
9396 }
9397
9398 #[test]
9399 fn resolve_url_preserves_empty_query_marker() {
9400 let config = HttpEndpointConfig::from_uri("http://h/p?").unwrap();
9401 let exchange = Exchange::new(Message::default());
9402
9403 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9404
9405 assert_eq!(url, "http://h/p?");
9408 }
9409
9410 #[test]
9411 fn resolve_url_raw_wrapper_not_re_encoded() {
9412 let config = HttpEndpointConfig::from_uri("http://h/p?token=RAW(abc)").unwrap();
9413 let exchange = Exchange::new(Message::default());
9414
9415 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9416
9417 assert_eq!(url, "http://h/p?token=RAW(abc)");
9419 assert!(!url.contains("%28"), "RAW( wrapper re-encoded: {url}");
9420 }
9421
9422 #[test]
9423 fn resolve_url_camel_http_query_composes_verbatim_span() {
9424 let config = HttpEndpointConfig::from_uri("http://h/p?x=1").unwrap();
9425 let mut exchange = Exchange::new(Message::default());
9426 exchange.input.set_header(
9427 "CamelHttpQuery",
9428 serde_json::Value::String("userFilter=a%2Cb".to_string()),
9429 );
9430
9431 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9432
9433 assert_eq!(url, "http://h/p?x=1&userFilter=a%2Cb");
9438 assert!(!url.contains("%252C"), "header bytes re-encoded: {url}");
9439 }
9440
9441 #[test]
9446 fn header_composes_with_endpoint_query() {
9447 let config =
9448 HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret&lang=en").unwrap();
9449 let mut exchange = Exchange::new(Message::default());
9450 exchange.input.set_header(
9451 "CamelHttpQuery",
9452 serde_json::Value::String("lang=es&page=2".to_string()),
9453 );
9454
9455 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9456
9457 assert_eq!(url, "http://upstream/api?apiKey=secret&lang=en&page=2");
9460 }
9461
9462 #[test]
9463 fn header_alone_still_rides() {
9464 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9465 let mut exchange = Exchange::new(Message::default());
9466 exchange.input.set_header(
9467 "CamelHttpQuery",
9468 serde_json::Value::String("page=2".to_string()),
9469 );
9470
9471 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9472
9473 assert_eq!(url, "http://upstream/api?page=2");
9475 }
9476
9477 #[test]
9478 fn empty_reflected_query_leaves_endpoint_query_intact() {
9479 let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
9480 let mut exchange = Exchange::new(Message::default());
9481 exchange
9484 .input
9485 .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
9486
9487 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9488
9489 assert_eq!(url, "http://upstream/api?apiKey=secret");
9491 assert!(!url.ends_with('?'), "dangling '?' marker: {url}");
9492 }
9493
9494 #[test]
9495 fn forbidden_byte_in_header_query_errors() {
9496 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9497 let mut exchange = Exchange::new(Message::default());
9498 exchange.input.set_header(
9499 "CamelHttpQuery",
9500 serde_json::Value::String("q=ab<cd".to_string()),
9501 );
9502
9503 let err = HttpProducer::resolve_url(&exchange, &config)
9504 .unwrap_err()
9505 .to_string();
9506
9507 assert!(err.contains("0x3C"), "error must name the byte: {err}");
9510 }
9511
9512 #[test]
9513 fn override_uri_with_query_plus_header_query() {
9514 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9515 let mut exchange = Exchange::new(Message::default());
9516 exchange.input.set_header(
9517 "CamelHttpUri",
9518 serde_json::Value::String("http://host/api?a=1".to_string()),
9519 );
9520 exchange.input.set_header(
9521 "CamelHttpQuery",
9522 serde_json::Value::String("a=2&b=3".to_string()),
9523 );
9524
9525 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9526
9527 assert_eq!(url, "http://host/api?a=1&b=3");
9530 }
9531
9532 #[test]
9533 fn path_applies_before_query_composition() {
9534 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
9535 let mut exchange = Exchange::new(Message::default());
9536 exchange.input.set_header(
9537 "CamelHttpUri",
9538 serde_json::Value::String("http://host/api?a=1".to_string()),
9539 );
9540 exchange.input.set_header(
9541 "CamelHttpPath",
9542 serde_json::Value::String("/extra".to_string()),
9543 );
9544 exchange.input.set_header(
9545 "CamelHttpQuery",
9546 serde_json::Value::String("b=2".to_string()),
9547 );
9548
9549 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9550
9551 assert_eq!(url, "http://host/api/extra?a=1&b=2");
9554 }
9555
9556 #[test]
9557 fn plain_proxy_reflection_composes() {
9558 let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
9559 let exchange = exchange_with_path_and_query("/in/extra", "page=2");
9561
9562 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9563
9564 assert_eq!(url, "http://upstream/api/in/extra?apiKey=secret&page=2");
9567 }
9568
9569 #[test]
9570 fn bridge_endpoint_ignores_url_headers() {
9571 let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
9572 let mut exchange = Exchange::new(Message::default());
9573 exchange.input.set_header(
9574 "CamelHttpUri",
9575 serde_json::Value::String("http://evil.test/x".to_string()),
9576 );
9577 exchange.input.set_header(
9578 "CamelHttpPath",
9579 serde_json::Value::String("/foo".to_string()),
9580 );
9581 exchange.input.set_header(
9582 "CamelHttpQuery",
9583 serde_json::Value::String("z=9".to_string()),
9584 );
9585
9586 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9587
9588 assert_eq!(url, "http://h/p?a=1");
9591 assert!(!url.contains("evil"), "override leaked: {url}");
9592 assert!(!url.contains("z=9"), "header query leaked: {url}");
9593 assert!(!url.contains("/foo"), "header path leaked: {url}");
9594 }
9595
9596 #[test]
9597 fn resolve_url_programmatic_params_use_percent20_deterministic() {
9598 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9599 config.query_params = vec![
9600 ("b".to_string(), "x y".to_string()),
9601 ("a".to_string(), "1".to_string()),
9602 ];
9603 let exchange = Exchange::new(Message::default());
9604
9605 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9606
9607 assert_eq!(url, "http://h/p?b=x%20y&a=1");
9610 assert!(!url.contains('+'));
9611 }
9612
9613 #[test]
9614 fn resolve_url_authored_and_programmatic_merge() {
9615 let mut config = HttpEndpointConfig::from_uri("http://h/p?a=1&c=t:1").unwrap();
9616 config.query_params = vec![
9617 ("b".to_string(), "2".to_string()),
9618 ("a".to_string(), "9".to_string()),
9619 ];
9620 let exchange = Exchange::new(Message::default());
9621
9622 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
9623
9624 assert_eq!(url, "http://h/p?a=1&c=t:1&b=2");
9627 }
9628
9629 #[test]
9630 fn from_uri_no_longer_fills_query_params_from_uri() {
9631 let config = HttpEndpointConfig::from_uri("http://h/p?a=1&connectTimeout=5000").unwrap();
9632
9633 assert!(
9635 config.query_params.is_empty(),
9636 "query_params is programmatic-only: {:?}",
9637 config.query_params
9638 );
9639 assert_eq!(config.raw_query.as_deref(), Some("a=1&connectTimeout=5000"));
9640 }
9641
9642 #[test]
9643 fn resolve_url_forbidden_raw_byte_errors() {
9644 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9645 config.raw_query = Some("a=x y".to_string());
9646 let exchange = Exchange::new(Message::default());
9647
9648 let err = HttpProducer::resolve_url(&exchange, &config)
9649 .expect_err("literal space in raw query must error");
9650
9651 assert!(
9653 err.to_string().contains("0x20"),
9654 "error must name the forbidden byte: {err}"
9655 );
9656 }
9657
9658 #[test]
9662 fn resolve_url_override_query_forbidden_byte_errors() {
9663 let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9664 let mut exchange = Exchange::new(Message::default());
9665 exchange.input.set_header(
9666 "CamelHttpUri",
9667 serde_json::Value::String("http://h2/p?a=x y".to_string()),
9668 );
9669
9670 let err = HttpProducer::resolve_url(&exchange, &config)
9671 .expect_err("literal space in the override URI's query must error");
9672
9673 assert!(
9674 err.to_string().contains("0x20"),
9675 "error must name the forbidden byte from the override query: {err}"
9676 );
9677 }
9678
9679 #[test]
9684 fn merge_header_query_decoded_key_collision_drops_header_pair() {
9685 let merged = merge_header_query(Some("a=1"), "%61=2")
9686 .expect("decoded-key collision must not be a parse error");
9687 assert_eq!(
9688 merged.as_deref(),
9689 Some("a=1"),
9690 "the higher-precedence span wins and the colliding header pair is dropped"
9691 );
9692 }
9693
9694 #[test]
9697 fn merge_header_query_duplicate_keys_within_header_ride_verbatim() {
9698 let merged = merge_header_query(None, "k=1&k=2")
9699 .expect("duplicate header keys must not be a parse error");
9700 assert_eq!(
9701 merged.as_deref(),
9702 Some("k=1&k=2"),
9703 "intra-header duplicate keys ride verbatim"
9704 );
9705 }
9706
9707 #[test]
9714 fn endpoint_config_debug_masks_base_url_userinfo() {
9715 let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
9716 config.base_url = "http://user:pass@h.example/p".to_string();
9717 let rendered = format!("{config:?}");
9718 assert!(
9719 rendered.contains("***@h.example"),
9720 "userinfo must render masked: {rendered}"
9721 );
9722 assert!(
9723 !rendered.contains("user:pass"),
9724 "no credentials in Debug output: {rendered}"
9725 );
9726
9727 let plain = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
9728 let rendered_plain = format!("{plain:?}");
9729 assert!(
9730 rendered_plain.contains("http://h.example/p"),
9731 "a base without userinfo renders unchanged: {rendered_plain}"
9732 );
9733 }
9734
9735 #[test]
9740 fn endpoint_config_debug_base_url_converges_on_canonical_redact() {
9741 let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
9742
9743 config.base_url = "http://h.example/p?token=secret#access_token=x".to_string();
9744 let rendered = format!("{config:?}");
9745 assert!(
9746 rendered.contains("base_url: \"http://h.example/p?[redacted]#[redacted]\""),
9747 "query and fragment must render as composed sentinels: {rendered}"
9748 );
9749 assert!(
9750 !rendered.contains("token=secret") && !rendered.contains("access_token"),
9751 "query/fragment credential bytes must not render: {rendered}"
9752 );
9753
9754 config.base_url = "http://h.example//u2:p2@evil/".to_string();
9755 let rendered = format!("{config:?}");
9756 assert!(
9757 rendered.contains("base_url: \"http://h.example//***@evil/\""),
9758 "later //window userinfo must mask (canonical window rule): {rendered}"
9759 );
9760 assert!(
9761 !rendered.contains("u2:p2"),
9762 "later-window credentials must not render: {rendered}"
9763 );
9764
9765 config.base_url = "http://user:pass@h.example/p?token=x".to_string();
9768 let canonical = camel_api::redact::redact_url(&config.base_url);
9769 assert_eq!(canonical, "http://***@h.example/p?[redacted]");
9770 let rendered = format!("{config:?}");
9771 assert!(
9772 rendered.contains(&format!("base_url: \"{canonical}\"")),
9773 "Debug base_url must equal canonical redact_url output: {rendered}"
9774 );
9775 }
9776
9777 #[test]
9783 fn resolve_url_authored_apostrophe_rejected_percent_escape_rides() {
9784 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9785
9786 config.raw_query = Some("q=it's".to_string());
9787 let exchange = Exchange::new(Message::default());
9788 let err = HttpProducer::resolve_url(&exchange, &config)
9789 .expect_err("authored apostrophe must be rejected, not silently %27-normalized");
9790 assert!(
9791 err.to_string().contains("0x27"),
9792 "error must name the apostrophe byte: {err}"
9793 );
9794
9795 config.raw_query = Some("q=it%27s".to_string());
9796 let url = HttpProducer::resolve_url(&exchange, &config)
9797 .expect("authored %27 escape is wire-legal");
9798 assert!(
9799 url.contains("q=it%27s"),
9800 "the authored escape must ride byte-for-byte: {url}"
9801 );
9802
9803 for &byte in b"\"`<>" {
9807 config.raw_query = Some(format!("k={}x", byte as char));
9808 let err = HttpProducer::resolve_url(&exchange, &config)
9809 .expect_err("WHATWG special-query byte must be rejected");
9810 assert!(
9811 err.to_string().contains(&format!("0x{byte:02X}")),
9812 "error must name byte 0x{byte:02X}: {err}"
9813 );
9814 }
9815 }
9816
9817 #[test]
9818 fn armed_fence_rejects_unknown_host_redacted() {
9819 let cfg = HttpEndpointConfig::from_uri(
9820 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
9821 )
9822 .unwrap();
9823 let mut exchange = Exchange::new(Message::default());
9824 exchange.input.set_header(
9825 "CamelHttpUri",
9826 serde_json::Value::String(
9827 "http://user:pass@evil.example.com/x?token=s3cret".to_string(),
9828 ),
9829 );
9830
9831 let err = HttpProducer::resolve_url(&exchange, &cfg)
9832 .expect_err("override host outside the fence must fail resolution");
9833
9834 let message = err.to_string();
9835 assert!(!message.contains("pass"), "userinfo leaked: {message}");
9836 assert!(!message.contains("s3cret"), "query leaked: {message}");
9837 }
9838
9839 #[test]
9840 fn armed_fence_rejects_unparseable_override_redacted() {
9841 let cfg = HttpEndpointConfig::from_uri(
9842 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
9843 )
9844 .unwrap();
9845 let mut exchange = Exchange::new(Message::default());
9846 exchange.input.set_header(
9847 "CamelHttpUri",
9848 serde_json::Value::String("http://u:fencesecret@evil.example.com:99999/x".to_string()),
9849 );
9850
9851 let err = HttpProducer::resolve_url(&exchange, &cfg)
9852 .expect_err("unparseable override outside the fence must fail resolution");
9853
9854 let message = err.to_string();
9855 assert!(
9856 message.contains("allowedUriHosts fence"),
9857 "fence must be named: {message}"
9858 );
9859 assert!(
9860 message.contains("[redacted]"),
9861 "suppression sentinel missing: {message}"
9862 );
9863 assert!(
9864 !message.contains("evil.example.com"),
9865 "host leaked: fail-closed arm must render only the sentinel: {message}"
9866 );
9867 assert!(
9868 !message.contains("fencesecret"),
9869 "password leaked: {message}"
9870 );
9871 assert!(!message.contains("u:"), "userinfo leaked: {message}");
9872 }
9873
9874 #[test]
9875 fn armed_fence_rejects_password_only_userinfo_redacted() {
9876 let cfg = HttpEndpointConfig::from_uri(
9877 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
9878 )
9879 .unwrap();
9880 let mut exchange = Exchange::new(Message::default());
9881 exchange.input.set_header(
9882 "CamelHttpUri",
9883 serde_json::Value::String(
9884 "http://:passwordonly@evil.example.com/x?token=querysecret".to_string(),
9885 ),
9886 );
9887
9888 let err = HttpProducer::resolve_url(&exchange, &cfg)
9889 .expect_err("password-only override outside the fence must fail resolution");
9890
9891 let message = err.to_string();
9892 assert!(
9893 !message.contains("passwordonly"),
9894 "password-only userinfo leaked: {message}"
9895 );
9896 assert!(!message.contains("querysecret"), "query leaked: {message}");
9897 assert!(
9898 message.contains("http://***@evil.example.com/x?[redacted]"),
9899 "masked shape missing: {message}"
9900 );
9901 }
9902
9903 #[test]
9904 fn armed_fence_allows_listed_host() {
9905 let cfg = HttpEndpointConfig::from_uri(
9906 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
9907 )
9908 .unwrap();
9909 let mut exchange = Exchange::new(Message::default());
9910 exchange.input.set_header(
9911 "CamelHttpUri",
9912 serde_json::Value::String("http://cdn.example.com/x".to_string()),
9913 );
9914
9915 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9916 assert_eq!(url, "http://cdn.example.com/x");
9917 }
9918
9919 #[test]
9920 fn host_only_entry_permits_any_port() {
9921 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=cdn.example.com").unwrap();
9922 let mut exchange = Exchange::new(Message::default());
9923 exchange.input.set_header(
9924 "CamelHttpUri",
9925 serde_json::Value::String("http://cdn.example.com:9443/x".to_string()),
9926 );
9927
9928 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9929 assert_eq!(url, "http://cdn.example.com:9443/x");
9930 }
9931
9932 #[test]
9933 fn unarmed_endpoint_unchanged() {
9934 let cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
9935 let mut exchange = Exchange::new(Message::default());
9936 exchange.input.set_header(
9937 "CamelHttpUri",
9938 serde_json::Value::String("http://any.example.com/path".to_string()),
9939 );
9940
9941 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9942 assert_eq!(url, "http://any.example.com/path");
9943 }
9944
9945 #[test]
9946 fn empty_allowlist_fails_endpoint_creation() {
9947 assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=,,").is_err());
9948 }
9949
9950 #[test]
9951 fn malformed_entry_fails_endpoint_creation() {
9952 assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=not a host!").is_err());
9953 }
9954
9955 #[test]
9956 fn fence_entry_with_path_fails_creation() {
9957 assert!(
9960 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=api.internal:8443/v2").is_err()
9961 );
9962 }
9963
9964 #[test]
9965 fn fence_entry_with_userinfo_fails_creation() {
9966 assert!(
9967 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=user@cdn.example.com").is_err()
9968 );
9969 }
9970
9971 #[test]
9972 fn ipv6_fence_entry_allows_bracketed_host() {
9973 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=[::1]:8443").unwrap();
9974 for uri in ["http://[::1]:8443/x", "http://[0:0:0:0:0:0:0:1]:8443/x"] {
9977 let mut exchange = Exchange::new(Message::default());
9978 exchange
9979 .input
9980 .set_header("CamelHttpUri", serde_json::Value::String(uri.to_string()));
9981 let url = HttpProducer::resolve_url(&exchange, &cfg)
9982 .unwrap_or_else(|e| panic!("override {uri} must be honored: {e}"));
9983 assert_eq!(url, uri, "bracketed IPv6 override not honored");
9984 }
9985 }
9986
9987 #[test]
9988 fn dns_case_insensitive_fence_match() {
9989 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=CDN.Example.COM").unwrap();
9992 let mut exchange = Exchange::new(Message::default());
9993 exchange.input.set_header(
9994 "CamelHttpUri",
9995 serde_json::Value::String("http://cdn.example.com/x".to_string()),
9996 );
9997 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
9998 assert_eq!(url, "http://cdn.example.com/x");
9999 }
10000
10001 #[test]
10002 fn fence_allowed_override_query_merges_with_header() {
10003 let cfg =
10006 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example&k=v").unwrap();
10007 let mut exchange = Exchange::new(Message::default());
10008 exchange.input.set_header(
10009 "CamelHttpUri",
10010 serde_json::Value::String("http://host.example/api?a=1".to_string()),
10011 );
10012 exchange.input.set_header(
10013 "CamelHttpQuery",
10014 serde_json::Value::String("b=2".to_string()),
10015 );
10016
10017 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10018 assert_eq!(url, "http://host.example/api?a=1&b=2");
10019 }
10020
10021 #[test]
10022 fn empty_header_with_armed_fence_leaves_no_query() {
10023 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example").unwrap();
10024 let mut exchange = Exchange::new(Message::default());
10025 exchange.input.set_header(
10026 "CamelHttpUri",
10027 serde_json::Value::String("http://host.example/api".to_string()),
10028 );
10029 exchange
10030 .input
10031 .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
10032
10033 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10034 assert_eq!(url, "http://host.example/api");
10035 assert!(!url.contains('?'), "query marker leaked: {url}");
10036 }
10037
10038 #[test]
10039 fn fence_option_is_consumed() {
10040 let cfg =
10044 HttpEndpointConfig::from_uri("http://h/p?x=1&allowedUriHosts=cdn.example.com").unwrap();
10045 let exchange = Exchange::new(Message::default());
10046
10047 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
10048 assert!(!url.contains("allowedUriHosts"), "option leaked: {url}");
10049 assert!(url.contains("x=1"), "authored query lost: {url}");
10050 }
10051
10052 #[tokio::test]
10053 async fn resolve_url_malformed_base_url_errors_no_panic() {
10054 use tower::ServiceExt;
10055
10056 let (url, _handle) = start_test_server().await;
10057 let mut config = HttpEndpointConfig::from_uri("http://[::1:bad").unwrap();
10058 config.allow_internal = true; let producer = HttpProducer {
10060 config: Arc::new(config),
10061 client: build_client(&HttpConfig::default(), None),
10062 pinned_cache: Arc::new(PinnedClientCache::new(
10063 PINNED_CLIENT_TTL,
10064 PINNED_CLIENT_MAX_ENTRIES,
10065 )),
10066 http_config: Arc::new(HttpConfig::default()),
10067 runtime: rt(),
10068 };
10069
10070 let first = producer
10073 .clone()
10074 .oneshot(Exchange::new(Message::default()))
10075 .await;
10076 let err = first.expect_err("malformed base URL must error, not panic");
10077 assert!(
10078 err.to_string().to_lowercase().contains("url"),
10079 "error must name the malformed URL: {err}"
10080 );
10081
10082 let mut exchange = Exchange::new(Message::default());
10085 exchange.input.set_header(
10086 "CamelHttpUri",
10087 serde_json::Value::String(format!("{url}/api")),
10088 );
10089 let response = producer
10090 .oneshot(exchange)
10091 .await
10092 .expect("valid request through same producer must succeed");
10093 let status = response
10094 .input
10095 .header("CamelHttpResponseCode")
10096 .and_then(|v| v.as_u64())
10097 .unwrap();
10098 assert_eq!(status, 200);
10099 }
10100
10101 #[test]
10102 fn resolve_url_bridge_malformed_base_errors_no_panic() {
10103 let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10104 cfg.bridge_endpoint = true;
10105 cfg.query_params.push(("k".to_string(), "1".to_string()));
10106 cfg.base_url = "http://[::1:bad".to_string();
10111 let exchange = Exchange::new(Message::default());
10112
10113 let err = HttpProducer::resolve_url(&exchange, &cfg)
10114 .expect_err("malformed bridge base URL must error");
10115 assert!(
10116 err.to_string().contains("invalid base URL"),
10117 "error must name the invalid base URL: {err}"
10118 );
10119 }
10120
10121 #[test]
10122 fn test_http_producer_helpers_status_and_size_boundaries() {
10123 assert!(HttpProducer::is_ok_status(200, (200, 299)));
10124 assert!(HttpProducer::is_ok_status(299, (200, 299)));
10125 assert!(!HttpProducer::is_ok_status(199, (200, 299)));
10126 assert!(!HttpProducer::is_ok_status(300, (200, 299)));
10127
10128 assert!(!exceeds_max_response_body(10, 10));
10129 assert!(exceeds_max_response_body(11, 10));
10130 }
10131
10132 #[allow(clippy::await_holding_lock)]
10137 async fn setup_consumer_on_free_port(
10138 path: &str,
10139 ) -> (
10140 u16,
10141 tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
10142 tokio_util::sync::CancellationToken,
10143 ) {
10144 use camel_component_api::ConsumerContext;
10145
10146 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10151 let port = listener.local_addr().unwrap().port();
10152
10153 let _registry_guard = lock_registry_test_mutex();
10162
10163 ServerRegistry::global()
10164 .stage_listener(listener)
10165 .await
10166 .expect("stage consumer test listener");
10167
10168 let consumer_cfg = HttpServerConfig {
10169 scheme: "http".to_string(),
10170 host: "127.0.0.1".to_string(),
10171 port,
10172 path: path.to_string(),
10173 max_request_body: 2 * 1024 * 1024,
10174 max_response_body: 10 * 1024 * 1024,
10175 max_inflight_requests: 1024,
10176 method: None,
10177 tls_config: None,
10178 };
10179 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
10180
10181 let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
10182 let token = tokio_util::sync::CancellationToken::new();
10183 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
10184
10185 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10186
10187 wait_for_registry_ready("127.0.0.1", port).await;
10193 for _ in 0..8 {
10194 tokio::task::yield_now().await;
10195 }
10196
10197 (port, rx, token)
10198 }
10199
10200 async fn wait_for_registry_ready(host: &str, port: u16) {
10205 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
10206 let mut backoff = std::time::Duration::from_millis(1);
10207 while ServerRegistry::global().bound_addr(host, port).is_none() {
10208 assert!(
10209 tokio::time::Instant::now() < deadline,
10210 "consumer server did not become ready on port {port} — registry entry absent (concurrent reset or starvation)"
10211 );
10212 tokio::time::sleep(backoff).await;
10213 backoff = (backoff * 2).min(std::time::Duration::from_millis(64));
10214 }
10215 }
10216
10217 #[tokio::test]
10218 #[should_panic(expected = "registry entry absent (concurrent reset or starvation)")]
10219 async fn readiness_deadline_fires_loud_with_hint() {
10220 let held = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10231 let port = held.local_addr().unwrap().port();
10232 wait_for_registry_ready("httpflake-unreachable-host", port).await;
10233 }
10234
10235 #[tokio::test]
10240 async fn readiness_survives_concurrent_registry_reset() {
10241 let contended = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
10242 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
10243
10244 let contended_hammer = std::sync::Arc::clone(&contended);
10248 let stop_hammer = std::sync::Arc::clone(&stop);
10249 let handle = std::thread::spawn(move || {
10250 while !stop_hammer.load(std::sync::atomic::Ordering::Relaxed) {
10251 let _guard = match REGISTRY_TEST_MUTEX.try_lock() {
10252 Err(_) => {
10253 contended_hammer.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
10254 lock_registry_test_mutex()
10255 }
10256 Ok(guard) => guard,
10257 };
10258 ServerRegistry::reset();
10259 }
10260 });
10261
10262 struct StopHammerOnDrop {
10265 handle: Option<std::thread::JoinHandle<()>>,
10266 stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
10267 }
10268 impl Drop for StopHammerOnDrop {
10269 fn drop(&mut self) {
10270 self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
10271 if let Some(handle) = self.handle.take() {
10272 let _ = handle.join();
10273 }
10274 }
10275 }
10276 let _hammer_guard = StopHammerOnDrop {
10277 handle: Some(handle),
10278 stop,
10279 };
10280
10281 let mut setups = 0;
10284 loop {
10285 setups += 1;
10286 let (_port, rx, token) = setup_consumer_on_free_port("/reset-hammer").await;
10287 drop(rx);
10288 token.cancel();
10289 if (setups >= 25 && contended.load(std::sync::atomic::Ordering::SeqCst) >= 1)
10290 || setups >= 50
10291 {
10292 break;
10293 }
10294 }
10295
10296 let contended_hits = contended.load(std::sync::atomic::Ordering::SeqCst);
10297 assert!(
10298 contended_hits >= 1,
10299 "expected at least one contended registry reset across {setups} setups, got {contended_hits}"
10300 );
10301 }
10302
10303 #[tokio::test]
10304 async fn test_content_type_inferred_for_json_body() {
10305 let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
10306
10307 let client = reqwest::Client::new();
10308 let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
10309
10310 let (http_result, _) = tokio::join!(send_fut, async {
10311 if let Some(mut envelope) = rx.recv().await {
10312 envelope.exchange.input.body =
10313 camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
10314 if let Some(reply_tx) = envelope.reply_tx {
10315 let _ = reply_tx.send(Ok(envelope.exchange));
10316 }
10317 }
10318 });
10319
10320 let resp = http_result.unwrap();
10321 assert_eq!(resp.status().as_u16(), 200);
10322 let ct = resp
10323 .headers()
10324 .get("content-type")
10325 .expect("Content-Type header should be present");
10326 assert_eq!(ct, "application/json");
10327 let body = resp.text().await.unwrap();
10328 assert_eq!(body, r#"{"message":"hello"}"#);
10329
10330 token.cancel();
10331 }
10332
10333 #[tokio::test]
10334 async fn test_content_type_inferred_for_text_body() {
10335 let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
10336
10337 let client = reqwest::Client::new();
10338 let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
10339
10340 let (http_result, _) = tokio::join!(send_fut, async {
10341 if let Some(mut envelope) = rx.recv().await {
10342 envelope.exchange.input.body =
10343 camel_component_api::Body::Text("plain text response".to_string());
10344 if let Some(reply_tx) = envelope.reply_tx {
10345 let _ = reply_tx.send(Ok(envelope.exchange));
10346 }
10347 }
10348 });
10349
10350 let resp = http_result.unwrap();
10351 assert_eq!(resp.status().as_u16(), 200);
10352 let ct = resp
10353 .headers()
10354 .get("content-type")
10355 .expect("Content-Type header should be present");
10356 assert_eq!(ct, "text/plain; charset=utf-8");
10357 let body = resp.text().await.unwrap();
10358 assert_eq!(body, "plain text response");
10359
10360 token.cancel();
10361 }
10362
10363 #[tokio::test]
10364 async fn test_content_type_inferred_for_xml_body() {
10365 let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
10366
10367 let client = reqwest::Client::new();
10368 let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
10369
10370 let (http_result, _) = tokio::join!(send_fut, async {
10371 if let Some(mut envelope) = rx.recv().await {
10372 envelope.exchange.input.body =
10373 camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
10374 if let Some(reply_tx) = envelope.reply_tx {
10375 let _ = reply_tx.send(Ok(envelope.exchange));
10376 }
10377 }
10378 });
10379
10380 let resp = http_result.unwrap();
10381 assert_eq!(resp.status().as_u16(), 200);
10382 let ct = resp
10383 .headers()
10384 .get("content-type")
10385 .expect("Content-Type header should be present");
10386 assert_eq!(ct, "application/xml");
10387 let body = resp.text().await.unwrap();
10388 assert_eq!(body, "<root><item>value</item></root>");
10389
10390 token.cancel();
10391 }
10392
10393 #[tokio::test]
10394 async fn test_no_content_type_for_empty_body() {
10395 let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
10396
10397 let client = reqwest::Client::new();
10398 let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
10399
10400 let (http_result, _) = tokio::join!(send_fut, async {
10401 if let Some(mut envelope) = rx.recv().await {
10402 envelope.exchange.input.body = camel_component_api::Body::Empty;
10403 if let Some(reply_tx) = envelope.reply_tx {
10404 let _ = reply_tx.send(Ok(envelope.exchange));
10405 }
10406 }
10407 });
10408
10409 let resp = http_result.unwrap();
10410 assert_eq!(resp.status().as_u16(), 200);
10411 assert!(
10412 resp.headers().get("content-type").is_none(),
10413 "Empty body should not set Content-Type"
10414 );
10415
10416 token.cancel();
10417 }
10418
10419 #[tokio::test]
10420 async fn test_no_content_type_for_raw_bytes_body() {
10421 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
10422
10423 let client = reqwest::Client::new();
10424 let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
10425
10426 let (http_result, _) = tokio::join!(send_fut, async {
10427 if let Some(mut envelope) = rx.recv().await {
10428 envelope.exchange.input.body =
10429 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
10430 if let Some(reply_tx) = envelope.reply_tx {
10431 let _ = reply_tx.send(Ok(envelope.exchange));
10432 }
10433 }
10434 });
10435
10436 let resp = http_result.unwrap();
10437 assert_eq!(resp.status().as_u16(), 200);
10438 assert!(
10439 resp.headers().get("content-type").is_none(),
10440 "Raw Bytes body should not set Content-Type"
10441 );
10442
10443 token.cancel();
10444 }
10445
10446 #[tokio::test]
10447 async fn test_content_type_from_stream_metadata() {
10448 use camel_component_api::{StreamBody, StreamMetadata};
10449 use futures::stream;
10450
10451 let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
10452
10453 let client = reqwest::Client::new();
10454 let send_fut = client
10455 .get(format!("http://127.0.0.1:{port}/stream-ct"))
10456 .send();
10457
10458 let (http_result, _) = tokio::join!(send_fut, async {
10459 if let Some(mut envelope) = rx.recv().await {
10460 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
10461 vec![Ok(bytes::Bytes::from("audio data"))];
10462 let stream = Box::pin(stream::iter(chunks));
10463 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
10464 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
10465 metadata: StreamMetadata {
10466 size_hint: None,
10467 content_type: Some("audio/mpeg".to_string()),
10468 origin: None,
10469 },
10470 });
10471 if let Some(reply_tx) = envelope.reply_tx {
10472 let _ = reply_tx.send(Ok(envelope.exchange));
10473 }
10474 }
10475 });
10476
10477 let resp = http_result.unwrap();
10478 assert_eq!(resp.status().as_u16(), 200);
10479 let ct = resp
10480 .headers()
10481 .get("content-type")
10482 .expect("Content-Type header should be present");
10483 assert_eq!(ct, "audio/mpeg");
10484 let body = resp.text().await.unwrap();
10485 assert_eq!(body, "audio data");
10486
10487 token.cancel();
10488 }
10489
10490 #[tokio::test]
10491 async fn test_user_content_type_overrides_inferred() {
10492 let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
10493
10494 let client = reqwest::Client::new();
10495 let send_fut = client
10496 .get(format!("http://127.0.0.1:{port}/override-ct"))
10497 .send();
10498
10499 let (http_result, _) = tokio::join!(send_fut, async {
10500 if let Some(mut envelope) = rx.recv().await {
10501 envelope.exchange.input.body =
10502 camel_component_api::Body::Json(serde_json::json!({"ok": true}));
10503 envelope.exchange.input.set_header(
10504 "Content-Type",
10505 serde_json::Value::String("text/html".to_string()),
10506 );
10507 if let Some(reply_tx) = envelope.reply_tx {
10508 let _ = reply_tx.send(Ok(envelope.exchange));
10509 }
10510 }
10511 });
10512
10513 let resp = http_result.unwrap();
10514 assert_eq!(resp.status().as_u16(), 200);
10515 let ct = resp
10516 .headers()
10517 .get("content-type")
10518 .expect("Content-Type header should be present");
10519 assert_eq!(
10520 ct, "text/html",
10521 "User-set Content-Type should take precedence over inferred type"
10522 );
10523
10524 token.cancel();
10525 }
10526
10527 #[tokio::test]
10528 async fn test_user_content_type_with_bytes_body() {
10529 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
10530
10531 let client = reqwest::Client::new();
10532 let send_fut = client
10533 .get(format!("http://127.0.0.1:{port}/bytes-ct"))
10534 .send();
10535
10536 let (http_result, _) = tokio::join!(send_fut, async {
10537 if let Some(mut envelope) = rx.recv().await {
10538 envelope.exchange.input.body =
10539 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
10540 envelope.exchange.input.set_header(
10541 "Content-Type",
10542 serde_json::Value::String("application/json".to_string()),
10543 );
10544 if let Some(reply_tx) = envelope.reply_tx {
10545 let _ = reply_tx.send(Ok(envelope.exchange));
10546 }
10547 }
10548 });
10549
10550 let resp = http_result.unwrap();
10551 assert_eq!(resp.status().as_u16(), 200);
10552 let ct = resp
10553 .headers()
10554 .get("content-type")
10555 .expect("Content-Type header should be present for Bytes body with user header");
10556 assert_eq!(
10557 ct, "application/json",
10558 "User Content-Type should be sent for Bytes body"
10559 );
10560
10561 token.cancel();
10562 }
10563
10564 #[tokio::test]
10569 async fn monitor_task_silent_on_clean_exit() {
10570 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
10571 let server_exited = tokio_util::sync::CancellationToken::new();
10572 monitor_axum_task(
10574 handle,
10575 "127.0.0.1:0".to_string(),
10576 noop_rt(),
10577 "test-monitor".into(),
10578 server_exited.clone(),
10579 )
10580 .await;
10581 assert!(
10585 !server_exited.is_cancelled(),
10586 "clean server exit must not cancel server_exited"
10587 );
10588 }
10589
10590 #[tokio::test]
10591 async fn monitor_task_handles_panicked_task() {
10592 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
10593 panic!("simulated server crash");
10594 });
10595 let server_exited = tokio_util::sync::CancellationToken::new();
10596 monitor_axum_task(
10598 handle,
10599 "127.0.0.1:9999".to_string(),
10600 noop_rt(),
10601 "test-monitor".into(),
10602 server_exited.clone(),
10603 )
10604 .await;
10605 assert!(
10608 server_exited.is_cancelled(),
10609 "crashed server must cancel server_exited"
10610 );
10611 }
10612
10613 #[test]
10618 fn http_auth_basic_debug_redacts_password() {
10619 let auth = HttpAuth::Basic {
10620 username: "admin".to_string(),
10621 password: "hunter2".to_string(),
10622 };
10623 let debug = format!("{:?}", auth);
10624 assert!(
10625 !debug.contains("hunter2"),
10626 "password must be redacted: {debug}"
10627 );
10628 assert!(debug.contains("admin"), "username should appear: {debug}");
10629 }
10630
10631 #[test]
10632 fn http_auth_bearer_debug_redacts_token() {
10633 let auth = HttpAuth::Bearer {
10634 token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
10635 };
10636 let debug = format!("{:?}", auth);
10637 assert!(
10638 !debug.contains("eyJhbGci"),
10639 "token must be redacted: {debug}"
10640 );
10641 }
10642
10643 #[test]
10644 fn http_auth_none_debug_shows_variant() {
10645 let debug = format!("{:?}", HttpAuth::None);
10646 assert!(
10647 debug.contains("None"),
10648 "None variant should appear: {debug}"
10649 );
10650 }
10651
10652 #[test]
10653 fn http_endpoint_config_debug_redacts_auth_credentials() {
10654 let config = HttpEndpointConfig::from_uri(
10655 "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
10656 )
10657 .unwrap();
10658 let debug = format!("{:?}", config);
10659 assert!(
10660 !debug.contains("secret123"),
10661 "password must be redacted in HttpEndpointConfig debug: {debug}"
10662 );
10663 }
10664
10665 #[test]
10666 fn debug_lists_all_public_fields() {
10667 let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
10668 let debug = format!("{:?}", config);
10669 for field in [
10670 "base_url",
10671 "http_method",
10672 "throw_exception_on_failure",
10673 "ok_status_code_range",
10674 "response_timeout",
10675 "query_params",
10676 "raw_query",
10677 "allow_internal",
10678 "blocked_hosts",
10679 "max_body_size",
10680 "read_timeout_ms",
10681 "max_response_bytes",
10682 "auth",
10683 "token_provider",
10684 "user_agent",
10685 "bridge_endpoint",
10686 "connection_close",
10687 "skip_request_headers",
10688 "skip_response_headers",
10689 "follow_redirects",
10690 "max_redirects",
10691 ] {
10692 assert!(
10693 debug.contains(field),
10694 "Debug output missing field '{field}': {debug}"
10695 );
10696 }
10697 }
10698
10699 use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
10704 use tower_http::services::ServeDir;
10705
10706 fn make_test_registry() -> HttpRouteRegistry {
10707 HttpRouteRegistry::new()
10708 }
10709
10710 fn make_test_state(registry: HttpRouteRegistry) -> AppState {
10711 AppState {
10712 registry,
10713 max_request_body: 2 * 1024 * 1024,
10714 max_response_body: 10 * 1024 * 1024,
10715 inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
10716 }
10717 }
10718
10719 #[allow(clippy::await_holding_lock)]
10720 #[tokio::test]
10721 async fn test_static_file_serving_serves_file_contents() {
10722 let _guard = lock_registry_test_mutex();
10723 ServerRegistry::reset();
10724
10725 let temp_dir =
10727 std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
10728 std::fs::create_dir_all(&temp_dir).unwrap();
10729 std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
10730 std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
10731
10732 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
10733
10734 let registry = make_test_registry();
10735 let serve_dir = ServeDir::new(&canonical_dir)
10736 .precompressed_gzip()
10737 .precompressed_br()
10738 .append_index_html_on_directories(true);
10739
10740 let mount = StaticMount {
10741 mount_path: "/".to_string(),
10742 mode: MountMode::Static,
10743 dir: canonical_dir.clone(),
10744 cache_control: "public, max-age=3600".to_string(),
10745 error_pages: std::collections::HashMap::new(),
10746 serve_dir,
10747 };
10748 registry.register_static_mount(mount).await.unwrap();
10749
10750 let state = make_test_state(registry);
10751
10752 let req = Request::builder()
10754 .uri("/hello.txt")
10755 .body(AxumBody::empty())
10756 .unwrap();
10757 let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
10758 assert_eq!(resp.status(), StatusCode::OK);
10759 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10760 .await
10761 .unwrap();
10762 assert_eq!(&body[..], b"Hello, static world!");
10763
10764 let req = Request::builder()
10766 .uri("/style.css")
10767 .body(AxumBody::empty())
10768 .unwrap();
10769 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
10770 assert_eq!(resp.status(), StatusCode::OK);
10771 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10772 .await
10773 .unwrap();
10774 assert_eq!(&body[..], b"body { color: red; }");
10775
10776 let req = Request::builder()
10778 .uri("/missing.txt")
10779 .body(AxumBody::empty())
10780 .unwrap();
10781 let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
10782 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
10783
10784 std::fs::remove_dir_all(&temp_dir).ok();
10786 }
10787
10788 #[allow(clippy::await_holding_lock)]
10789 #[tokio::test]
10790 async fn test_spa_fallback_serves_index_for_unknown_paths() {
10791 let _guard = lock_registry_test_mutex();
10792 ServerRegistry::reset();
10793
10794 let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
10795 std::fs::create_dir_all(&temp_dir).unwrap();
10796 std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
10797 std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
10798
10799 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
10800
10801 let registry = make_test_registry();
10802 let serve_dir = ServeDir::new(&canonical_dir)
10803 .precompressed_gzip()
10804 .precompressed_br()
10805 .append_index_html_on_directories(true);
10806
10807 let mount = StaticMount {
10808 mount_path: "/".to_string(),
10809 mode: MountMode::Spa,
10810 dir: canonical_dir.clone(),
10811 cache_control: "public, max-age=0".to_string(),
10812 error_pages: std::collections::HashMap::new(),
10813 serve_dir,
10814 };
10815 registry.register_static_mount(mount).await.unwrap();
10817
10818 let state = make_test_state(registry);
10819
10820 let req = Request::builder()
10822 .method("GET")
10823 .uri("/dashboard")
10824 .header("Accept", "text/html")
10825 .body(AxumBody::empty())
10826 .unwrap();
10827 let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
10828 assert_eq!(resp.status(), StatusCode::OK);
10829 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10830 .await
10831 .unwrap();
10832 assert_eq!(&body[..], b"<h1>SPA App</h1>");
10833
10834 let req = Request::builder()
10836 .method("GET")
10837 .uri("/app.js")
10838 .body(AxumBody::empty())
10839 .unwrap();
10840 let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
10841 assert_eq!(resp.status(), StatusCode::OK);
10842 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10843 .await
10844 .unwrap();
10845 assert_eq!(&body[..], b"console.log('app')");
10846
10847 let req = Request::builder()
10849 .method("GET")
10850 .uri("/api/data")
10851 .header("Accept", "application/json")
10852 .body(AxumBody::empty())
10853 .unwrap();
10854 let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
10855 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
10856
10857 let req = Request::builder()
10859 .method("GET")
10860 .uri("/style.css")
10861 .header("Accept", "text/html")
10862 .body(AxumBody::empty())
10863 .unwrap();
10864 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
10865 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
10866
10867 std::fs::remove_dir_all(&temp_dir).ok();
10869 }
10870
10871 #[allow(clippy::await_holding_lock)]
10878 async fn run_conditional_get_returns_304(mode: MountMode) {
10879 let _guard = lock_registry_test_mutex();
10880 ServerRegistry::reset();
10881
10882 let temp_dir = std::env::temp_dir().join(format!(
10883 "http_cond_get_{}_{}",
10884 if mode == MountMode::Spa {
10885 "spa"
10886 } else {
10887 "static"
10888 },
10889 std::process::id()
10890 ));
10891 std::fs::create_dir_all(&temp_dir).unwrap();
10892 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
10893
10894 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
10895
10896 let registry = make_test_registry();
10897 let serve_dir = ServeDir::new(&canonical_dir)
10898 .precompressed_gzip()
10899 .precompressed_br()
10900 .append_index_html_on_directories(true);
10901
10902 let mount = StaticMount {
10903 mount_path: "/".to_string(),
10904 mode,
10905 dir: canonical_dir.clone(),
10906 cache_control: "public, max-age=3600".to_string(),
10907 error_pages: std::collections::HashMap::new(),
10908 serve_dir,
10909 };
10910 registry.register_static_mount(mount).await.unwrap();
10911
10912 let state = make_test_state(registry);
10913
10914 let req = Request::builder()
10916 .method("GET")
10917 .uri("/index.html")
10918 .body(AxumBody::empty())
10919 .unwrap();
10920 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
10921 assert_eq!(
10922 resp.status(),
10923 StatusCode::OK,
10924 "first GET should return 200, got {}",
10925 resp.status()
10926 );
10927 assert!(
10929 resp.headers().contains_key(http::header::CACHE_CONTROL),
10930 "200 response missing Cache-Control"
10931 );
10932 let etag = resp
10933 .headers()
10934 .get(http::header::ETAG)
10935 .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
10936 .clone();
10937 let last_modified = resp
10938 .headers()
10939 .get(http::header::LAST_MODIFIED)
10940 .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
10941 .clone();
10942 let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
10944 .await
10945 .unwrap();
10946
10947 let req = Request::builder()
10951 .method("GET")
10952 .uri("/index.html")
10953 .header(http::header::IF_NONE_MATCH, etag.clone())
10954 .body(AxumBody::empty())
10955 .unwrap();
10956 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
10957 assert_eq!(
10958 resp.status(),
10959 StatusCode::NOT_MODIFIED,
10960 "If-None-Match with matching ETag should return 304, got {}",
10961 resp.status()
10962 );
10963 assert!(
10965 resp.headers().contains_key(http::header::CACHE_CONTROL),
10966 "304 (If-None-Match) missing Cache-Control"
10967 );
10968 assert_eq!(
10971 resp.headers().get(http::header::ETAG),
10972 Some(&etag),
10973 "304 (If-None-Match) must echo the ETag validator"
10974 );
10975 assert_eq!(
10976 resp.headers().get(http::header::LAST_MODIFIED),
10977 Some(&last_modified),
10978 "304 (If-None-Match) must carry Last-Modified"
10979 );
10980
10981 let req = Request::builder()
10983 .method("GET")
10984 .uri("/index.html")
10985 .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
10986 .body(AxumBody::empty())
10987 .unwrap();
10988 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
10989 assert_eq!(
10990 resp.status(),
10991 StatusCode::NOT_MODIFIED,
10992 "If-Modified-Since with matching timestamp should return 304, got {}",
10993 resp.status()
10994 );
10995 assert!(
10996 resp.headers().contains_key(http::header::CACHE_CONTROL),
10997 "304 (If-Modified-Since) missing Cache-Control"
10998 );
10999 assert_eq!(
11000 resp.headers().get(http::header::ETAG),
11001 Some(&etag),
11002 "304 (If-Modified-Since) must carry the ETag validator"
11003 );
11004 assert_eq!(
11005 resp.headers().get(http::header::LAST_MODIFIED),
11006 Some(&last_modified),
11007 "304 (If-Modified-Since) must echo Last-Modified"
11008 );
11009
11010 let req = Request::builder()
11016 .method("GET")
11017 .uri("/index.html")
11018 .header(
11019 http::header::IF_MODIFIED_SINCE,
11020 "Wed, 21 Oct 2000 07:28:00 GMT",
11021 )
11022 .body(AxumBody::empty())
11023 .unwrap();
11024 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11025 assert_eq!(
11026 resp.status(),
11027 StatusCode::OK,
11028 "past If-Modified-Since should return 200 (file modified after it), got {}",
11029 resp.status()
11030 );
11031
11032 std::fs::remove_dir_all(&temp_dir).ok();
11034 }
11035
11036 #[tokio::test]
11037 async fn test_conditional_get_returns_304_static_mode() {
11038 run_conditional_get_returns_304(MountMode::Static).await;
11039 }
11040
11041 #[tokio::test]
11042 async fn test_conditional_get_returns_304_spa_mode() {
11043 run_conditional_get_returns_304(MountMode::Spa).await;
11044 }
11045
11046 #[allow(clippy::await_holding_lock)]
11047 #[tokio::test]
11048 async fn test_error_page_mapping_serves_custom_404() {
11049 let _guard = lock_registry_test_mutex();
11050 ServerRegistry::reset();
11051
11052 let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
11053 let errors_dir = temp_dir.join("errors");
11054 std::fs::create_dir_all(&errors_dir).unwrap();
11055 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
11056 std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
11057
11058 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11059 let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
11060
11061 let registry = make_test_registry();
11062 let serve_dir = ServeDir::new(&canonical_dir)
11063 .precompressed_gzip()
11064 .precompressed_br()
11065 .append_index_html_on_directories(true);
11066
11067 let mut error_pages = std::collections::HashMap::new();
11068 error_pages.insert(404, canonical_404);
11069
11070 let mount = StaticMount {
11071 mount_path: "/".to_string(),
11072 mode: MountMode::Static,
11073 dir: canonical_dir.clone(),
11074 cache_control: "public, max-age=0".to_string(),
11075 error_pages,
11076 serve_dir,
11077 };
11078 registry.register_static_mount(mount).await.unwrap();
11079
11080 let state = make_test_state(registry);
11081
11082 let req = Request::builder()
11084 .method("GET")
11085 .uri("/missing.html")
11086 .body(AxumBody::empty())
11087 .unwrap();
11088 let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
11089 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
11090 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11091 .await
11092 .unwrap();
11093 assert_eq!(&body[..], b"<h1>Custom 404</h1>");
11094
11095 let req = Request::builder()
11097 .method("GET")
11098 .uri("/index.html")
11099 .body(AxumBody::empty())
11100 .unwrap();
11101 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
11102 assert_eq!(resp.status(), StatusCode::OK);
11103 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11104 .await
11105 .unwrap();
11106 assert_eq!(&body[..], b"<h1>Home</h1>");
11107
11108 std::fs::remove_dir_all(&temp_dir).ok();
11110 }
11111
11112 #[tokio::test]
11113 async fn http_consumer_returns_body_and_code_on_stop() {
11114 use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
11115 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
11116 use tower::ServiceExt;
11117
11118 let set_body_step = CompiledStep::Process {
11120 kind_hint: camel_api::SpanKindHint::Internal,
11121 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
11122 ex.input.body = Body::Text("nope".into());
11123 Box::pin(async move { Ok(ex) })
11124 }),
11125 body_contract: None,
11126 lifecycle: None,
11127 label: None,
11128 to_uri: None,
11129 };
11130 let set_status_step = CompiledStep::Process {
11131 kind_hint: camel_api::SpanKindHint::Internal,
11132 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
11133 ex.input.set_header(
11134 "CamelHttpResponseCode",
11135 serde_json::Value::Number(409.into()),
11136 );
11137 Box::pin(async move { Ok(ex) })
11138 }),
11139 body_contract: None,
11140 lifecycle: None,
11141 label: None,
11142 to_uri: None,
11143 };
11144 let pipeline = compose_pipeline_with_handler(
11145 vec![set_body_step, set_status_step, CompiledStep::Stop],
11146 None,
11147 PipelineRuntimeCtx::compile_time(),
11148 );
11149
11150 let ex = Exchange::new(Message::default());
11151 let result = pipeline.oneshot(ex).await;
11152 assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
11153 let returned = result.unwrap();
11154 assert_eq!(returned.input.body.as_text(), Some("nope"));
11155 assert_eq!(
11156 returned
11157 .input
11158 .header("CamelHttpResponseCode")
11159 .and_then(|v| v.as_u64()),
11160 Some(409)
11161 );
11162 }
11163
11164 #[tokio::test]
11165 async fn http_consumer_returns_200_when_body_empty_on_stop() {
11166 use camel_api::{Exchange, Message};
11174 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
11175 use tower::ServiceExt;
11176
11177 let pipeline = compose_pipeline_with_handler(
11178 vec![CompiledStep::Stop],
11179 None,
11180 PipelineRuntimeCtx::compile_time(),
11181 );
11182 let ex = Exchange::new(Message::default());
11183 let result = pipeline.oneshot(ex).await;
11184 assert!(result.is_ok(), "Stop with empty body arrives as Ok");
11185 }
11188
11189 async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
11197 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11198 let port = listener.local_addr().unwrap().port();
11199 let registry = HttpRouteRegistry::new();
11200 tokio::spawn(run_axum_server(
11201 listener,
11202 registry.clone(),
11203 2 * 1024 * 1024,
11204 10 * 1024 * 1024,
11205 Arc::new(tokio::sync::Semaphore::new(1024)),
11206 test_rt(),
11207 "test-route".into(),
11208 ));
11209 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
11211 (port, registry)
11212 }
11213
11214 fn spawn_responder(
11219 mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
11220 status: u16,
11221 body: String,
11222 ) -> tokio::task::JoinHandle<()> {
11223 tokio::spawn(async move {
11224 if let Some(envelope) = rx.recv().await {
11225 let _ = envelope.reply_tx.send(HttpReply {
11226 status,
11227 headers: vec![],
11228 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
11229 });
11230 }
11231 })
11232 }
11233
11234 #[tokio::test]
11235 async fn method_aware_dispatch_same_path_different_verbs() {
11236 let (port, registry) = spawn_test_server().await;
11237
11238 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11243 registry
11244 .register_rest_endpoint(
11245 "GET".into(),
11246 vec![PathSegment::Literal("users".into())],
11247 get_tx,
11248 )
11249 .await;
11250
11251 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11252 registry
11253 .register_rest_endpoint(
11254 "POST".into(),
11255 vec![PathSegment::Literal("users".into())],
11256 post_tx,
11257 )
11258 .await;
11259
11260 let get_handle = spawn_responder(get_rx, 200, "list".into());
11261 let post_handle = spawn_responder(post_rx, 201, "create".into());
11262
11263 let client = reqwest::Client::new();
11264
11265 let resp = client
11267 .get(format!("http://127.0.0.1:{port}/users"))
11268 .send()
11269 .await
11270 .unwrap();
11271 assert_eq!(resp.status().as_u16(), 200);
11272 let body = resp.text().await.unwrap();
11273 assert_eq!(body, "list");
11274
11275 let resp = client
11277 .post(format!("http://127.0.0.1:{port}/users"))
11278 .send()
11279 .await
11280 .unwrap();
11281 assert_eq!(resp.status().as_u16(), 201);
11282 let body = resp.text().await.unwrap();
11283 assert_eq!(body, "create");
11284
11285 let _ = tokio::join!(get_handle, post_handle);
11286 }
11287
11288 #[tokio::test]
11289 async fn method_aware_dispatch_templated_path_extracts_params() {
11290 let (port, registry) = spawn_test_server().await;
11291
11292 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11296 registry
11297 .register_rest_endpoint(
11298 "GET".into(),
11299 vec![
11300 PathSegment::Literal("users".into()),
11301 PathSegment::Param("id".into()),
11302 ],
11303 tx,
11304 )
11305 .await;
11306
11307 let handle = tokio::spawn(async move {
11310 if let Some(envelope) = rx.recv().await {
11311 let id = envelope.path_params.get("id").cloned().unwrap_or_default();
11312 let _ = envelope.reply_tx.send(HttpReply {
11313 status: 200,
11314 headers: vec![],
11315 body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
11316 });
11317 }
11318 });
11319
11320 let client = reqwest::Client::new();
11321 let resp = client
11322 .get(format!("http://127.0.0.1:{port}/users/42"))
11323 .send()
11324 .await
11325 .unwrap();
11326 assert_eq!(resp.status().as_u16(), 200);
11327 let body = resp.text().await.unwrap();
11328 assert_eq!(body, "id=42");
11329
11330 let _ = handle.await;
11331 }
11332
11333 #[tokio::test]
11334 async fn method_aware_dispatch_unmatched_method_falls_through() {
11335 let (port, _registry) = spawn_test_server().await;
11340
11341 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11343 _registry
11344 .register_rest_endpoint(
11345 "GET".into(),
11346 vec![PathSegment::Literal("users".into())],
11347 get_tx,
11348 )
11349 .await;
11350
11351 let drain = tokio::spawn(async move {
11354 let mut get_rx = get_rx;
11355 while get_rx.recv().await.is_some() {}
11356 });
11357
11358 let client = reqwest::Client::new();
11359 let resp = client
11360 .delete(format!("http://127.0.0.1:{port}/users"))
11361 .send()
11362 .await
11363 .unwrap();
11364 assert_eq!(resp.status().as_u16(), 404);
11365
11366 drop(drain);
11367 }
11368
11369 #[tokio::test]
11370 async fn regression_legacy_exact_api_route_still_works() {
11371 let (port, registry) = spawn_test_server().await;
11376
11377 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11378 registry.register_api_route("/legacy/path".into(), tx).await;
11379
11380 let handle = tokio::spawn(async move {
11381 if let Some(envelope) = rx.recv().await {
11382 let _ = envelope.reply_tx.send(HttpReply {
11383 status: 200,
11384 headers: vec![],
11385 body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
11386 });
11387 }
11388 });
11389
11390 let client = reqwest::Client::new();
11391 let resp = client
11392 .get(format!("http://127.0.0.1:{port}/legacy/path"))
11393 .send()
11394 .await
11395 .unwrap();
11396 assert_eq!(resp.status().as_u16(), 200);
11397 let body = resp.text().await.unwrap();
11398 assert_eq!(body, "legacy ok");
11399
11400 let _ = handle.await;
11401 }
11402
11403 #[allow(clippy::await_holding_lock)]
11404 #[tokio::test]
11405 async fn regression_static_mount_still_works() {
11406 let _guard = lock_registry_test_mutex();
11410 ServerRegistry::reset();
11411
11412 let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
11413 std::fs::create_dir_all(&temp_dir).unwrap();
11414 std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
11415 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
11416
11417 let registry = make_test_registry();
11418 let serve_dir = ServeDir::new(&canonical_dir)
11419 .precompressed_gzip()
11420 .precompressed_br()
11421 .append_index_html_on_directories(true);
11422 let mount = StaticMount {
11423 mount_path: "/".to_string(),
11424 mode: MountMode::Static,
11425 dir: canonical_dir.clone(),
11426 cache_control: "public, max-age=3600".to_string(),
11427 error_pages: std::collections::HashMap::new(),
11428 serve_dir,
11429 };
11430 registry.register_static_mount(mount).await.unwrap();
11431
11432 let state = make_test_state(registry);
11433 let req = Request::builder()
11434 .uri("/regress.txt")
11435 .body(AxumBody::empty())
11436 .unwrap();
11437 let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
11438 assert_eq!(resp.status(), StatusCode::OK);
11439 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
11440 .await
11441 .unwrap();
11442 assert_eq!(&body[..], b"static works");
11443
11444 std::fs::remove_dir_all(&temp_dir).ok();
11445 }
11446
11447 #[tokio::test]
11456 async fn deregister_one_method_keeps_sibling_verbs() {
11457 let (port, registry) = spawn_test_server().await;
11461
11462 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11463 registry
11464 .register_rest_endpoint(
11465 "GET".into(),
11466 vec![PathSegment::Literal("users".into())],
11467 get_tx,
11468 )
11469 .await;
11470
11471 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11472 registry
11473 .register_rest_endpoint(
11474 "POST".into(),
11475 vec![PathSegment::Literal("users".into())],
11476 post_tx,
11477 )
11478 .await;
11479
11480 let drain = tokio::spawn(async move {
11482 let mut get_rx = get_rx;
11483 while get_rx.recv().await.is_some() {}
11484 });
11485
11486 registry.unregister_rest_endpoint("GET", "/users").await;
11488 drop(drain);
11489
11490 let post_handle = spawn_responder(post_rx, 201, "create".into());
11491
11492 let client = reqwest::Client::new();
11493 let resp = client
11495 .post(format!("http://127.0.0.1:{port}/users"))
11496 .send()
11497 .await
11498 .unwrap();
11499 assert_eq!(resp.status().as_u16(), 201);
11500 assert_eq!(resp.text().await.unwrap(), "create");
11501
11502 let _ = post_handle.await;
11503 }
11504
11505 #[tokio::test]
11506 async fn dispatch_exact_legacy_beats_rest_template() {
11507 let (port, registry) = spawn_test_server().await;
11512
11513 let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11515 registry
11516 .register_api_route("/api/users".into(), exact_tx)
11517 .await;
11518 let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
11519
11520 let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11522 registry
11523 .register_rest_endpoint(
11524 "GET".into(),
11525 vec![
11526 PathSegment::Literal("api".into()),
11527 PathSegment::Param("resource".into()),
11528 ],
11529 tpl_tx,
11530 )
11531 .await;
11532 let _tpl_drain = tokio::spawn(async move {
11538 let mut tpl_rx = tpl_rx;
11539 if let Some(env) = tpl_rx.recv().await {
11540 let _ = env.reply_tx.send(HttpReply {
11541 status: 200,
11542 headers: vec![],
11543 body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
11544 });
11545 }
11546 });
11547
11548 let client = reqwest::Client::new();
11549 let resp = client
11550 .get(format!("http://127.0.0.1:{port}/api/users"))
11551 .send()
11552 .await
11553 .unwrap();
11554 assert_eq!(resp.status().as_u16(), 200);
11555 assert_eq!(resp.text().await.unwrap(), "exact");
11557
11558 let _ = exact_handle.await;
11559 }
11560
11561 #[tokio::test]
11562 async fn ambiguous_rest_templates_return_500_not_silent_404() {
11563 let (port, registry) = spawn_test_server().await;
11568
11569 let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11570 registry
11571 .register_rest_endpoint(
11572 "GET".into(),
11573 vec![
11574 PathSegment::Literal("users".into()),
11575 PathSegment::Param("id".into()),
11576 ],
11577 a_tx,
11578 )
11579 .await;
11580
11581 let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11582 registry
11583 .register_rest_endpoint(
11584 "GET".into(),
11585 vec![
11586 PathSegment::Literal("users".into()),
11587 PathSegment::Param("name".into()),
11588 ],
11589 b_tx,
11590 )
11591 .await;
11592
11593 let client = reqwest::Client::new();
11594 let resp = client
11595 .get(format!("http://127.0.0.1:{port}/users/42"))
11596 .send()
11597 .await
11598 .unwrap();
11599 assert_eq!(resp.status().as_u16(), 500);
11601 }
11602
11603 #[test]
11604 fn from_uri_round_trips_templated_path_with_http_method() {
11605 use crate::UriConfig;
11611 let cfg =
11612 HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
11613 assert_eq!(cfg.host, "0.0.0.0");
11614 assert_eq!(cfg.port, 8080);
11615 assert_eq!(cfg.path, "/users/{id}");
11616 assert_eq!(cfg.method.as_deref(), Some("GET"));
11617
11618 let cfg_lc =
11620 HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
11621 assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
11622 assert_eq!(cfg_lc.path, "/orders");
11623 }
11624
11625 #[test]
11630 fn type_conversion_failed_maps_to_400() {
11631 let reply = pipeline_error_to_reply(
11632 CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
11633 "/api/users",
11634 );
11635 assert_eq!(reply.status, 400);
11636 let json_ct = reply
11638 .headers
11639 .iter()
11640 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11641 .count();
11642 assert_eq!(json_ct, 1);
11643 let body = match &reply.body {
11645 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11646 _ => panic!("expected bytes body"),
11647 };
11648 let parsed: serde_json::Value =
11649 serde_json::from_str(&body).expect("body must be valid JSON");
11650 assert_eq!(parsed["error"], "bad_request");
11651 assert_eq!(parsed["message"], "invalid JSON at line 1");
11652 }
11653
11654 #[test]
11655 fn other_error_still_maps_to_500() {
11656 let reply =
11657 pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
11658 assert_eq!(reply.status, 500);
11659 }
11660
11661 #[test]
11662 fn unauthenticated_maps_to_401() {
11663 let reply = pipeline_error_to_reply(
11664 CamelError::Unauthenticated("no token".to_string()),
11665 "/api/users",
11666 );
11667 assert_eq!(reply.status, 401);
11668 }
11669
11670 #[test]
11671 fn unauthorized_maps_to_403() {
11672 let reply = pipeline_error_to_reply(
11673 CamelError::Unauthorized("forbidden".to_string()),
11674 "/api/users",
11675 );
11676 assert_eq!(reply.status, 403);
11677 }
11678
11679 #[test]
11680 fn validation_error_maps_to_400() {
11681 let reply = pipeline_error_to_reply(
11682 CamelError::ValidationError("body does not match schema".to_string()),
11683 "/api/users",
11684 );
11685 assert_eq!(reply.status, 400);
11686 let json_ct = reply
11687 .headers
11688 .iter()
11689 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11690 .count();
11691 assert_eq!(json_ct, 1);
11692 let body = match &reply.body {
11693 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11694 _ => panic!("expected bytes body"),
11695 };
11696 let parsed: serde_json::Value =
11697 serde_json::from_str(&body).expect("body must be valid JSON");
11698 assert_eq!(parsed["error"], "validation_error");
11699 assert_eq!(parsed["message"], "body does not match schema");
11700 }
11701
11702 #[test]
11707 fn finalizer_maps_unsupported_media_type() {
11708 let reply = pipeline_error_to_reply(
11709 CamelError::UnsupportedMediaType {
11710 consumed: "text/plain".to_string(),
11711 declared: "application/json".to_string(),
11712 },
11713 "/x",
11714 );
11715 assert_eq!(reply.status, 415);
11716 let json_ct = reply
11717 .headers
11718 .iter()
11719 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11720 .count();
11721 assert_eq!(json_ct, 1);
11722 let body = match &reply.body {
11723 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11724 _ => panic!("expected bytes body"),
11725 };
11726 let parsed: serde_json::Value =
11727 serde_json::from_str(&body).expect("body must be valid JSON");
11728 assert_eq!(parsed["error"], "unsupported_media_type");
11729 assert_eq!(
11730 parsed["message"],
11731 "consumed text/plain, declared application/json"
11732 );
11733 }
11734
11735 #[test]
11736 fn finalizer_maps_not_acceptable() {
11737 let reply = pipeline_error_to_reply(
11738 CamelError::NotAcceptable {
11739 accept: "application/xml".to_string(),
11740 produced: "application/json".to_string(),
11741 },
11742 "/x",
11743 );
11744 assert_eq!(reply.status, 406);
11745 let json_ct = reply
11746 .headers
11747 .iter()
11748 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11749 .count();
11750 assert_eq!(json_ct, 1);
11751 let body = match &reply.body {
11752 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11753 _ => panic!("expected bytes body"),
11754 };
11755 let parsed: serde_json::Value =
11756 serde_json::from_str(&body).expect("body must be valid JSON");
11757 assert_eq!(parsed["error"], "not_acceptable");
11758 assert_eq!(
11759 parsed["message"],
11760 "accept application/xml, produced application/json"
11761 );
11762 }
11763
11764 #[test]
11765 fn json_error_reply_preserves_empty_message() {
11766 let reply = json_error_reply(400, "bad_request", "".to_string());
11767 assert_eq!(reply.status, 400);
11768 let json_ct = reply
11769 .headers
11770 .iter()
11771 .filter(|(k, v)| k == "Content-Type" && v == "application/json")
11772 .count();
11773 assert_eq!(json_ct, 1);
11774 let body = match &reply.body {
11775 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
11776 _ => panic!("expected bytes body"),
11777 };
11778 let parsed: serde_json::Value =
11779 serde_json::from_str(&body).expect("body must be valid JSON");
11780 assert_eq!(parsed["error"], "bad_request");
11781 assert_eq!(parsed["message"], "");
11782 }
11783
11784 #[test]
11785 fn https_consumer_without_tls_cert_errors() {
11786 let endpoint = HttpEndpoint {
11787 uri: "https://0.0.0.0:8443/api".to_string(),
11788 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
11789 server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
11790 client: reqwest::Client::new(),
11791 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
11792 PINNED_CLIENT_TTL,
11793 PINNED_CLIENT_MAX_ENTRIES,
11794 )),
11795 http_config: HttpConfig::default(),
11796 };
11797 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
11798 let result = endpoint.create_consumer(rt);
11799 assert!(result.is_err(), "expected error for https without tls cert");
11800 if let Err(e) = result {
11801 let msg = e.to_string();
11802 assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
11803 }
11804 }
11805
11806 #[test]
11807 fn http_consumer_with_tls_config_errors() {
11808 let endpoint = HttpEndpoint {
11809 uri: "http://0.0.0.0:8080/api".to_string(),
11810 config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
11811 server_config: HttpServerConfig::from_uri(
11812 "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
11813 )
11814 .unwrap(),
11815 client: reqwest::Client::new(),
11816 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
11817 PINNED_CLIENT_TTL,
11818 PINNED_CLIENT_MAX_ENTRIES,
11819 )),
11820 http_config: HttpConfig::default(),
11821 };
11822 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
11823 let result = endpoint.create_consumer(rt);
11824 assert!(result.is_err(), "expected error for http with tls config");
11825 if let Err(e) = result {
11826 let msg = e.to_string();
11827 assert!(msg.contains("https"), "error must mention https: {msg}");
11828 }
11829 }
11830
11831 #[test]
11832 fn https_consumer_with_partial_tls_cert_only_errors() {
11833 let server_config =
11836 HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
11837 assert!(
11838 server_config.tls_config.is_none(),
11839 "partial tlsCert must not create ServerTlsConfig"
11840 );
11841 let endpoint = HttpEndpoint {
11842 uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
11843 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
11844 .unwrap(),
11845 server_config,
11846 client: reqwest::Client::new(),
11847 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
11848 PINNED_CLIENT_TTL,
11849 PINNED_CLIENT_MAX_ENTRIES,
11850 )),
11851 http_config: HttpConfig::default(),
11852 };
11853 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
11854 let result = endpoint.create_consumer(rt);
11855 assert!(
11856 result.is_err(),
11857 "must error: https:// requires both tlsCert and tlsKey"
11858 );
11859 }
11860
11861 #[test]
11862 fn load_tls_config_parses_valid_pem() {
11863 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
11865 use camel_component_api::test_support::tls;
11866 let (_, cert_pem, key_pem) = tls::gen_server_cert();
11867 let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
11868 let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
11869
11870 let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
11871 assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
11872 }
11873
11874 #[tokio::test(flavor = "multi_thread")]
11875 #[allow(clippy::await_holding_lock)]
11876 async fn consumer_tls_handshake_roundtrip() {
11877 use camel_component_api::test_support::tls;
11878 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
11879
11880 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
11882
11883 let _guard = lock_registry_test_mutex();
11885
11886 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
11888 let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
11889 let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
11890 let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
11891
11892 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11894 let port = probe.local_addr().unwrap().port();
11895 drop(probe);
11896
11897 ServerRegistry::reset();
11898
11899 let component = HttpComponent::new();
11901 let endpoint_ctx = NoOpComponentContext;
11902 let uri = format!(
11903 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
11904 cert_path.to_string_lossy(),
11905 key_path.to_string_lossy(),
11906 );
11907 let endpoint = component
11908 .create_endpoint(&uri, &endpoint_ctx)
11909 .expect("create TLS endpoint");
11910 let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
11911
11912 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
11914 let token = tokio_util::sync::CancellationToken::new();
11915 let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
11916 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
11917
11918 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
11920
11921 let ca_bytes = std::fs::read(&ca_path).unwrap();
11923 let client = reqwest::Client::builder()
11924 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
11925 .build()
11926 .unwrap();
11927
11928 let send_fut = client
11929 .post(format!("https://localhost:{port}/test"))
11930 .body("ping")
11931 .send();
11932
11933 let (http_result, _) = tokio::join!(send_fut, async {
11935 if let Some(mut envelope) = rx.recv().await {
11936 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
11937 if let Some(reply_tx) = envelope.reply_tx {
11938 let _ = reply_tx.send(Ok(envelope.exchange));
11939 }
11940 }
11941 });
11942
11943 let resp = http_result.expect("TLS handshake + request must succeed");
11944
11945 assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
11946 let body = resp.text().await.unwrap();
11947 assert_eq!(body, "pong");
11948
11949 token.cancel();
11950 }
11951
11952 #[tokio::test(flavor = "multi_thread")]
11953 #[allow(clippy::await_holding_lock)]
11954 async fn consumer_tls_rejects_client_without_ca() {
11955 use camel_component_api::test_support::tls;
11956 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
11957
11958 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
11959
11960 let _guard = lock_registry_test_mutex();
11962
11963 let (_, cert_pem, key_pem) = tls::gen_server_cert();
11964 let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
11965 let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
11966
11967 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
11968 let port = probe.local_addr().unwrap().port();
11969 drop(probe);
11970
11971 ServerRegistry::reset();
11972
11973 let component = HttpComponent::new();
11975 let endpoint_ctx = NoOpComponentContext;
11976 let uri = format!(
11977 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
11978 cert_path.to_string_lossy(),
11979 key_path.to_string_lossy(),
11980 );
11981 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
11982 let mut consumer = endpoint.create_consumer(rt()).unwrap();
11983 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
11984 let token = tokio_util::sync::CancellationToken::new();
11985 let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
11986 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
11987
11988 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
11989
11990 let client = reqwest::Client::builder().build().unwrap();
11992
11993 let result = client
11994 .get(format!("https://localhost:{port}/test"))
11995 .send()
11996 .await;
11997
11998 assert!(
11999 result.is_err(),
12000 "must reject without CA — proves real verification"
12001 );
12002
12003 token.cancel();
12004 }
12005
12006 #[test]
12007 fn server_config_partial_tls_cert_without_key() {
12008 let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
12010 assert!(cfg.tls_config.is_none());
12012 }
12013
12014 #[test]
12015 fn endpoint_uri_options_count_parity() {
12016 assert_eq!(
12018 HttpEndpointConfig::uri_options().len(),
12019 22,
12020 "HttpEndpointUriConfig #[uri_param] count drifted from parser"
12021 );
12022 }
12023
12024 fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
12025 pairs
12026 .iter()
12027 .map(|(k, v)| {
12028 (
12029 (*k).to_string(),
12030 serde_json::Value::String((*v).to_string()),
12031 )
12032 })
12033 .collect()
12034 }
12035
12036 #[test]
12037 fn response_emits_cache_control_via_pragma_warning() {
12038 let headers = make_headers(&[
12039 ("Cache-Control", "public, max-age=3600"),
12040 ("Via", "1.1 myproxy"),
12041 ("Pragma", "no-cache"),
12042 ("Warning", "199 misc"),
12043 ]);
12044 let selected = select_response_headers(&headers, None, None);
12045 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12046 for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
12047 assert!(
12048 names.contains(&expected),
12049 "{expected} should pass through to the response"
12050 );
12051 }
12052 }
12053
12054 #[test]
12055 fn response_excludes_request_only_and_server_owned() {
12056 let headers = make_headers(&[
12057 ("User-Agent", "x"),
12058 ("Accept", "*/*"),
12059 ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
12060 ]);
12061 let selected = select_response_headers(&headers, None, None);
12062 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12063 for excluded in ["User-Agent", "Accept", "Date"] {
12064 assert!(
12065 !names.contains(&excluded),
12066 "{excluded} should NOT appear in the response"
12067 );
12068 }
12069 }
12070
12071 #[test]
12072 fn response_re_derives_content_type() {
12073 let headers = make_headers(&[("Content-Type", "text/plain")]);
12074 let selected = select_response_headers(&headers, Some("application/json".into()), None);
12075 let ct_entries: Vec<&str> = selected
12076 .iter()
12077 .filter(|(k, _)| k == "Content-Type")
12078 .map(|(_, v)| v.as_str())
12079 .collect();
12080 assert_eq!(
12081 ct_entries,
12082 ["application/json"],
12083 "exactly one Content-Type entry, re-derived from user_content_type"
12084 );
12085 }
12086
12087 #[test]
12088 fn response_excludes_camel_headers() {
12089 let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
12090 let selected = select_response_headers(&headers, None, None);
12091 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12092 assert!(
12093 !names.contains(&"CamelHttpPath"),
12094 "Camel-namespace headers must be excluded"
12095 );
12096 assert!(
12097 names.contains(&"Cache-Control"),
12098 "Cache-Control must pass through"
12099 );
12100 }
12101
12102 #[test]
12103 fn response_stringifies_scalar_header_values() {
12104 let mut headers = make_headers(&[("X-Label", "keep")]);
12105 headers.insert("X-Retries".to_string(), serde_json::json!(3));
12106 headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
12107 headers.insert("X-Enabled".to_string(), serde_json::json!(true));
12108 let selected = select_response_headers(&headers, None, None);
12109 let get = |name: &str| -> Option<&str> {
12110 selected
12111 .iter()
12112 .find(|(k, _)| k == name)
12113 .map(|(_, v)| v.as_str())
12114 };
12115 assert_eq!(
12116 get("X-Retries"),
12117 Some("3"),
12118 "integer header must be stringified"
12119 );
12120 assert_eq!(
12121 get("X-Ratio"),
12122 Some("3.5"),
12123 "float header must be stringified"
12124 );
12125 assert_eq!(
12126 get("X-Enabled"),
12127 Some("true"),
12128 "bool header must be stringified"
12129 );
12130 assert_eq!(
12131 get("X-Label"),
12132 Some("keep"),
12133 "string header must pass through"
12134 );
12135 }
12136
12137 #[test]
12138 fn response_drops_null_and_structured_header_values() {
12139 let mut headers = make_headers(&[("X-Keep", "yes")]);
12140 headers.insert("X-Null".to_string(), serde_json::Value::Null);
12141 headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
12142 headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
12143 let selected = select_response_headers(&headers, None, None);
12144 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12145 for dropped in ["X-Null", "X-Obj", "X-Arr"] {
12146 assert!(
12147 !names.contains(&dropped),
12148 "{dropped} must not be emitted: no single-value form"
12149 );
12150 }
12151 assert!(names.contains(&"X-Keep"), "scalar headers must survive");
12152 }
12153
12154 #[test]
12155 fn response_stringifies_scalars_despite_excluded_names() {
12156 let mut headers = HashMap::new();
12160 headers.insert("Content-Length".to_string(), serde_json::json!(999));
12161 headers.insert("Date".to_string(), serde_json::json!(12345));
12162 let selected = select_response_headers(&headers, None, None);
12163 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
12164 assert!(
12165 !names.contains(&"Content-Length"),
12166 "content-length is re-derived by the server"
12167 );
12168 assert!(!names.contains(&"Date"), "date is server-owned");
12169 }
12170
12171 #[test]
12172 fn outbound_stringifies_scalar_header_values() {
12173 let mut headers = make_headers(&[("X-Label", "keep")]);
12174 headers.insert("X-Retries".to_string(), serde_json::json!(3));
12175 headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
12176 headers.insert("X-Enabled".to_string(), serde_json::json!(true));
12177 let outbound = select_outbound_headers(&headers, &[], &[]);
12178 let get = |name: &str| -> Option<String> {
12180 outbound
12181 .accepted
12182 .iter()
12183 .find(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12184 .map(|(_, v)| v.to_str().unwrap().to_string())
12185 };
12186 assert_eq!(
12187 get("X-Retries").as_deref(),
12188 Some("3"),
12189 "integer header must be stringified"
12190 );
12191 assert_eq!(
12192 get("X-Ratio").as_deref(),
12193 Some("3.5"),
12194 "float header must be stringified"
12195 );
12196 assert_eq!(
12197 get("X-Enabled").as_deref(),
12198 Some("true"),
12199 "bool header must be stringified"
12200 );
12201 assert_eq!(
12202 get("X-Label").as_deref(),
12203 Some("keep"),
12204 "string header must pass through"
12205 );
12206 assert!(outbound.drops.is_empty(), "scalar headers must not drop");
12207 }
12208
12209 #[test]
12210 fn outbound_drops_null_and_structured_header_values() {
12211 let mut headers = make_headers(&[("X-Keep", "yes")]);
12212 headers.insert("X-Null".to_string(), serde_json::Value::Null);
12213 headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
12214 headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
12215 let outbound = select_outbound_headers(&headers, &[], &[]);
12216 let has = |name: &str| {
12217 outbound
12218 .accepted
12219 .iter()
12220 .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12221 };
12222 assert!(has("X-Keep"), "scalar headers must survive");
12223 for (name, kind) in [("X-Null", "null"), ("X-Obj", "object"), ("X-Arr", "array")] {
12224 let dropped = outbound
12225 .drops
12226 .iter()
12227 .find(|d| d.name == name)
12228 .unwrap_or_else(|| panic!("{name} must have a drop record: {:?}", outbound.drops));
12229 assert_eq!(
12230 dropped.reason, "no scalar string form",
12231 "{name} drop reason must name the value kind absence"
12232 );
12233 assert_eq!(dropped.value_kind, Some(kind), "{name} kind recorded");
12234 }
12235 }
12236
12237 #[test]
12238 fn outbound_stringifies_scalars_despite_excluded_names() {
12239 let mut headers = HashMap::new();
12243 headers.insert("Transfer-Encoding".to_string(), serde_json::json!(7));
12244 headers.insert("Host".to_string(), serde_json::json!(12345));
12245 headers.insert("X-Ok".to_string(), serde_json::json!(7));
12246 let outbound = select_outbound_headers(&headers, &[], &[]);
12247 let has = |name: &str| {
12248 outbound
12249 .accepted
12250 .iter()
12251 .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12252 };
12253 assert!(
12254 !has("Transfer-Encoding"),
12255 "hop-by-hop header must stay excluded"
12256 );
12257 assert!(!has("Host"), "host is destination-derived");
12258 assert!(has("X-Ok"), "non-excluded scalar must be stringified");
12259 assert!(
12260 outbound
12261 .drops
12262 .iter()
12263 .any(|d| d.name == "Transfer-Encoding" && d.reason == "outbound emission policy"),
12264 "policy drop must be recorded before coercion"
12265 );
12266 }
12267
12268 #[test]
12269 fn outbound_drops_invalid_names_values_and_skip_config() {
12270 let mut headers = make_headers(&[("X-Good", "fine")]);
12271 headers.insert("X Bad Name".to_string(), serde_json::json!("v"));
12272 headers.insert(
12273 "X-Control-Value".to_string(),
12274 serde_json::json!("line1\nline2"),
12275 );
12276 headers.insert("X-Secret".to_string(), serde_json::json!("s3cr3t"));
12277 headers.insert("CamelHttpQuery".to_string(), serde_json::json!("q=1"));
12278 let skip = vec!["x-secret".to_string()];
12279 let outbound = select_outbound_headers(&headers, &skip, &[]);
12280 let has = |name: &str| {
12281 outbound
12282 .accepted
12283 .iter()
12284 .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
12285 };
12286 assert!(has("X-Good"), "valid header must survive");
12287 assert!(!has("X Bad Name"), "invalid header name must drop");
12288 assert!(!has("X-Control-Value"), "control-char value must drop");
12289 assert!(!has("X-Secret"), "skipped header must drop");
12290 assert!(!has("CamelHttpQuery"), "Camel-namespace header must drop");
12291 let reason = |n: &str| {
12292 outbound
12293 .drops
12294 .iter()
12295 .find(|d| d.name == n)
12296 .map(|d| d.reason)
12297 };
12298 assert_eq!(reason("X Bad Name"), Some("invalid header name"));
12299 assert_eq!(reason("X-Control-Value"), Some("invalid header value"));
12300 assert_eq!(reason("X-Secret"), Some("skip_request_headers"));
12301 assert_eq!(reason("CamelHttpQuery"), Some("Camel namespace"));
12302 }
12303
12304 #[test]
12305 fn constructed_header_invalid_value_returns_drop_record() {
12306 let result = constructed_header("user-agent", "bad\r\ns3nt1nel");
12307 let Err(record) = result else {
12308 panic!("invalid value must produce a drop record");
12309 };
12310 assert_eq!(record.reason, "invalid header value");
12311 assert_eq!(record.name, "user-agent");
12312 assert!(record.value_kind.is_none());
12313 let debug = format!("{record:?}");
12314 assert!(
12315 !debug.contains("bad\r\n") && !debug.contains("s3nt1nel"),
12316 "drop record debug must not leak the value"
12317 );
12318 }
12319
12320 #[test]
12321 fn constructed_header_invalid_name_returns_drop_record() {
12322 let result = constructed_header("bad name", "ok");
12323 let Err(record) = result else {
12324 panic!("invalid name must produce a drop record");
12325 };
12326 assert_eq!(record.reason, "invalid header name");
12327 assert_eq!(record.name, "bad name");
12328 let debug = format!("{record:?}");
12329 assert!(
12330 !debug.contains("ok"),
12331 "drop record debug must not leak the value"
12332 );
12333 }
12334
12335 #[test]
12336 fn constructed_header_valid_pair_roundtrip() {
12337 let result = constructed_header("authorization", "Bearer abc123");
12338 let Ok((name, val)) = result else {
12339 panic!("valid pair must construct");
12340 };
12341 assert_eq!(name.as_str(), "authorization");
12342 let Ok(roundtrip) = val.to_str() else {
12343 panic!("valid value must roundtrip to str");
12344 };
12345 assert_eq!(roundtrip, "Bearer abc123");
12346 }
12347
12348 async fn start_host_capturing_destination() -> (
12360 String,
12361 Arc<std::sync::Mutex<Option<(String, String)>>>,
12362 tokio::task::JoinHandle<()>,
12363 ) {
12364 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12365 let port = listener.local_addr().unwrap().port();
12366 let url = format!("http://127.0.0.1:{port}");
12367 let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
12368 Arc::new(std::sync::Mutex::new(None));
12369 let captured_clone = Arc::clone(&captured);
12370 let handle = tokio::spawn(async move {
12371 use tokio::io::{AsyncReadExt, AsyncWriteExt};
12372 if let Ok((mut stream, _)) = listener.accept().await {
12373 let mut buf = vec![0u8; 16384];
12374 let n = stream.read(&mut buf).await.unwrap_or(0);
12375 let request = String::from_utf8_lossy(&buf[..n]).to_string();
12376 if request.contains("\r\n\r\n") {
12377 let request_line = request.lines().next().unwrap_or("").to_string();
12378 let host_value = request
12379 .lines()
12380 .find(|l| l.to_lowercase().starts_with("host:"))
12381 .and_then(|l| l.split_once(':'))
12382 .map(|(_, v)| v.trim().to_string())
12383 .unwrap_or_default();
12384 *captured_clone.lock().unwrap() = Some((host_value, request_line));
12385 }
12386 let body = r#"{"echo":"ok"}"#;
12387 let resp = format!(
12388 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
12389 body.len(),
12390 body
12391 );
12392 let _ = stream.write_all(resp.as_bytes()).await;
12393 }
12394 });
12395 (url, captured, handle)
12396 }
12397
12398 #[tokio::test]
12403 async fn bridge_proxy_outbound_host_matches_destination() {
12404 use tower::ServiceExt;
12405
12406 let (url, captured, _handle) = start_host_capturing_destination().await;
12407 let expected_host = url.strip_prefix("http://").unwrap();
12410
12411 let ctx = test_producer_ctx();
12412 let component = HttpComponent::new();
12413 let endpoint_ctx = NoOpComponentContext;
12414 let endpoint = component
12415 .create_endpoint(
12416 &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
12417 &endpoint_ctx,
12418 )
12419 .unwrap();
12420 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
12421
12422 let mut exchange = Exchange::new(Message::default());
12425 exchange.input.set_header("Host", "localhost");
12426 exchange.input.set_header("CamelHttpPath", "/foo");
12427
12428 let result = producer.oneshot(exchange).await;
12429 assert!(result.is_ok(), "producer call failed: {:?}", result);
12430
12431 tokio::time::sleep(Duration::from_millis(100)).await;
12432 let (host_value, request_line) = captured
12433 .lock()
12434 .unwrap()
12435 .take()
12436 .expect("destination capture mutex empty — producer did not reach the destination");
12437
12438 assert_ne!(
12439 host_value, "localhost",
12440 "bridge producer must not forward the exchange Host: localhost"
12441 );
12442 assert_eq!(
12443 host_value, expected_host,
12444 "Host must be derived from the destination authority (no scheme)"
12445 );
12446 assert!(
12447 !request_line.contains("/foo"),
12448 "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
12449 );
12450 }
12451
12452 #[tokio::test]
12457 async fn bridge_proxy_route_set_response_header_survives() {
12458 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
12459
12460 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
12461 let port = listener.local_addr().unwrap().port();
12462 drop(listener);
12463
12464 let component = HttpComponent::new();
12465 let endpoint_ctx = NoOpComponentContext;
12466 let endpoint = component
12467 .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
12468 .unwrap();
12469 let mut consumer = endpoint.create_consumer(rt()).unwrap();
12470
12471 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
12472 let token = tokio_util::sync::CancellationToken::new();
12473 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
12474
12475 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
12476 tokio::time::sleep(Duration::from_millis(50)).await;
12477
12478 let client = reqwest::Client::new();
12479 let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
12480
12481 let (http_result, _) = tokio::join!(send_fut, async {
12485 if let Some(mut envelope) = rx.recv().await {
12486 envelope
12487 .exchange
12488 .input
12489 .set_header("Cache-Control", "public, max-age=3600");
12490 if let Some(reply_tx) = envelope.reply_tx {
12491 let _ = reply_tx.send(Ok(envelope.exchange));
12492 }
12493 }
12494 });
12495
12496 let resp = http_result.unwrap();
12497 assert_eq!(resp.status().as_u16(), 200);
12498
12499 let cache_control = resp.headers().get("cache-control");
12500 assert!(
12501 cache_control.is_some(),
12502 "Cache-Control header must survive to the wire response"
12503 );
12504 assert_eq!(
12505 cache_control.unwrap().to_str().unwrap(),
12506 "public, max-age=3600"
12507 );
12508
12509 token.cancel();
12510 }
12511
12512 use camel_api::security_policy::CredentialSource;
12531 use camel_auth::credential_source::extract_token_from_exchange;
12532 use camel_auth::native_auth::NativeCredentialStore;
12533 use camel_auth::{StaticTokenAuthenticator, TokenAuthenticator};
12534
12535 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 {
12543 let mut msg = Message::default();
12544 msg.set_header(
12545 "CamelHttpMethod",
12546 serde_json::Value::String(envelope.method.clone()),
12547 );
12548 msg.set_header(
12549 "CamelHttpPath",
12550 serde_json::Value::String(envelope.path.clone()),
12551 );
12552 msg.set_header(
12553 "CamelHttpQuery",
12554 serde_json::Value::String(envelope.query.clone()),
12555 );
12556 for (k, v) in &envelope.headers {
12557 if let Ok(val_str) = v.to_str() {
12558 msg.set_header(
12559 title_case_header(k.as_str()),
12560 serde_json::Value::String(val_str.to_string()),
12561 );
12562 }
12563 }
12564 Exchange::new(msg)
12565 }
12566
12567 async fn spawn_failing_auth_route(
12574 registry: &HttpRouteRegistry,
12575 path: &str,
12576 sources: Vec<CredentialSource>,
12577 ) {
12578 let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
12579 NativeCredentialStore::try_new(vec![]).unwrap(),
12580 ));
12581 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
12582 registry.register_api_route(path.to_string(), tx).await;
12583 let path_owned = path.to_string();
12584 tokio::spawn(async move {
12585 while let Some(envelope) = rx.recv().await {
12586 let exchange = envelope_to_exchange(&envelope);
12587 let reply_tx = envelope.reply_tx;
12588 let result: Result<(), CamelError> = async {
12589 let token = extract_token_from_exchange(&exchange, &sources)
12590 .map(|extracted| extracted.token)
12591 .ok_or_else(|| {
12592 CamelError::Unauthenticated("no credential in any source".into())
12593 })?;
12594 authenticator.authenticate_bearer(&token).await?;
12595 Ok(())
12596 }
12597 .await;
12598 let reply = match result {
12599 Ok(()) => HttpReply {
12600 status: 200,
12601 headers: vec![],
12602 body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
12603 },
12604 Err(e) => pipeline_error_to_reply(e, &path_owned),
12605 };
12606 let _ = reply_tx.send(reply);
12607 }
12608 });
12609 }
12610
12611 fn captured_logs_contain(needle: &str) -> bool {
12615 let buf = tracing_test::internal::global_buf().lock().unwrap();
12616 String::from_utf8_lossy(&buf).contains(needle)
12617 }
12618
12619 #[tracing_test::traced_test]
12620 #[tokio::test]
12621 async fn error_context_redacts_query_sentinel() {
12622 let (port, registry) = spawn_test_server().await;
12623 spawn_failing_auth_route(
12624 ®istry,
12625 "/secure-query",
12626 vec![CredentialSource::QueryParam {
12627 param: "token".to_string(),
12628 }],
12629 )
12630 .await;
12631
12632 let client = reqwest::Client::new();
12633 let resp = client
12634 .get(format!(
12636 "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
12637 ))
12638 .send()
12639 .await
12640 .unwrap();
12641
12642 assert_eq!(resp.status().as_u16(), 401);
12643 let body = resp.text().await.unwrap();
12644 assert_eq!(body, "Unauthorized");
12645 assert!(
12646 !body.contains(SENTINEL_QRY_42),
12647 "reply body must not contain the query credential"
12648 );
12649 assert!(
12650 !captured_logs_contain(SENTINEL_QRY_42),
12651 "no tracing record during request handling may render the query credential"
12652 );
12653 assert!(
12657 captured_logs_contain("Authentication failed"),
12658 "positive control: the failed-auth warn! must be captured by the test subscriber"
12659 );
12660 }
12661
12662 #[tracing_test::traced_test]
12663 #[tokio::test]
12664 async fn error_context_redacts_cookie_sentinel() {
12665 let (port, registry) = spawn_test_server().await;
12666 spawn_failing_auth_route(
12667 ®istry,
12668 "/secure-cookie",
12669 vec![CredentialSource::Cookie {
12670 name: "session".to_string(),
12671 }],
12672 )
12673 .await;
12674
12675 let client = reqwest::Client::new();
12676 let resp = client
12677 .get(format!("http://127.0.0.1:{port}/secure-cookie"))
12678 .header("Cookie", format!("session={SENTINEL_CKY_7}"))
12679 .send()
12680 .await
12681 .unwrap();
12682
12683 assert_eq!(resp.status().as_u16(), 401);
12684 let body = resp.text().await.unwrap();
12685 assert_eq!(body, "Unauthorized");
12686 assert!(
12687 !body.contains(SENTINEL_CKY_7),
12688 "reply body must not contain the cookie credential"
12689 );
12690 assert!(
12691 !captured_logs_contain(SENTINEL_CKY_7),
12692 "no tracing record during request handling may render the cookie credential"
12693 );
12694 }
12695
12696 #[tracing_test::traced_test]
12697 #[tokio::test]
12698 async fn error_reply_no_credential_value() {
12699 let (port, registry) = spawn_test_server().await;
12700 spawn_failing_auth_route(
12701 ®istry,
12702 "/secure-bad",
12703 vec![CredentialSource::Cookie {
12704 name: "session".to_string(),
12705 }],
12706 )
12707 .await;
12708
12709 let client = reqwest::Client::new();
12710 let resp = client
12711 .get(format!("http://127.0.0.1:{port}/secure-bad"))
12712 .header("Cookie", format!("session={SENTINEL_BAD_1}"))
12713 .send()
12714 .await
12715 .unwrap();
12716
12717 assert_eq!(resp.status().as_u16(), 401);
12718 let body = resp.text().await.unwrap();
12719 assert_eq!(body, "Unauthorized");
12720 assert!(
12721 !body.contains(SENTINEL_BAD_1),
12722 "reply body must not contain the credential value"
12723 );
12724 assert!(
12725 !captured_logs_contain(SENTINEL_BAD_1),
12726 "error logs must not render the credential value"
12727 );
12728 }
12729
12730 async fn spawn_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
12744 use tokio::io::AsyncWriteExt;
12745
12746 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
12747 .await
12748 .expect("bind ephemeral 127.0.0.1 listener");
12749 let port = listener.local_addr().expect("local addr").port();
12750 let base_url = format!("http://localhost:{port}");
12751 let handle = tokio::spawn(async move {
12752 while let Ok((mut conn, _)) = listener.accept().await {
12753 let _ = conn
12754 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
12755 .await;
12756 let _ = conn.shutdown().await;
12757 }
12758 });
12759 (base_url, handle)
12760 }
12761
12762 async fn spawn_tls_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
12769 use tokio::io::AsyncWriteExt;
12770
12771 let (_ca_pem, cert_pem, key_pem) =
12772 camel_component_api::test_support::tls::gen_server_cert();
12773 let certs: Vec<_> = rustls_pemfile::certs(&mut cert_pem.as_bytes())
12774 .collect::<Result<_, _>>()
12775 .expect("parse server cert pem");
12776 let key = rustls_pemfile::private_key(&mut key_pem.as_bytes())
12777 .expect("parse server key pem")
12778 .expect("server key present");
12779 let provider =
12782 std::sync::Arc::new(tokio_rustls::rustls::crypto::aws_lc_rs::default_provider());
12783 let tls_cfg = tokio_rustls::rustls::ServerConfig::builder_with_provider(provider)
12784 .with_safe_default_protocol_versions()
12785 .expect("safe default protocol versions")
12786 .with_no_client_auth()
12787 .with_single_cert(certs, key)
12788 .expect("build rustls server config");
12789 let acceptor = tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(tls_cfg));
12790
12791 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
12792 .await
12793 .expect("bind ephemeral 127.0.0.1 listener");
12794 let port = listener.local_addr().expect("local addr").port();
12795 let base_url = format!("https://localhost:{port}");
12796 let handle = tokio::spawn(async move {
12797 while let Ok((conn, _)) = listener.accept().await {
12798 let acceptor = acceptor.clone();
12799 tokio::spawn(async move {
12800 if let Ok(mut tls) = acceptor.accept(conn).await {
12801 let _ = tls
12802 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
12803 .await;
12804 let _ = tls.shutdown().await;
12805 }
12806 });
12807 }
12808 });
12809 (base_url, handle)
12810 }
12811
12812 fn responder_port(base_url: &str) -> u16 {
12816 url::Url::parse(base_url)
12817 .expect("responder base URL parses")
12818 .port()
12819 .expect("responder base URL carries an explicit port")
12820 }
12821
12822 fn endpoint_with_shared_cache(
12827 base_url: &str,
12828 pinned_cache: &Arc<PinnedClientCache>,
12829 ) -> HttpEndpoint {
12830 let uri = format!("{base_url}?allowInternal=true");
12831 HttpEndpoint {
12832 uri: uri.clone(),
12833 config: HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses"),
12834 server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
12835 client: reqwest::Client::new(),
12836 pinned_cache: Arc::clone(pinned_cache),
12837 http_config: HttpConfig::default(),
12838 }
12839 }
12840
12841 #[tokio::test]
12842 async fn producers_share_endpoint_cache() {
12843 use tower::ServiceExt;
12844
12845 let (base_url, _handle) = spawn_multi_accept_200().await;
12846 let pinned_cache = Arc::new(PinnedClientCache::new(
12847 PINNED_CLIENT_TTL,
12848 PINNED_CLIENT_MAX_ENTRIES,
12849 ));
12850
12851 let ctx = test_producer_ctx();
12852 let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
12853 let producer_a = endpoint.create_producer(rt(), &ctx);
12854 let producer_b = endpoint.create_producer(rt(), &ctx);
12855
12856 for producer in [producer_a, producer_b] {
12859 let producer = producer.expect("create producer");
12860 let exchange = Exchange::new(Message::default());
12861 let reply = producer.oneshot(exchange).await;
12862 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
12863 }
12864
12865 assert_eq!(
12866 pinned_cache.build_count(),
12867 1,
12868 "both producers must hit the same shared cache entry; a second \
12869 build means sharing is broken"
12870 );
12871 }
12872
12873 #[tokio::test]
12874 async fn producer_repeated_hostname_requests_build_one_client() {
12875 use tower::ServiceExt;
12876
12877 let (base_url, _handle) = spawn_multi_accept_200().await;
12878 let pinned_cache = Arc::new(PinnedClientCache::new(
12879 PINNED_CLIENT_TTL,
12880 PINNED_CLIENT_MAX_ENTRIES,
12881 ));
12882 let ctx = test_producer_ctx();
12883 let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
12884 let producer = endpoint
12885 .create_producer(rt(), &ctx)
12886 .expect("create producer");
12887
12888 for i in 0..2 {
12891 let exchange = Exchange::new(Message::default());
12892 let reply = producer.clone().oneshot(exchange).await;
12893 assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
12894 }
12895
12896 assert_eq!(
12897 pinned_cache.build_count(),
12898 1,
12899 "repeated hostname requests must reuse the one pinned client; \
12900 0 builds means the producer bypassed the cache, more than 1 \
12901 means the entry was dropped"
12902 );
12903 }
12904
12905 #[tokio::test]
12906 async fn ip_literal_request_never_enters_cache() {
12907 use tower::ServiceExt;
12908
12909 let (base_url, _handle) = spawn_multi_accept_200().await;
12910 let pinned_cache = Arc::new(PinnedClientCache::new(
12911 PINNED_CLIENT_TTL,
12912 PINNED_CLIENT_MAX_ENTRIES,
12913 ));
12914
12915 let ctx = test_producer_ctx();
12916 let destination = format!("http://127.0.0.1:{}/ping", responder_port(&base_url));
12917 let endpoint = endpoint_with_shared_cache(&destination, &pinned_cache);
12918 let producer = endpoint
12919 .create_producer(rt(), &ctx)
12920 .expect("create producer");
12921
12922 let exchange = Exchange::new(Message::default());
12923 let reply = producer.oneshot(exchange).await;
12924 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
12925
12926 assert_eq!(
12927 pinned_cache.build_count(),
12928 0,
12929 "an IP-literal URL must use the shared unpinned client and \
12930 never enter the pinned cache"
12931 );
12932 }
12933
12934 #[tokio::test]
12935 async fn test_component_endpoints_share_pinned_cache() {
12936 use tower::ServiceExt;
12937
12938 let component = HttpComponent::new();
12939 let (base_url, _handle) = spawn_multi_accept_200().await;
12940 let baseline = component.pinned_cache.build_count();
12941
12942 let ctx = test_producer_ctx();
12943 let endpoint_ctx = NoOpComponentContext;
12944 for uri in [
12945 format!("{base_url}/a?allowInternal=true&k=a"),
12946 format!("{base_url}/b?allowInternal=true&k=b"),
12947 ] {
12948 let endpoint = component
12949 .create_endpoint(&uri, &endpoint_ctx)
12950 .expect("create endpoint");
12951 let producer = endpoint
12952 .create_producer(rt(), &ctx)
12953 .expect("create producer");
12954 let exchange = Exchange::new(Message::default());
12955 let reply = producer.oneshot(exchange).await;
12956 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
12957 }
12958
12959 assert_eq!(
12960 component.pinned_cache.build_count() - baseline,
12961 1,
12962 "endpoints created by one component must share its pinned cache; \
12963 0 builds means the endpoints bypassed it, more than 1 means \
12964 per-endpoint caches came back"
12965 );
12966 }
12967
12968 #[tokio::test]
12969 async fn test_dynamic_resolution_sequence_hits_shared_cache() {
12970 use tower::ServiceExt;
12971
12972 let component = HttpComponent::new();
12973 let (base_url, _handle) = spawn_multi_accept_200().await;
12974 let baseline = component.pinned_cache.build_count();
12975
12976 let ctx = test_producer_ctx();
12977 let endpoint_ctx = NoOpComponentContext;
12978 for i in 0..3 {
12979 let endpoint = component
12980 .create_endpoint(
12981 &format!("{base_url}/r{i}?allowInternal=true&k={i}"),
12982 &endpoint_ctx,
12983 )
12984 .expect("create endpoint");
12985 let producer = endpoint
12986 .create_producer(rt(), &ctx)
12987 .expect("create producer");
12988 let exchange = Exchange::new(Message::default());
12989 let reply = producer.oneshot(exchange).await;
12990 assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
12991 }
12992
12993 assert_eq!(
12994 component.pinned_cache.build_count() - baseline,
12995 1,
12996 "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
12997 must reuse the component's one pinned cache entry; 0 builds \
12998 means the endpoints bypassed it, more than 1 means \
12999 per-endpoint caches came back"
13000 );
13001 }
13002
13003 #[tokio::test]
13011 async fn test_https_component_endpoints_share_pinned_cache_behaviorally() {
13012 use tower::ServiceExt;
13013
13014 let http_config = HttpConfig {
13015 tls: Some(crate::config::TlsConfig {
13016 enabled: true,
13017 insecure: true,
13018 ..Default::default()
13019 }),
13020 ..Default::default()
13021 };
13022 let component = HttpsComponent::with_config(http_config);
13023 let (base_url, _handle) = spawn_tls_multi_accept_200().await;
13024 let baseline = component.pinned_cache.build_count();
13025
13026 let ctx = test_producer_ctx();
13027 let endpoint_ctx = NoOpComponentContext;
13028 for uri in [
13029 format!("{base_url}/a?allowInternal=true&k=a"),
13030 format!("{base_url}/b?allowInternal=true&k=b"),
13031 ] {
13032 let endpoint = component
13033 .create_endpoint(&uri, &endpoint_ctx)
13034 .expect("create https endpoint");
13035 let producer = endpoint
13036 .create_producer(rt(), &ctx)
13037 .expect("create producer");
13038 let exchange = Exchange::new(Message::default());
13039 let reply = producer.oneshot(exchange).await;
13040 assert!(reply.is_ok(), "https request failed: {reply:?}");
13041 }
13042
13043 assert_eq!(
13044 component.pinned_cache.build_count() - baseline,
13045 1,
13046 "endpoints of one HttpsComponent must share its pinned cache over \
13047 real https requests; 0 builds means the endpoints bypassed it \
13048 (per-endpoint cache regression), more than 1 means \
13049 per-endpoint caches came back"
13050 );
13051 }
13052
13053 #[test]
13054 fn test_https_component_owns_distinct_cache() {
13055 let http = HttpComponent::new();
13056 let https = HttpsComponent::new();
13057
13058 assert!(
13059 !Arc::ptr_eq(&http.pinned_cache, &https.pinned_cache),
13060 "http and https components must each own their own pinned cache"
13061 );
13062
13063 let endpoint_ctx = NoOpComponentContext;
13064 let _ = http
13065 .create_endpoint("http://localhost:1/?allowInternal=true", &endpoint_ctx)
13066 .expect("http endpoint");
13067 let _ = https
13068 .create_endpoint("https://localhost:1/?allowInternal=true", &endpoint_ctx)
13069 .expect("https endpoint");
13070
13071 assert_eq!(
13072 http.pinned_cache.build_count(),
13073 0,
13074 "endpoint creation must not build a pinned client"
13075 );
13076 assert_eq!(
13077 https.pinned_cache.build_count(),
13078 0,
13079 "endpoint creation must not build a pinned client"
13080 );
13081 }
13082
13083 #[test]
13084 fn test_component_constructor_builds_one_unpinned_client() {
13085 let baseline = build_client_call_count();
13086
13087 let _http = HttpComponent::new();
13088 assert_eq!(
13089 build_client_call_count() - baseline,
13090 1,
13091 "HttpComponent::new() must build exactly one shared unpinned client"
13092 );
13093
13094 let _https = HttpsComponent::new();
13095 assert_eq!(
13096 build_client_call_count() - baseline,
13097 2,
13098 "HttpsComponent::new() must build exactly one more shared unpinned client"
13099 );
13100 }
13101
13102 #[test]
13103 fn test_component_endpoints_share_unpinned_client() {
13104 let component = HttpComponent::new();
13105 let baseline = build_client_call_count();
13106
13107 let endpoint_ctx = NoOpComponentContext;
13108 for uri in [
13109 "http://localhost:1/a?allowInternal=true",
13110 "http://localhost:1/b?allowInternal=true",
13111 ] {
13112 let _endpoint = component
13113 .create_endpoint(uri, &endpoint_ctx)
13114 .expect("create endpoint");
13115 }
13116
13117 assert_eq!(
13118 build_client_call_count() - baseline,
13119 0,
13120 "create_endpoint must clone the component's shared unpinned client, \
13121 never build a fresh one"
13122 );
13123 }
13124
13125 #[test]
13126 fn test_dynamic_resolution_adds_no_unpinned_client_builds() {
13127 let component = HttpComponent::new();
13128 let baseline = build_client_call_count();
13129
13130 let ctx = test_producer_ctx();
13131 let endpoint_ctx = NoOpComponentContext;
13132 for i in 0..3 {
13133 let endpoint = component
13134 .create_endpoint(
13135 &format!("http://localhost:1/r{i}?allowInternal=true&k={i}"),
13136 &endpoint_ctx,
13137 )
13138 .expect("create endpoint");
13139 let _producer = endpoint
13140 .create_producer(rt(), &ctx)
13141 .expect("create producer");
13142 }
13143
13144 assert_eq!(
13145 build_client_call_count() - baseline,
13146 0,
13147 "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
13148 must reuse the component's shared unpinned client and build \
13149 no additional clients"
13150 );
13151 }
13152}