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