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() {
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 && let Ok(val) = reqwest::header::HeaderValue::from_str(user_agent)
3025 {
3026 collected_headers.push((reqwest::header::USER_AGENT, val));
3027 }
3028
3029 #[cfg(feature = "otel")]
3031 let should_inject_otel = !config.bridge_endpoint;
3032 #[cfg(feature = "otel")]
3033 if should_inject_otel {
3034 let mut otel_headers = HashMap::new();
3035 camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
3036 for (k, v) in otel_headers {
3037 if let (Ok(name), Ok(val)) = (
3038 reqwest::header::HeaderName::from_bytes(k.as_bytes()),
3039 reqwest::header::HeaderValue::from_str(&v),
3040 ) {
3041 collected_headers.push((name, val));
3042 }
3043 }
3044 }
3045
3046 let conn_tokens = header_policy::connection_tokens(
3047 exchange
3048 .input
3049 .headers
3050 .iter()
3051 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3052 .filter_map(|(_, v)| v.as_str()),
3053 );
3054
3055 let outbound = select_outbound_headers(
3056 &exchange.input.headers,
3057 &config.skip_request_headers,
3058 &conn_tokens,
3059 );
3060 for drop in &outbound.drops {
3061 if let Some(value_kind) = drop.value_kind {
3062 debug!(
3063 correlation_id = %exchange.correlation_id(),
3064 header = %drop.name,
3065 value_kind = value_kind,
3066 "outbound header dropped: {}",
3067 drop.reason
3068 );
3069 } else {
3070 debug!(
3071 correlation_id = %exchange.correlation_id(),
3072 header = %drop.name,
3073 "outbound header dropped: {}",
3074 drop.reason
3075 );
3076 }
3077 }
3078 collected_headers.extend(outbound.accepted);
3079
3080 if !config.bridge_endpoint {
3082 match &config.auth {
3083 HttpAuth::None => {}
3084 HttpAuth::Basic { username, password } => {
3085 use base64::Engine;
3086 let credentials = format!("{username}:{password}");
3088 let encoded =
3089 base64::engine::general_purpose::STANDARD.encode(credentials);
3090 if let Ok(val) =
3091 reqwest::header::HeaderValue::from_str(&format!("Basic {encoded}"))
3092 {
3093 collected_headers.push((reqwest::header::AUTHORIZATION, val));
3094 }
3095 }
3096 HttpAuth::Bearer { token } => {
3097 let bearer = format!("Bearer {token}");
3099 if let Ok(val) = reqwest::header::HeaderValue::from_str(&bearer) {
3100 collected_headers.push((reqwest::header::AUTHORIZATION, val));
3101 }
3102 }
3103 }
3104
3105 if config.connection_close
3106 && let Ok(val) = reqwest::header::HeaderValue::from_str("close")
3107 {
3108 collected_headers.push((reqwest::header::CONNECTION, val));
3109 }
3110 }
3111
3112 let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
3114 let materialized_body: Option<Vec<u8>> = if is_stream_body {
3115 if suppress_body {
3116 std::mem::take(&mut exchange.input.body);
3124 tracing::warn!(
3126 correlation_id = %exchange.correlation_id(),
3127 method = %method_str,
3128 "dropping request body for non-entity-enclosing HTTP method"
3129 );
3130 }
3131 None } else {
3133 let body = std::mem::take(&mut exchange.input.body);
3134 let bytes = body.into_bytes(config.max_body_size).await?;
3135 if bytes.is_empty() {
3136 None
3138 } else if suppress_body {
3139 tracing::warn!(
3141 correlation_id = %exchange.correlation_id(),
3142 method = %method_str,
3143 "dropping request body for non-entity-enclosing HTTP method"
3144 );
3145 None
3146 } else {
3147 Some(bytes.to_vec())
3148 }
3149 };
3150
3151 let response = if config.follow_redirects && !is_stream_body {
3152 ssrf::send_with_ssrf_safe_redirects(
3158 &client,
3159 &shared_client,
3160 &pinned_cache,
3161 &http_config,
3162 &config,
3163 method,
3164 &url,
3165 collected_headers,
3166 materialized_body,
3167 config.max_redirects,
3168 config.response_timeout,
3169 )
3170 .await?
3171 } else {
3172 let mut request = client.request(method, &url);
3174
3175 if let Some(timeout) = config.response_timeout {
3176 request = request.timeout(timeout);
3177 }
3178
3179 for (name, value) in &collected_headers {
3180 request = request.header(name, value);
3181 }
3182
3183 if is_stream_body {
3184 if let Body::Stream(ref s) = exchange.input.body {
3185 let mut stream_lock = s.stream.lock().await;
3186 if let Some(stream) = stream_lock.take() {
3187 request = request.body(reqwest::Body::wrap_stream(stream));
3188 } else {
3189 return Err(CamelError::AlreadyConsumed);
3190 }
3191 }
3192 } else if let Some(ref body_bytes) = materialized_body {
3193 request = request.body(body_bytes.clone());
3194 }
3195
3196 request.send().await.map_err(|e| {
3197 CamelError::ProcessorError(format!("HTTP request failed: {e}"))
3198 })?
3199 };
3200
3201 let status_code = response.status().as_u16();
3202 let status_text = response
3203 .status()
3204 .canonical_reason()
3205 .unwrap_or("Unknown")
3206 .to_string();
3207
3208 for (key, value) in response.headers() {
3209 if config
3210 .skip_response_headers
3211 .iter()
3212 .any(|h| h.eq_ignore_ascii_case(key.as_str()))
3213 {
3214 continue;
3215 }
3216 if let Ok(val_str) = value.to_str() {
3217 exchange.input.set_header(
3218 title_case_header(key.as_str()),
3219 serde_json::Value::String(val_str.to_string()),
3220 );
3221 }
3222 }
3223
3224 exchange.input.set_header(
3225 "CamelHttpResponseCode",
3226 serde_json::Value::Number(status_code.into()),
3227 );
3228 exchange.input.set_header(
3229 "CamelHttpResponseText",
3230 serde_json::Value::String(status_text.clone()),
3231 );
3232
3233 let read_timeout = Duration::from_millis(config.read_timeout_ms);
3235 let response_body = tokio::time::timeout(read_timeout, async {
3236 if let Some(content_len) = response.content_length()
3238 && content_len > config.max_response_bytes as u64
3239 {
3240 return Err(CamelError::ProcessorError(format!(
3241 "Response body too large: {} bytes exceeds limit of {} bytes",
3242 content_len, config.max_response_bytes
3243 )));
3244 }
3245 use futures::TryStreamExt;
3247 let mut stream = response.bytes_stream();
3248 let mut total: usize = 0;
3249 let mut collected = Vec::new();
3250 while let Some(chunk) = stream.try_next().await.map_err(|e| {
3251 CamelError::ProcessorError(format!("Failed to read response body: {e}"))
3252 })? {
3253 total += chunk.len();
3254 if total > config.max_response_bytes {
3255 return Err(CamelError::ProcessorError(format!(
3256 "Response body too large: {} bytes exceeds limit of {} bytes",
3257 total, config.max_response_bytes
3258 )));
3259 }
3260 collected.push(chunk);
3261 }
3262 let mut result = bytes::BytesMut::with_capacity(total);
3263 for chunk in collected {
3264 result.extend_from_slice(&chunk);
3265 }
3266 Ok::<bytes::Bytes, CamelError>(result.freeze())
3267 })
3268 .await
3269 .map_err(|_| {
3270 CamelError::ProcessorError(format!(
3271 "Read timeout after {}ms",
3272 config.read_timeout_ms
3273 ))
3274 })??;
3275
3276 if config.throw_exception_on_failure
3277 && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
3278 {
3279 return Err(CamelError::HttpOperationFailed {
3280 method: method_str,
3281 url: redact_url_for_diagnostics(&url),
3284 status_code,
3285 status_text,
3286 response_body: Some(truncate_error_body(&response_body)),
3287 });
3288 }
3289
3290 if !response_body.is_empty() {
3291 exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
3292 }
3293
3294 debug!(
3295 correlation_id = %exchange.correlation_id(),
3296 status = status_code,
3297 url = %redact_url_for_diagnostics(&url),
3298 "HTTP response"
3299 );
3300 Ok(exchange)
3301 }
3302 .await;
3303 component_metrics.observe("http", "request", outcome.is_err());
3310 outcome
3311 })
3312 }
3313}
3314
3315#[cfg(test)]
3325pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
3326
3327fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
3337 match e {
3338 CamelError::Unauthenticated(msg) => {
3339 tracing::warn!(error = %msg, path = %path, "Authentication failed");
3340 HttpReply {
3341 status: 401,
3342 headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
3343 body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
3344 }
3345 }
3346 CamelError::Unauthorized(msg) => {
3347 tracing::warn!(error = %msg, path = %path, "Authorization failed");
3348 HttpReply {
3349 status: 403,
3350 headers: vec![],
3351 body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
3352 }
3353 }
3354 CamelError::TypeConversionFailed(msg) => {
3355 tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
3356 let body = serde_json::to_string(&serde_json::json!({
3357 "error": "bad_request",
3358 "message": msg,
3359 }))
3360 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
3362 status: 400,
3363 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
3364 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
3365 }
3366 }
3367 CamelError::ValidationError(msg) => {
3368 tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
3369 let body = serde_json::to_string(&serde_json::json!({
3370 "error": "validation_error",
3371 "message": msg,
3372 }))
3373 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
3375 status: 400,
3376 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
3377 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
3378 }
3379 }
3380 CamelError::ConsumerStopping => {
3381 tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
3382 HttpReply {
3383 status: 503,
3384 headers: vec![],
3385 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
3386 }
3387 }
3388 CamelError::UnsupportedMediaType { consumed, declared } => {
3389 tracing::warn!(error = %consumed, declared = %declared, path = %path, "Unsupported media type (bad request)");
3390 let body = serde_json::to_string(&serde_json::json!({
3391 "error": "unsupported_media_type",
3392 "message": format!("consumed {consumed}, declared {declared}"),
3393 }))
3394 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
3396 status: 415,
3397 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
3398 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
3399 }
3400 }
3401 CamelError::NotAcceptable { accept, produced } => {
3402 tracing::warn!(error = %accept, produced = %produced, path = %path, "Not acceptable (bad request)");
3403 let body = serde_json::to_string(&serde_json::json!({
3404 "error": "not_acceptable",
3405 "message": format!("accept {accept}, produced {produced}"),
3406 }))
3407 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
3409 status: 406,
3410 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
3411 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
3412 }
3413 }
3414 e => {
3415 tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
3417 HttpReply {
3418 status: 500,
3419 headers: vec![],
3420 body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
3421 }
3422 }
3423 }
3424}
3425
3426const fn json_value_kind(v: &serde_json::Value) -> &'static str {
3430 match v {
3431 serde_json::Value::Null => "null",
3432 serde_json::Value::Bool(_) => "bool",
3433 serde_json::Value::Number(_) => "number",
3434 serde_json::Value::String(_) => "string",
3435 serde_json::Value::Array(_) => "array",
3436 serde_json::Value::Object(_) => "object",
3437 }
3438}
3439
3440fn scalar_string_form(v: &serde_json::Value) -> Option<String> {
3445 match v {
3446 serde_json::Value::String(s) => Some(s.clone()),
3447 serde_json::Value::Number(n) => Some(n.to_string()),
3448 serde_json::Value::Bool(b) => Some(b.to_string()),
3449 _ => None,
3450 }
3451}
3452
3453fn select_response_headers(
3468 headers: &HashMap<String, serde_json::Value>,
3469 user_content_type: Option<String>,
3470 inferred_content_type: Option<String>,
3471) -> Vec<(String, String)> {
3472 let conn_tokens = header_policy::connection_tokens(
3473 headers
3474 .iter()
3475 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3476 .filter_map(|(_, v)| v.as_str()),
3477 );
3478 let mut selected: Vec<(String, String)> = Vec::new();
3479 for (k, v) in headers {
3480 if k.starts_with("Camel") {
3481 debug!(header = %k, "reply header dropped: Camel namespace");
3482 continue;
3483 }
3484 if header_policy::excluded_response(k, &conn_tokens) {
3485 debug!(header = %k, "reply header dropped: emission policy");
3486 continue;
3487 }
3488 match scalar_string_form(v) {
3489 Some(s) => selected.push((k.clone(), s)),
3490 None => debug!(
3491 header = %k,
3492 value_kind = json_value_kind(v),
3493 "reply header dropped: no scalar string form"
3494 ),
3495 }
3496 }
3497 if let Some(ct) = user_content_type.or(inferred_content_type) {
3498 selected.push(("Content-Type".to_string(), ct));
3499 }
3500 selected
3501}
3502
3503#[derive(Debug)]
3508struct OutboundHeaderDrop<'a> {
3509 name: &'a str,
3510 reason: &'static str,
3511 value_kind: Option<&'static str>,
3512}
3513
3514struct OutboundHeaderSelection<'a> {
3517 accepted: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>,
3518 drops: Vec<OutboundHeaderDrop<'a>>,
3519}
3520
3521fn select_outbound_headers<'a>(
3535 headers: &'a HashMap<String, serde_json::Value>,
3536 skip_request_headers: &[String],
3537 conn_tokens: &[String],
3538) -> OutboundHeaderSelection<'a> {
3539 let mut accepted = Vec::new();
3540 let mut drops = Vec::new();
3541 for (key, value) in headers {
3542 if key.starts_with("Camel") {
3543 drops.push(OutboundHeaderDrop {
3544 name: key,
3545 reason: "Camel namespace",
3546 value_kind: None,
3547 });
3548 continue;
3549 }
3550 if skip_request_headers
3551 .iter()
3552 .any(|h| h.eq_ignore_ascii_case(key))
3553 {
3554 drops.push(OutboundHeaderDrop {
3555 name: key,
3556 reason: "skip_request_headers",
3557 value_kind: None,
3558 });
3559 continue;
3560 }
3561 if header_policy::excluded_outbound(key, conn_tokens) {
3562 drops.push(OutboundHeaderDrop {
3563 name: key,
3564 reason: "outbound emission policy",
3565 value_kind: None,
3566 });
3567 continue;
3568 }
3569 let Some(val_str) = scalar_string_form(value) else {
3570 drops.push(OutboundHeaderDrop {
3571 name: key,
3572 reason: "no scalar string form",
3573 value_kind: Some(json_value_kind(value)),
3574 });
3575 continue;
3576 };
3577 let name = match reqwest::header::HeaderName::from_bytes(key.as_bytes()) {
3578 Ok(name) => name,
3579 Err(_) => {
3580 drops.push(OutboundHeaderDrop {
3581 name: key,
3582 reason: "invalid header name",
3583 value_kind: None,
3584 });
3585 continue;
3586 }
3587 };
3588 match reqwest::header::HeaderValue::from_str(&val_str) {
3589 Ok(val) => accepted.push((name, val)),
3590 Err(_) => drops.push(OutboundHeaderDrop {
3591 name: key,
3592 reason: "invalid header value",
3593 value_kind: None,
3594 }),
3595 }
3596 }
3597 OutboundHeaderSelection { accepted, drops }
3598}
3599
3600#[cfg(test)]
3601mod tests {
3602 use camel_component_api::test_support::NoopRuntimeObservability;
3603
3604 fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3608 std::sync::Arc::new(NoopRuntimeObservability)
3609 }
3610 fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3611 std::sync::Arc::new(NoopRuntimeObservability)
3612 }
3613 fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3614 std::sync::Arc::new(NoopRuntimeObservability)
3615 }
3616
3617 use super::*;
3618 use crate::rest_match::PathSegment;
3619 use camel_component_api::{Message, NoOpComponentContext};
3620 use std::sync::Arc;
3621 use std::time::Duration;
3622
3623 fn test_producer_ctx() -> ProducerContext {
3624 ProducerContext::new()
3625 }
3626
3627 #[test]
3632 fn redact_url_masks_userinfo_and_query() {
3633 let redacted =
3634 redact_url_for_diagnostics("http://user:secretpass@internal.example/api?token=abc123");
3635 assert!(
3636 !redacted.contains("secretpass"),
3637 "password must be masked: {redacted}"
3638 );
3639 assert!(
3640 !redacted.contains("token=abc123"),
3641 "query must be masked: {redacted}"
3642 );
3643 assert!(
3644 !redacted.contains("user@"),
3645 "username must be masked: {redacted}"
3646 );
3647 assert!(
3648 redacted.contains("internal.example"),
3649 "host stays visible: {redacted}"
3650 );
3651 assert!(redacted.contains("[redacted]"), "query marked: {redacted}");
3652 }
3653
3654 #[test]
3655 fn redact_url_keeps_clean_urls_visible() {
3656 let redacted = redact_url_for_diagnostics("https://api.example.com/v1/items");
3657 assert_eq!(redacted, "https://api.example.com/v1/items");
3658 }
3659
3660 #[test]
3661 fn redact_url_truncates_unparseable() {
3662 let long = "x".repeat(1000);
3663 let redacted = redact_url_for_diagnostics(&long);
3664 assert_eq!(redacted.len(), 256, "unparseable URL must be truncated");
3665 }
3666
3667 #[test]
3668 fn truncate_error_body_caps_attacker_body() {
3669 let big = vec![b'A'; 10 * 1024 * 1024];
3670 let truncated = truncate_error_body(&big);
3671 assert!(
3672 truncated.len() <= MAX_ERROR_RESPONSE_BODY_BYTES + 20,
3673 "body must be capped near {} bytes, got {}",
3674 MAX_ERROR_RESPONSE_BODY_BYTES,
3675 truncated.len()
3676 );
3677 assert!(truncated.ends_with("...[truncated]"));
3678 }
3679
3680 #[test]
3681 fn truncate_error_body_keeps_small_body() {
3682 assert_eq!(truncate_error_body(b"boom"), "boom");
3683 }
3684
3685 #[test]
3686 fn test_http_config_defaults() {
3687 let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
3688 assert_eq!(config.base_url, "http://localhost:8080/api");
3689 assert!(config.http_method.is_none());
3690 assert!(config.throw_exception_on_failure);
3691 assert_eq!(config.ok_status_code_range, (200, 299));
3692 assert!(config.response_timeout.is_none());
3693 assert!(matches!(config.auth, HttpAuth::None));
3694 assert!(!config.bridge_endpoint);
3695 assert!(!config.connection_close);
3696 }
3697
3698 #[test]
3699 fn test_http_config_scheme() {
3700 assert_eq!(HttpEndpointConfig::scheme(), "http");
3702 }
3703
3704 #[test]
3705 fn test_http_config_from_components() {
3706 let components = camel_component_api::UriComponents {
3708 scheme: "https".to_string(),
3709 path: "//api.example.com/v1".to_string(),
3710 params: std::collections::HashMap::from([(
3711 "httpMethod".to_string(),
3712 "POST".to_string(),
3713 )]),
3714 raw_query: None,
3715 };
3716 let config = HttpEndpointConfig::from_components(components).unwrap();
3717 assert_eq!(config.base_url, "https://api.example.com/v1");
3718 assert_eq!(config.http_method, Some("POST".to_string()));
3719 }
3720
3721 #[test]
3722 fn test_http_config_with_options() {
3723 let config = HttpEndpointConfig::from_uri(
3724 "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
3725 ).unwrap();
3726 assert_eq!(config.base_url, "https://api.example.com/v1");
3727 assert_eq!(config.http_method, Some("PUT".to_string()));
3728 assert!(!config.throw_exception_on_failure);
3729 assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
3730 }
3731
3732 #[test]
3733 fn test_http_endpoint_config_auth_and_headers_options() {
3734 let config = HttpEndpointConfig::from_uri(
3735 "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
3736 )
3737 .unwrap();
3738
3739 assert!(matches!(
3740 config.auth,
3741 HttpAuth::Basic { username, password } if username == "u" && password == "p"
3742 ));
3743 assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
3744 assert!(config.bridge_endpoint);
3745 assert!(config.connection_close);
3746 assert_eq!(
3747 config.skip_request_headers,
3748 vec!["authorization".to_string(), "x-secret".to_string()]
3749 );
3750 assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
3751 }
3752
3753 #[test]
3754 fn test_http_endpoint_config_bearer_auth() {
3755 let config = HttpEndpointConfig::from_uri(
3756 "http://localhost/api?authMethod=Bearer&authBearerToken=t",
3757 )
3758 .unwrap();
3759 assert!(matches!(
3760 config.auth,
3761 HttpAuth::Bearer { token } if token == "t"
3762 ));
3763 }
3764
3765 #[test]
3766 fn rejects_cookie_handling_inmemory() {
3767 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
3768 match result {
3769 Err(CamelError::InvalidUri(msg)) => {
3770 assert!(
3771 msg.contains("cookieHandling is not supported"),
3772 "expected rejection message, got: {msg}"
3773 );
3774 }
3775 other => panic!("expected InvalidUri error, got: {other:?}"),
3776 }
3777 }
3778
3779 #[test]
3780 fn rejects_cookie_handling_disabled() {
3781 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
3782 match result {
3783 Err(CamelError::InvalidUri(msg)) => {
3784 assert!(
3785 msg.contains("cookieHandling is not supported"),
3786 "expected rejection message, got: {msg}"
3787 );
3788 }
3789 other => panic!("expected InvalidUri error, got: {other:?}"),
3790 }
3791 }
3792
3793 #[test]
3794 fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
3795 let config = HttpConfig::default()
3796 .with_response_timeout_ms(999)
3797 .with_allow_internal(true)
3798 .with_blocked_hosts(vec!["evil.com".to_string()])
3799 .with_max_body_size(12345);
3800 let endpoint =
3801 HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
3802 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
3803 assert!(endpoint.allow_internal);
3804 assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
3805 assert_eq!(endpoint.max_body_size, 12345);
3806 }
3807
3808 #[test]
3809 fn test_from_uri_with_defaults_uri_overrides_config() {
3810 let config = HttpConfig::default()
3811 .with_response_timeout_ms(999)
3812 .with_allow_internal(true)
3813 .with_blocked_hosts(vec!["evil.com".to_string()])
3814 .with_max_body_size(12345);
3815 let endpoint = HttpEndpointConfig::from_uri_with_defaults(
3816 "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
3817 &config,
3818 )
3819 .unwrap();
3820 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
3821 assert!(!endpoint.allow_internal);
3822 assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
3823 assert_eq!(endpoint.max_body_size, 99);
3824 }
3825
3826 #[test]
3827 fn test_http_config_ok_status_range() {
3828 let config =
3829 HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
3830 assert_eq!(config.ok_status_code_range, (200, 204));
3831 }
3832
3833 #[test]
3834 fn test_http_config_wrong_scheme() {
3835 let result = HttpEndpointConfig::from_uri("file:/tmp");
3836 assert!(result.is_err());
3837 }
3838
3839 #[test]
3840 fn test_http_component_scheme() {
3841 let component = HttpComponent::new();
3842 assert_eq!(component.scheme(), "http");
3843 }
3844
3845 #[test]
3846 fn test_https_component_scheme() {
3847 let component = HttpsComponent::new();
3848 assert_eq!(component.scheme(), "https");
3849 }
3850
3851 #[test]
3852 fn test_http_endpoint_creates_consumer() {
3853 let component = HttpComponent::new();
3854 let ctx = NoOpComponentContext;
3855 let endpoint = component
3856 .create_endpoint("http://0.0.0.0:19100/test", &ctx)
3857 .unwrap();
3858 assert!(endpoint.create_consumer(rt()).is_ok());
3859 }
3860
3861 #[test]
3862 fn test_https_endpoint_creates_consumer_errors_without_tls() {
3863 let component = HttpsComponent::new();
3864 let ctx = NoOpComponentContext;
3865 let endpoint = component
3866 .create_endpoint("https://0.0.0.0:8443/test", &ctx)
3867 .unwrap();
3868 assert!(endpoint.create_consumer(rt()).is_err());
3870 }
3871
3872 #[test]
3873 fn test_http_endpoint_creates_producer() {
3874 let ctx = test_producer_ctx();
3875 let component = HttpComponent::new();
3876 let endpoint_ctx = NoOpComponentContext;
3877 let endpoint = component
3878 .create_endpoint("http://localhost/api", &endpoint_ctx)
3879 .unwrap();
3880 assert!(endpoint.create_producer(rt(), &ctx).is_ok());
3881 }
3882
3883 #[tokio::test]
3888 async fn test_producer_with_token_provider() {
3889 use camel_auth::oauth2::TokenProvider;
3890 use tower::ServiceExt;
3891
3892 let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
3893 Arc::new(std::sync::Mutex::new(None));
3894 let captured_clone = Arc::clone(&captured_auth);
3895
3896 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3897 let port = listener.local_addr().unwrap().port();
3898
3899 let _handle = tokio::spawn(async move {
3900 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3901 if let Ok((mut stream, _)) = listener.accept().await {
3902 let mut buf = vec![0u8; 8192];
3903 let n = stream.read(&mut buf).await.unwrap_or(0);
3904 let request = String::from_utf8_lossy(&buf[..n]).to_string();
3905 let auth = request
3906 .lines()
3907 .find(|l| l.to_lowercase().starts_with("authorization:"))
3908 .map(|l| {
3909 l.split(':')
3910 .nth(1)
3911 .map(|s| s.trim().to_string())
3912 .unwrap_or_default()
3913 });
3914 *captured_clone.lock().unwrap() = auth;
3915 let body = r#"{"echo":"ok"}"#;
3916 let resp = format!(
3917 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
3918 body.len(),
3919 body
3920 );
3921 let _ = stream.write_all(resp.as_bytes()).await;
3922 }
3923 });
3924
3925 #[derive(Debug)]
3926 struct StaticProvider;
3927 #[async_trait::async_trait]
3928 impl TokenProvider for StaticProvider {
3929 async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
3930 Ok("injected-token".into())
3931 }
3932 }
3933
3934 let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
3935 let ctx = test_producer_ctx();
3936 let component = HttpComponent::new();
3937 let endpoint_ctx = NoOpComponentContext;
3938 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
3939 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3940
3941 let exchange = Exchange::new(Message::new("hello"));
3942
3943 let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
3944 let mut layered = layer.layer(producer);
3945 let result = layered.ready().await.unwrap().call(exchange).await;
3946 assert!(result.is_ok(), "producer call failed: {:?}", result);
3947
3948 tokio::time::sleep(Duration::from_millis(100)).await;
3949 let auth = captured_auth.lock().unwrap().take();
3950 assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
3951 }
3952
3953 async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
3954 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3955 let addr = listener.local_addr().unwrap();
3956 let url = format!("http://127.0.0.1:{}", addr.port());
3957
3958 let handle = tokio::spawn(async move {
3959 loop {
3960 if let Ok((mut stream, _)) = listener.accept().await {
3961 tokio::spawn(async move {
3962 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3963 let mut buf = vec![0u8; 4096];
3964 let n = stream.read(&mut buf).await.unwrap_or(0);
3965 let request = String::from_utf8_lossy(&buf[..n]).to_string();
3966
3967 let method = request.split_whitespace().next().unwrap_or("GET");
3968
3969 let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
3970 let response = format!(
3971 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
3972 body.len(),
3973 body
3974 );
3975 let _ = stream.write_all(response.as_bytes()).await;
3976 });
3977 }
3978 }
3979 });
3980
3981 (url, handle)
3982 }
3983
3984 async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
3985 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3986 let addr = listener.local_addr().unwrap();
3987 let url = format!("http://127.0.0.1:{}", addr.port());
3988
3989 let handle = tokio::spawn(async move {
3990 loop {
3991 if let Ok((mut stream, _)) = listener.accept().await {
3992 let status = status;
3993 tokio::spawn(async move {
3994 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3995 let mut buf = vec![0u8; 4096];
3996 let _ = stream.read(&mut buf).await;
3997
3998 let status_text = match status {
3999 404 => "Not Found",
4000 500 => "Internal Server Error",
4001 _ => "Error",
4002 };
4003 let body = "error body";
4004 let response = format!(
4005 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
4006 status,
4007 status_text,
4008 body.len(),
4009 body
4010 );
4011 let _ = stream.write_all(response.as_bytes()).await;
4012 });
4013 }
4014 }
4015 });
4016
4017 (url, handle)
4018 }
4019
4020 async fn start_request_capturing_server() -> (
4021 String,
4022 Arc<std::sync::Mutex<Option<String>>>,
4023 tokio::task::JoinHandle<()>,
4024 ) {
4025 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4026 let port = listener.local_addr().unwrap().port();
4027 let url = format!("http://127.0.0.1:{port}");
4028 let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
4029 let captured_clone = Arc::clone(&captured);
4030 let handle = tokio::spawn(async move {
4031 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4032 if let Ok((mut stream, _)) = listener.accept().await {
4033 let mut buf = vec![0u8; 16384];
4034 let n = stream.read(&mut buf).await.unwrap_or(0);
4035 let request = String::from_utf8_lossy(&buf[..n]).to_string();
4036 if request.contains("\r\n\r\n") {
4037 *captured_clone.lock().unwrap() = Some(request);
4038 }
4039 let body = r#"{"echo":"ok"}"#;
4040 let resp = format!(
4041 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
4042 body.len(),
4043 body
4044 );
4045 let _ = stream.write_all(resp.as_bytes()).await;
4046 }
4047 });
4048 (url, captured, handle)
4049 }
4050
4051 #[tokio::test]
4052 async fn test_http_producer_get_request() {
4053 use tower::ServiceExt;
4054
4055 let (url, _handle) = start_test_server().await;
4056 let ctx = test_producer_ctx();
4057
4058 let component = HttpComponent::new();
4059 let endpoint_ctx = NoOpComponentContext;
4060 let endpoint = component
4061 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4062 .unwrap();
4063 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4064
4065 let exchange = Exchange::new(Message::default());
4066 let result = producer.oneshot(exchange).await.unwrap();
4067
4068 let status = result
4069 .input
4070 .header("CamelHttpResponseCode")
4071 .and_then(|v| v.as_u64())
4072 .unwrap();
4073 assert_eq!(status, 200);
4074
4075 assert!(!result.input.body.is_empty());
4076 }
4077
4078 #[tokio::test]
4079 async fn producer_excludes_host_and_framing() {
4080 use tower::ServiceExt;
4081
4082 let (url, captured, _handle) = start_request_capturing_server().await;
4083 let ctx = test_producer_ctx();
4084 let component = HttpComponent::new();
4085 let endpoint_ctx = NoOpComponentContext;
4086 let endpoint = component
4087 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4088 .unwrap();
4089 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4090
4091 let mut exchange = Exchange::new(Message::default());
4092 exchange.input.set_header("Host", "localhost");
4093 exchange.input.set_header("Content-Length", "42");
4094 exchange.input.set_header("Connection", "keep-alive");
4095 exchange.input.set_header("Upgrade", "h2c");
4096
4097 let result = producer.oneshot(exchange).await;
4098 assert!(result.is_ok(), "producer call failed: {:?}", result);
4099
4100 tokio::time::sleep(Duration::from_millis(100)).await;
4101 let request = captured
4102 .lock()
4103 .unwrap()
4104 .take()
4105 .expect("no outbound request captured");
4106 let lower = request.to_ascii_lowercase();
4107 assert!(
4108 !lower.contains("\r\nhost: localhost"),
4109 "forwarded Host: localhost must be stripped\n{request}"
4110 );
4111 assert!(
4112 !lower.contains("content-length: 42"),
4113 "exchange Content-Length must not be copied\n{request}"
4114 );
4115 assert!(
4116 !lower.lines().any(|l| l.starts_with("connection:")),
4117 "Connection header must not be forwarded\n{request}"
4118 );
4119 assert!(
4120 !lower.lines().any(|l| l.starts_with("upgrade:")),
4121 "Upgrade header must not be forwarded\n{request}"
4122 );
4123 let host_header = lower
4124 .lines()
4125 .find(|l| l.starts_with("host:"))
4126 .map(|l| l.split_once(':').map(|(_, v)| v).unwrap_or("").trim())
4127 .expect("outbound Host header must be set by reqwest");
4128 assert!(
4129 host_header.starts_with("127.0.0.1:"),
4130 "outbound Host '{host_header}' must match the capture-server address"
4131 );
4132 }
4133
4134 #[tokio::test]
4135 async fn producer_forwards_request_only_headers() {
4136 use tower::ServiceExt;
4137
4138 let (url, captured, _handle) = start_request_capturing_server().await;
4139 let ctx = test_producer_ctx();
4140 let component = HttpComponent::new();
4141 let endpoint_ctx = NoOpComponentContext;
4142 let endpoint = component
4143 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4144 .unwrap();
4145 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4146
4147 let mut exchange = Exchange::new(Message::default());
4148 exchange.input.set_header("Accept", "application/json");
4149 exchange.input.set_header("User-Agent", "myclient/1.0");
4150
4151 let result = producer.oneshot(exchange).await;
4152 assert!(result.is_ok(), "producer call failed: {:?}", result);
4153
4154 tokio::time::sleep(Duration::from_millis(100)).await;
4155 let request = captured
4156 .lock()
4157 .unwrap()
4158 .take()
4159 .expect("no outbound request captured");
4160 let lower = request.to_ascii_lowercase();
4161 assert!(
4162 lower.contains("accept: application/json"),
4163 "request-only Accept header must be forwarded\n{request}"
4164 );
4165 assert!(
4166 lower.contains("user-agent: myclient/1.0"),
4167 "request-only User-Agent header must be forwarded\n{request}"
4168 );
4169 }
4170
4171 #[tokio::test]
4172 async fn producer_honours_skip_request_headers() {
4173 use tower::ServiceExt;
4174
4175 let (url, captured, _handle) = start_request_capturing_server().await;
4176 let ctx = test_producer_ctx();
4177 let component = HttpComponent::new();
4178 let endpoint_ctx = NoOpComponentContext;
4179 let endpoint = component
4180 .create_endpoint(
4181 &format!("{url}/api/test?allowInternal=true&skipRequestHeaders=Authorization"),
4182 &endpoint_ctx,
4183 )
4184 .unwrap();
4185 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4186
4187 let mut exchange = Exchange::new(Message::default());
4188 exchange.input.set_header("Authorization", "Bearer x");
4189
4190 let result = producer.oneshot(exchange).await;
4191 assert!(result.is_ok(), "producer call failed: {:?}", result);
4192
4193 tokio::time::sleep(Duration::from_millis(100)).await;
4194 let request = captured
4195 .lock()
4196 .unwrap()
4197 .take()
4198 .expect("no outbound request captured");
4199 assert!(
4200 !request.to_ascii_lowercase().contains("authorization"),
4201 "Authorization must be stripped by skipRequestHeaders\n{request}"
4202 );
4203 }
4204
4205 #[tokio::test]
4206 async fn producer_stringifies_scalar_header_values_on_wire() {
4207 use tower::ServiceExt;
4208
4209 let (url, captured, _handle) = start_request_capturing_server().await;
4210 let ctx = test_producer_ctx();
4211 let component = HttpComponent::new();
4212 let endpoint_ctx = NoOpComponentContext;
4213 let endpoint = component
4214 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
4215 .unwrap();
4216 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4217
4218 let mut exchange = Exchange::new(Message::default());
4219 exchange.input.set_header("X-Retries", serde_json::json!(3));
4220 exchange
4221 .input
4222 .set_header("X-Enabled", serde_json::json!(true));
4223 exchange
4224 .input
4225 .set_header("X-Obj", serde_json::json!({"a": 1}));
4226
4227 let result = producer.oneshot(exchange).await;
4228 assert!(result.is_ok(), "producer call failed: {:?}", result);
4229
4230 tokio::time::sleep(Duration::from_millis(100)).await;
4231 let request = captured
4232 .lock()
4233 .unwrap()
4234 .take()
4235 .expect("no outbound request captured");
4236 let lower = request.to_ascii_lowercase();
4237 assert!(
4238 lower.contains("x-retries: 3"),
4239 "numeric header must reach the wire stringified\n{request}"
4240 );
4241 assert!(
4242 lower.contains("x-enabled: true"),
4243 "bool header must reach the wire stringified\n{request}"
4244 );
4245 assert!(
4246 !lower.contains("x-obj:"),
4247 "object header has no single-value form and must not reach the wire\n{request}"
4248 );
4249 }
4250
4251 #[tokio::test]
4252 async fn test_http_producer_post_with_body() {
4253 use tower::ServiceExt;
4254
4255 let (url, _handle) = start_test_server().await;
4256 let ctx = test_producer_ctx();
4257
4258 let component = HttpComponent::new();
4259 let endpoint_ctx = NoOpComponentContext;
4260 let endpoint = component
4261 .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
4262 .unwrap();
4263 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4264
4265 let exchange = Exchange::new(Message::new("request body"));
4266 let result = producer.oneshot(exchange).await.unwrap();
4267
4268 let status = result
4269 .input
4270 .header("CamelHttpResponseCode")
4271 .and_then(|v| v.as_u64())
4272 .unwrap();
4273 assert_eq!(status, 200);
4274 }
4275
4276 #[tokio::test]
4277 async fn test_http_producer_method_from_header() {
4278 use tower::ServiceExt;
4279
4280 let (url, _handle) = start_test_server().await;
4281 let ctx = test_producer_ctx();
4282
4283 let component = HttpComponent::new();
4284 let endpoint_ctx = NoOpComponentContext;
4285 let endpoint = component
4286 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4287 .unwrap();
4288 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4289
4290 let mut exchange = Exchange::new(Message::default());
4291 exchange.input.set_header(
4292 "CamelHttpMethod",
4293 serde_json::Value::String("DELETE".to_string()),
4294 );
4295
4296 let result = producer.oneshot(exchange).await.unwrap();
4297 let status = result
4298 .input
4299 .header("CamelHttpResponseCode")
4300 .and_then(|v| v.as_u64())
4301 .unwrap();
4302 assert_eq!(status, 200);
4303 }
4304
4305 #[tokio::test]
4306 async fn test_http_producer_forced_method() {
4307 use tower::ServiceExt;
4308
4309 let (url, _handle) = start_test_server().await;
4310 let ctx = test_producer_ctx();
4311
4312 let component = HttpComponent::new();
4313 let endpoint_ctx = NoOpComponentContext;
4314 let endpoint = component
4315 .create_endpoint(
4316 &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
4317 &endpoint_ctx,
4318 )
4319 .unwrap();
4320 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4321
4322 let exchange = Exchange::new(Message::default());
4323 let result = producer.oneshot(exchange).await.unwrap();
4324
4325 let status = result
4326 .input
4327 .header("CamelHttpResponseCode")
4328 .and_then(|v| v.as_u64())
4329 .unwrap();
4330 assert_eq!(status, 200);
4331 }
4332
4333 #[tokio::test]
4334 async fn test_http_producer_throw_exception_on_failure() {
4335 use tower::ServiceExt;
4336
4337 let (url, _handle) = start_status_server(404).await;
4338 let ctx = test_producer_ctx();
4339
4340 let component = HttpComponent::new();
4341 let endpoint_ctx = NoOpComponentContext;
4342 let endpoint = component
4343 .create_endpoint(
4344 &format!("{url}/not-found?allowInternal=true"),
4345 &endpoint_ctx,
4346 )
4347 .unwrap();
4348 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4349
4350 let exchange = Exchange::new(Message::default());
4351 let result = producer.oneshot(exchange).await;
4352 assert!(result.is_err());
4353
4354 match result.unwrap_err() {
4355 CamelError::HttpOperationFailed { status_code, .. } => {
4356 assert_eq!(status_code, 404);
4357 }
4358 e => panic!("Expected HttpOperationFailed, got: {e}"),
4359 }
4360 }
4361
4362 #[tokio::test]
4363 async fn test_http_producer_no_throw_on_failure() {
4364 use tower::ServiceExt;
4365
4366 let (url, _handle) = start_status_server(500).await;
4367 let ctx = test_producer_ctx();
4368
4369 let component = HttpComponent::new();
4370 let endpoint_ctx = NoOpComponentContext;
4371 let endpoint = component
4372 .create_endpoint(
4373 &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
4374 &endpoint_ctx,
4375 )
4376 .unwrap();
4377 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4378
4379 let exchange = Exchange::new(Message::default());
4380 let result = producer.oneshot(exchange).await.unwrap();
4381
4382 let status = result
4383 .input
4384 .header("CamelHttpResponseCode")
4385 .and_then(|v| v.as_u64())
4386 .unwrap();
4387 assert_eq!(status, 500);
4388 }
4389
4390 #[tokio::test]
4391 async fn test_http_producer_uri_override() {
4392 use tower::ServiceExt;
4393
4394 let (url, _handle) = start_test_server().await;
4395 let ctx = test_producer_ctx();
4396
4397 let component = HttpComponent::new();
4398 let endpoint_ctx = NoOpComponentContext;
4399 let endpoint = component
4400 .create_endpoint(
4401 "http://localhost:1/does-not-exist?allowInternal=true",
4402 &endpoint_ctx,
4403 )
4404 .unwrap();
4405 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4406
4407 let mut exchange = Exchange::new(Message::default());
4408 exchange.input.set_header(
4409 "CamelHttpUri",
4410 serde_json::Value::String(format!("{url}/api")),
4411 );
4412
4413 let result = producer.oneshot(exchange).await.unwrap();
4414 let status = result
4415 .input
4416 .header("CamelHttpResponseCode")
4417 .and_then(|v| v.as_u64())
4418 .unwrap();
4419 assert_eq!(status, 200);
4420 }
4421
4422 #[tokio::test]
4423 async fn test_http_producer_response_headers_mapped() {
4424 use tower::ServiceExt;
4425
4426 let (url, _handle) = start_test_server().await;
4427 let ctx = test_producer_ctx();
4428
4429 let component = HttpComponent::new();
4430 let endpoint_ctx = NoOpComponentContext;
4431 let endpoint = component
4432 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4433 .unwrap();
4434 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4435
4436 let exchange = Exchange::new(Message::default());
4437 let result = producer.oneshot(exchange).await.unwrap();
4438
4439 assert!(
4440 result.input.header("Content-Type").is_some(),
4441 "Response should have Content-Type header"
4442 );
4443 assert!(result.input.header("CamelHttpResponseText").is_some());
4444 }
4445
4446 async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
4451 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4452 let addr = listener.local_addr().unwrap();
4453 let url = format!("http://127.0.0.1:{}", addr.port());
4454
4455 let handle = tokio::spawn(async move {
4456 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4457 loop {
4458 if let Ok((mut stream, _)) = listener.accept().await {
4459 tokio::spawn(async move {
4460 let mut buf = vec![0u8; 4096];
4461 let n = stream.read(&mut buf).await.unwrap_or(0);
4462 let request = String::from_utf8_lossy(&buf[..n]).to_string();
4463
4464 if request.contains("GET /final") {
4466 let body = r#"{"status":"final"}"#;
4467 let response = format!(
4468 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
4469 body.len(),
4470 body
4471 );
4472 let _ = stream.write_all(response.as_bytes()).await;
4473 } else {
4474 let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
4479 let _ = stream.write_all(response.as_bytes()).await;
4480 }
4481 });
4482 }
4483 }
4484 });
4485
4486 (url, handle)
4487 }
4488
4489 struct CapturedRequest {
4490 method: String,
4491 path: String,
4492 body: Vec<u8>,
4493 content_length: Option<String>,
4494 transfer_encoding: Option<String>,
4495 }
4496
4497 async fn capture_request(stream: &mut tokio::net::TcpStream) -> Option<CapturedRequest> {
4503 use tokio::io::AsyncReadExt;
4504
4505 let mut buf: Vec<u8> = Vec::new();
4507 let mut chunk = [0u8; 4096];
4508 let head_end: usize;
4509 loop {
4510 let n = stream.read(&mut chunk).await.unwrap_or(0);
4511 if n == 0 {
4512 return None;
4513 }
4514 buf.extend_from_slice(&chunk[..n]);
4515 if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
4516 head_end = pos + 4;
4517 break;
4518 }
4519 }
4520
4521 let head = String::from_utf8_lossy(&buf[..head_end]);
4523 let mut lines = head.split("\r\n");
4524 let request_line = lines.next().unwrap_or("");
4525 let mut parts = request_line.split_whitespace();
4526 let method = parts.next().unwrap_or("").to_string();
4527 let path = parts.next().unwrap_or("").to_string();
4528
4529 let mut content_length: Option<String> = None;
4530 let mut transfer_encoding: Option<String> = None;
4531 for line in lines {
4532 if let Some((name, value)) = line.split_once(':') {
4533 let name = name.trim().to_ascii_lowercase();
4534 let value = value.trim().to_string();
4535 if name == "content-length" {
4536 content_length = Some(value);
4537 } else if name == "transfer-encoding" {
4538 transfer_encoding = Some(value);
4539 }
4540 }
4541 }
4542
4543 let body_len: usize = content_length
4545 .as_deref()
4546 .and_then(|v| v.parse::<usize>().ok())
4547 .unwrap_or(0);
4548
4549 let mut body: Vec<u8> = buf[head_end..].to_vec();
4550 while body.len() < body_len {
4551 let n = stream.read(&mut chunk).await.unwrap_or(0);
4552 if n == 0 {
4553 break;
4554 }
4555 body.extend_from_slice(&chunk[..n]);
4556 }
4557 body.truncate(body_len);
4558
4559 Some(CapturedRequest {
4560 method,
4561 path,
4562 body,
4563 content_length,
4564 transfer_encoding,
4565 })
4566 }
4567
4568 async fn start_capture_server() -> (
4573 String,
4574 tokio::task::JoinHandle<()>,
4575 Arc<Mutex<Vec<CapturedRequest>>>,
4576 ) {
4577 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4578 let addr = listener.local_addr().unwrap();
4579 let url = format!("http://127.0.0.1:{}", addr.port());
4580
4581 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
4582 let captured_for_return = Arc::clone(&captured);
4583
4584 let handle = tokio::spawn(async move {
4585 use tokio::io::AsyncWriteExt;
4586 loop {
4587 if let Ok((mut stream, _)) = listener.accept().await {
4588 let captured = Arc::clone(&captured);
4589 tokio::spawn(async move {
4590 let Some(req) = capture_request(&mut stream).await else {
4591 return;
4592 };
4593 captured.lock().unwrap().push(req);
4594
4595 let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
4598 let _ = stream.write_all(response.as_bytes()).await;
4599 });
4600 }
4601 }
4602 });
4603
4604 (url, handle, captured_for_return)
4605 }
4606
4607 async fn start_redirect_capture_server() -> (
4613 String,
4614 tokio::task::JoinHandle<()>,
4615 Arc<Mutex<Vec<CapturedRequest>>>,
4616 ) {
4617 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4618 let addr = listener.local_addr().unwrap();
4619 let url = format!("http://127.0.0.1:{}", addr.port());
4620
4621 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
4622 let captured_for_return = Arc::clone(&captured);
4623
4624 let handle = tokio::spawn(async move {
4625 use tokio::io::AsyncWriteExt;
4626 loop {
4627 if let Ok((mut stream, _)) = listener.accept().await {
4628 let captured = Arc::clone(&captured);
4629 tokio::spawn(async move {
4630 let Some(req) = capture_request(&mut stream).await else {
4631 return;
4632 };
4633 let path = req.path.clone();
4634 captured.lock().unwrap().push(req);
4635
4636 let (status_line, location) = match path.as_str() {
4637 "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
4638 "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
4639 "/final" => ("HTTP/1.1 200 OK", None),
4640 _ => ("HTTP/1.1 404 Not Found", None),
4641 };
4642
4643 let response = match location {
4644 Some(loc) => format!(
4648 "{status_line}\r\nLocation: {loc}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
4649 ),
4650 None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
4651 };
4652 let _ = stream.write_all(response.as_bytes()).await;
4653 });
4654 }
4655 }
4656 });
4657
4658 (url, handle, captured_for_return)
4659 }
4660
4661 #[tokio::test]
4662 async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
4663 use tower::ServiceExt;
4664
4665 let (url, _handle, captured) = start_capture_server().await;
4666 let ctx = test_producer_ctx();
4667
4668 let component = HttpComponent::with_config(HttpConfig::default());
4669 let endpoint_ctx = NoOpComponentContext;
4670 let endpoint = component
4671 .create_endpoint(
4672 &format!("{url}?httpMethod=GET&allowInternal=true"),
4673 &endpoint_ctx,
4674 )
4675 .unwrap();
4676 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4677
4678 let mut exchange = Exchange::new(Message::default());
4679 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
4680
4681 let result = producer.oneshot(exchange).await.unwrap();
4682
4683 let status = result
4684 .input
4685 .header("CamelHttpResponseCode")
4686 .and_then(|v| v.as_u64())
4687 .unwrap();
4688 assert_eq!(status, 200);
4689
4690 let captured = captured.lock().unwrap();
4691 assert_eq!(captured.len(), 1, "expected exactly one captured request");
4692 let req = &captured[0];
4693 assert_eq!(req.method, "GET");
4694 assert_eq!(req.path, "/");
4697 assert!(req.body.is_empty(), "GET must not carry a body");
4698 assert!(
4699 req.content_length.is_none(),
4700 "suppressed request must not carry Content-Length"
4701 );
4702 assert!(
4703 req.transfer_encoding.is_none(),
4704 "suppressed request must not carry Transfer-Encoding"
4705 );
4706
4707 assert!(
4709 result.input.body.is_empty(),
4710 "exchange body must be consumed"
4711 );
4712 }
4713
4714 #[tokio::test]
4715 async fn test_head_with_body_suppressed_via_header() {
4716 use tower::ServiceExt;
4717
4718 let (url, _handle, captured) = start_capture_server().await;
4719 let ctx = test_producer_ctx();
4720
4721 let component = HttpComponent::with_config(HttpConfig::default());
4722 let endpoint_ctx = NoOpComponentContext;
4723 let endpoint = component
4724 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
4725 .unwrap();
4726 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4727
4728 let mut exchange = Exchange::new(Message::default());
4729 exchange.input.set_header(
4730 "CamelHttpMethod",
4731 serde_json::Value::String("HEAD".to_string()),
4732 );
4733 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
4734
4735 let result = producer.oneshot(exchange).await.unwrap();
4736 let status = result
4737 .input
4738 .header("CamelHttpResponseCode")
4739 .and_then(|v| v.as_u64())
4740 .unwrap();
4741 assert_eq!(status, 200);
4742
4743 let captured = captured.lock().unwrap();
4744 assert_eq!(captured.len(), 1);
4745 let req = &captured[0];
4746 assert_eq!(req.method, "HEAD");
4747 assert!(req.body.is_empty(), "HEAD must not carry a body");
4748 }
4749
4750 #[tokio::test]
4751 async fn test_delete_options_trace_with_body_suppressed() {
4752 use tower::ServiceExt;
4753
4754 let (url, _handle, captured) = start_capture_server().await;
4755 let ctx = test_producer_ctx();
4756 let component = HttpComponent::with_config(HttpConfig::default());
4757 let endpoint_ctx = NoOpComponentContext;
4758
4759 for method in ["DELETE", "OPTIONS", "TRACE"] {
4760 let endpoint = component
4761 .create_endpoint(
4762 &format!("{url}?httpMethod={method}&allowInternal=true"),
4763 &endpoint_ctx,
4764 )
4765 .unwrap();
4766 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4767
4768 let mut exchange = Exchange::new(Message::default());
4769 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
4770
4771 let result = producer.oneshot(exchange).await.unwrap();
4772 let status = result
4773 .input
4774 .header("CamelHttpResponseCode")
4775 .and_then(|v| v.as_u64())
4776 .unwrap();
4777 assert_eq!(status, 200, "method {method} should succeed");
4778 }
4779
4780 let captured = captured.lock().unwrap();
4781 assert_eq!(captured.len(), 3, "expected three captured requests");
4782 for method in ["DELETE", "OPTIONS", "TRACE"] {
4783 let req = captured
4784 .iter()
4785 .find(|r| r.method == method)
4786 .unwrap_or_else(|| panic!("missing captured request for {method}"));
4787 assert!(req.body.is_empty(), "{} must not carry a body", req.method);
4788 }
4789 }
4790
4791 #[tokio::test]
4792 async fn test_post_put_patch_with_body_still_sent() {
4793 use tower::ServiceExt;
4794
4795 let (url, _handle, captured) = start_capture_server().await;
4796 let ctx = test_producer_ctx();
4797 let component = HttpComponent::with_config(HttpConfig::default());
4798 let endpoint_ctx = NoOpComponentContext;
4799
4800 for method in ["POST", "PUT", "PATCH"] {
4801 let endpoint = component
4802 .create_endpoint(
4803 &format!("{url}?httpMethod={method}&allowInternal=true"),
4804 &endpoint_ctx,
4805 )
4806 .unwrap();
4807 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4808
4809 let payload = format!("body-for-{method}");
4810 let mut exchange = Exchange::new(Message::default());
4811 exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
4812
4813 let result = producer.oneshot(exchange).await.unwrap();
4814 let status = result
4815 .input
4816 .header("CamelHttpResponseCode")
4817 .and_then(|v| v.as_u64())
4818 .unwrap();
4819 assert_eq!(status, 200, "method {method} should succeed");
4820 }
4821
4822 let captured = captured.lock().unwrap();
4823 assert_eq!(captured.len(), 3, "expected three captured requests");
4824 for method in ["POST", "PUT", "PATCH"] {
4825 let req = captured
4826 .iter()
4827 .find(|r| r.method == method)
4828 .unwrap_or_else(|| panic!("missing captured request for {method}"));
4829 let expected = format!("body-for-{method}");
4830 assert!(!req.body.is_empty(), "{method} must still carry its body");
4831 assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
4832 }
4833 }
4834
4835 #[tokio::test]
4839 async fn test_stream_body_under_get_not_attached() {
4840 use tower::ServiceExt;
4841
4842 let (url, _handle, captured) = start_capture_server().await;
4843 let ctx = test_producer_ctx();
4844
4845 let component = HttpComponent::with_config(HttpConfig::default());
4846 let endpoint_ctx = NoOpComponentContext;
4847 let endpoint = component
4848 .create_endpoint(
4849 &format!("{url}?httpMethod=GET&allowInternal=true"),
4850 &endpoint_ctx,
4851 )
4852 .unwrap();
4853 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4854
4855 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
4856 vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
4857 let stream = Box::pin(futures::stream::iter(chunks));
4858 let mut exchange = Exchange::new(Message::default());
4859 exchange.input.body = Body::Stream(StreamBody {
4860 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
4861 metadata: StreamMetadata::default(),
4862 });
4863
4864 let result = producer.oneshot(exchange).await.unwrap();
4865
4866 let status = result
4867 .input
4868 .header("CamelHttpResponseCode")
4869 .and_then(|v| v.as_u64())
4870 .unwrap();
4871 assert_eq!(status, 200);
4872
4873 let captured = captured.lock().unwrap();
4874 assert_eq!(captured.len(), 1, "expected exactly one captured request");
4875 assert!(
4876 captured[0].body.is_empty(),
4877 "GET must not carry a stream body"
4878 );
4879 assert!(
4880 captured[0].transfer_encoding.is_none(),
4881 "suppressed request must not carry Transfer-Encoding"
4882 );
4883 assert!(
4884 captured[0].content_length.is_none(),
4885 "suppressed request must not carry Content-Length"
4886 );
4887 assert!(
4888 result.input.body.is_empty(),
4889 "exchange body must be consumed to Empty, not left as a stream"
4890 );
4891 }
4892
4893 #[tokio::test]
4897 async fn test_redirect_hops_never_replay_suppressed_body() {
4898 use tower::ServiceExt;
4899
4900 let (url, _handle, captured) = start_redirect_capture_server().await;
4901 let ctx = test_producer_ctx();
4902
4903 let component =
4904 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4905 let endpoint_ctx = NoOpComponentContext;
4906
4907 for path in ["/hop307", "/hop308"] {
4908 let endpoint = component
4909 .create_endpoint(
4910 &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
4911 &endpoint_ctx,
4912 )
4913 .unwrap();
4914 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4915
4916 let mut exchange = Exchange::new(Message::default());
4917 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
4918
4919 let result = producer.oneshot(exchange).await.unwrap();
4920 let status = result
4921 .input
4922 .header("CamelHttpResponseCode")
4923 .and_then(|v| v.as_u64())
4924 .unwrap();
4925 assert_eq!(
4926 status, 200,
4927 "redirect chain for {path} should end at /final"
4928 );
4929 }
4930
4931 let captured = captured.lock().unwrap();
4933 assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
4934 for req in captured.iter() {
4935 assert!(
4936 req.body.is_empty(),
4937 "hop {} {} must not carry a body",
4938 req.method,
4939 req.path
4940 );
4941 }
4942 }
4943
4944 #[tracing_test::traced_test]
4953 #[tokio::test]
4954 async fn test_suppressed_body_logs_exactly_one_warn() {
4955 use tower::ServiceExt;
4956
4957 let (url, _handle, _captured) = start_capture_server().await;
4958 let ctx = test_producer_ctx();
4959
4960 let component = HttpComponent::with_config(HttpConfig::default());
4961 let endpoint_ctx = NoOpComponentContext;
4962 let endpoint = component
4963 .create_endpoint(
4964 &format!("{url}?httpMethod=GET&allowInternal=true"),
4965 &endpoint_ctx,
4966 )
4967 .unwrap();
4968 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4969
4970 let mut exchange = Exchange::new(Message::default());
4971 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
4972 let correlation_id = exchange.correlation_id().to_string();
4973
4974 let result = producer.oneshot(exchange).await.unwrap();
4975 let status = result
4976 .input
4977 .header("CamelHttpResponseCode")
4978 .and_then(|v| v.as_u64())
4979 .unwrap();
4980 assert_eq!(status, 200);
4981
4982 logs_assert(|lines: &[&str]| {
4983 let hits = lines
4984 .iter()
4985 .filter(|l| {
4986 l.contains("dropping request body")
4987 && l.contains("method=GET")
4988 && l.contains(&format!("correlation_id={correlation_id}"))
4989 })
4990 .count();
4991 match hits {
4992 1 => Ok(()),
4993 n => Err(format!("expected exactly one body-drop warn, found {n}")),
4994 }
4995 });
4996 }
4997
4998 #[tracing_test::traced_test]
4999 #[tokio::test]
5000 async fn test_empty_body_get_emits_no_warn() {
5001 use tower::ServiceExt;
5002
5003 let (url, _handle, _captured) = start_capture_server().await;
5004 let ctx = test_producer_ctx();
5005
5006 let component = HttpComponent::with_config(HttpConfig::default());
5007 let endpoint_ctx = NoOpComponentContext;
5008 let endpoint = component
5009 .create_endpoint(
5010 &format!("{url}?httpMethod=GET&allowInternal=true"),
5011 &endpoint_ctx,
5012 )
5013 .unwrap();
5014 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5015
5016 let exchange = Exchange::new(Message::default());
5017 let result = producer.oneshot(exchange).await.unwrap();
5018 let status = result
5019 .input
5020 .header("CamelHttpResponseCode")
5021 .and_then(|v| v.as_u64())
5022 .unwrap();
5023 assert_eq!(status, 200);
5024
5025 logs_assert(|lines: &[&str]| {
5026 let hits = lines
5027 .iter()
5028 .filter(|l| l.contains("dropping request body"))
5029 .count();
5030 match hits {
5031 0 => Ok(()),
5032 n => Err(format!("expected no body-drop warn, found {n}")),
5033 }
5034 });
5035 }
5036
5037 #[tokio::test]
5038 async fn test_follow_redirects_false_does_not_follow() {
5039 use tower::ServiceExt;
5040
5041 let (url, _handle) = start_redirect_server().await;
5042 let ctx = test_producer_ctx();
5043
5044 let component =
5045 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
5046 let endpoint_ctx = NoOpComponentContext;
5047 let endpoint = component
5048 .create_endpoint(
5049 &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
5050 &endpoint_ctx,
5051 )
5052 .unwrap();
5053 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5054
5055 let exchange = Exchange::new(Message::default());
5056 let result = producer.oneshot(exchange).await.unwrap();
5057
5058 let status = result
5060 .input
5061 .header("CamelHttpResponseCode")
5062 .and_then(|v| v.as_u64())
5063 .unwrap();
5064 assert_eq!(
5065 status, 302,
5066 "Should NOT follow redirect when followRedirects=false"
5067 );
5068 }
5069
5070 #[tokio::test]
5071 async fn test_follow_redirects_true_follows_redirect() {
5072 use tower::ServiceExt;
5073
5074 let (url, _handle) = start_redirect_server().await;
5075 let ctx = test_producer_ctx();
5076
5077 let component =
5078 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5079 let endpoint_ctx = NoOpComponentContext;
5080 let endpoint = component
5081 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5082 .unwrap();
5083 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5084
5085 let exchange = Exchange::new(Message::default());
5086 let result = producer.oneshot(exchange).await.unwrap();
5087
5088 let status = result
5090 .input
5091 .header("CamelHttpResponseCode")
5092 .and_then(|v| v.as_u64())
5093 .unwrap();
5094 assert_eq!(
5095 status, 200,
5096 "Should follow redirect when followRedirects=true"
5097 );
5098 }
5099
5100 #[tokio::test]
5103 async fn test_redirect_to_private_ip_is_ssrf_blocked() {
5104 use tower::ServiceExt;
5105
5106 let (url, _handle) = start_redirect_server().await;
5108 let ctx = test_producer_ctx();
5109
5110 let component =
5111 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5112 let endpoint_ctx = NoOpComponentContext;
5113 let endpoint = component
5114 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5115 .unwrap();
5116 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5117
5118 let exchange = Exchange::new(Message::default());
5119 let result = producer.oneshot(exchange).await;
5120
5121 assert!(
5123 result.is_ok(),
5124 "Redirect should succeed with allowInternal=true, got: {:?}",
5125 result
5126 );
5127 let exchange = result.unwrap();
5128 let status = exchange
5129 .input
5130 .header("CamelHttpResponseCode")
5131 .and_then(|v| v.as_u64())
5132 .unwrap();
5133 assert_eq!(status, 200, "Should follow redirect to /final");
5134 }
5135
5136 #[tokio::test]
5138 async fn test_redirect_to_private_ip_allowed_when_configured() {
5139 use tower::ServiceExt;
5140
5141 let (url, _handle) = start_redirect_server().await;
5143 let ctx = test_producer_ctx();
5144
5145 let component =
5146 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5147 let endpoint_ctx = NoOpComponentContext;
5148 let endpoint = component
5149 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
5150 .unwrap();
5151 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5152
5153 let exchange = Exchange::new(Message::default());
5154 let result = producer.oneshot(exchange).await.unwrap();
5155
5156 let status = result
5157 .input
5158 .header("CamelHttpResponseCode")
5159 .and_then(|v| v.as_u64())
5160 .unwrap();
5161 assert_eq!(
5162 status, 200,
5163 "Should follow redirect to private IP when allowInternal=true"
5164 );
5165 }
5166
5167 #[tokio::test]
5170 async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
5171 use tower::ServiceExt;
5172
5173 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5175 let addr = listener.local_addr().unwrap();
5176 let url = format!("http://127.0.0.1:{}", addr.port());
5177
5178 let handle = tokio::spawn(async move {
5179 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5180 loop {
5181 if let Ok((mut stream, _)) = listener.accept().await {
5182 tokio::spawn(async move {
5183 let mut buf = vec![0u8; 4096];
5184 let _ = stream.read(&mut buf).await;
5185 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";
5187 let _ = stream.write_all(response.as_bytes()).await;
5188 });
5189 }
5190 }
5191 });
5192
5193 let ctx = test_producer_ctx();
5194 let component =
5195 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5196 let endpoint_ctx = NoOpComponentContext;
5197 let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
5199 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5200
5201 let exchange = Exchange::new(Message::default());
5202 let result = producer.oneshot(exchange).await;
5203
5204 assert!(
5206 result.is_err(),
5207 "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
5208 );
5209 let err = result.unwrap_err().to_string();
5210 assert!(
5211 err.contains("blocked IP")
5212 || err.contains("private IP")
5213 || err.contains("SSRF")
5214 || err.contains("not allowed"),
5215 "Error should mention SSRF/IP blocking, got: {err}"
5216 );
5217
5218 handle.abort();
5219 }
5220
5221 #[tokio::test]
5223 async fn test_too_many_redirects_returns_error() {
5224 use tower::ServiceExt;
5225
5226 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5228 let addr = listener.local_addr().unwrap();
5229 let url = format!("http://127.0.0.1:{}", addr.port());
5230
5231 let handle = tokio::spawn(async move {
5232 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5233 loop {
5234 if let Ok((mut stream, _)) = listener.accept().await {
5235 tokio::spawn(async move {
5236 let mut buf = vec![0u8; 4096];
5237 let _ = stream.read(&mut buf).await;
5238 let response = "HTTP/1.1 302 Found\r\nLocation: /loop\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
5243 let _ = stream.write_all(response.as_bytes()).await;
5244 });
5245 }
5246 }
5247 });
5248
5249 let ctx = test_producer_ctx();
5250 let component =
5251 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5252 let endpoint_ctx = NoOpComponentContext;
5253 let endpoint = component
5254 .create_endpoint(
5255 &format!("{url}?allowInternal=true&maxRedirects=2"),
5256 &endpoint_ctx,
5257 )
5258 .unwrap();
5259 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5260
5261 let exchange = Exchange::new(Message::default());
5262 let result = producer.oneshot(exchange).await;
5263
5264 match result {
5272 Err(e) => {
5273 let msg = e.to_string();
5275 assert!(
5276 msg.contains("HTTP operation failed") || msg.contains("302"),
5277 "expected redirect-after-exhaustion error, got: {msg}"
5278 );
5279 }
5280 Ok(ex) => {
5281 let response_code = ex
5282 .input
5283 .header("CamelHttpResponseCode")
5284 .and_then(|v| v.as_u64());
5285 assert_eq!(
5286 response_code,
5287 Some(302),
5288 "expected 302 after exhausting redirects"
5289 );
5290 }
5291 }
5292
5293 handle.abort();
5294 }
5295
5296 #[tokio::test]
5297 async fn test_query_params_forwarded_to_http_request() {
5298 use tower::ServiceExt;
5299
5300 let (url, _handle) = start_test_server().await;
5301 let ctx = test_producer_ctx();
5302
5303 let component = HttpComponent::new();
5304 let endpoint_ctx = NoOpComponentContext;
5305 let endpoint = component
5307 .create_endpoint(
5308 &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
5309 &endpoint_ctx,
5310 )
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
5320 .input
5321 .header("CamelHttpResponseCode")
5322 .and_then(|v| v.as_u64())
5323 .unwrap();
5324 assert_eq!(status, 200);
5325 }
5326
5327 #[test]
5328 fn test_non_camel_query_params_are_forwarded() {
5329 let config = HttpEndpointConfig::from_uri(
5332 "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
5333 )
5334 .unwrap();
5335
5336 assert_eq!(
5339 config.raw_query.as_deref(),
5340 Some("apiKey=secret123&httpMethod=GET&token=abc456")
5341 );
5342 assert!(config.query_params.is_empty());
5343 }
5344
5345 #[test]
5346 fn test_authored_query_bytes_survive_resolve_url() {
5347 let config =
5348 HttpEndpointConfig::from_uri("http://example.com/api?q=hello%20world&tag=a+b").unwrap();
5349 let exchange = Exchange::new(Message::default());
5350
5351 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
5352
5353 assert!(url.contains("q=hello%20world"), "url was: {url}");
5356 assert!(url.contains("tag=a+b"), "url was: {url}");
5357 }
5358
5359 async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
5364 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5365 let addr = listener.local_addr().unwrap();
5366 let url = format!("http://127.0.0.1:{}", addr.port());
5367
5368 let handle = tokio::spawn(async move {
5369 loop {
5370 if let Ok((mut stream, _)) = listener.accept().await {
5371 let delay = delay_ms;
5372 tokio::spawn(async move {
5373 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5374 let mut buf = vec![0u8; 4096];
5375 let _ = stream.read(&mut buf).await;
5376 let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
5378 let _ = stream.write_all(headers.as_bytes()).await;
5379 tokio::time::sleep(Duration::from_millis(delay)).await;
5381 let body = r#"{"status":"slow"}"#;
5382 let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
5383 let _ = stream.write_all(chunk.as_bytes()).await;
5384 });
5385 }
5386 }
5387 });
5388
5389 (url, handle)
5390 }
5391
5392 #[tokio::test]
5393 async fn test_http_producer_timeout() {
5394 use tower::ServiceExt;
5395
5396 let (url, _handle) = start_slow_server(500).await;
5398 let ctx = test_producer_ctx();
5399
5400 let component = HttpComponent::with_config(
5401 HttpConfig::default()
5402 .with_read_timeout_ms(100)
5403 .with_response_timeout_ms(30_000), );
5405 let endpoint_ctx = NoOpComponentContext;
5406 let endpoint = component
5407 .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
5408 .unwrap();
5409 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5410
5411 let exchange = Exchange::new(Message::default());
5412 let result = producer.oneshot(exchange).await;
5413
5414 assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
5415 let err = result.unwrap_err().to_string();
5416 assert!(
5417 err.contains("Read timeout") || err.contains("timeout"),
5418 "Error should mention timeout, got: {}",
5419 err
5420 );
5421 }
5422
5423 #[tokio::test]
5424 async fn test_http_producer_no_timeout_when_fast() {
5425 use tower::ServiceExt;
5426
5427 let (url, _handle) = start_test_server().await;
5428 let ctx = test_producer_ctx();
5429
5430 let component =
5431 HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
5432 let endpoint_ctx = NoOpComponentContext;
5433 let endpoint = component
5434 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5435 .unwrap();
5436 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5437
5438 let exchange = Exchange::new(Message::default());
5439 let result = producer.oneshot(exchange).await.unwrap();
5440
5441 let status = result
5442 .input
5443 .header("CamelHttpResponseCode")
5444 .and_then(|v| v.as_u64())
5445 .unwrap();
5446 assert_eq!(status, 200);
5447 }
5448
5449 #[tokio::test]
5454 async fn test_http_producer_blocks_metadata_endpoint() {
5455 use tower::ServiceExt;
5456
5457 let ctx = test_producer_ctx();
5458 let component = HttpComponent::new();
5459 let endpoint_ctx = NoOpComponentContext;
5460 let endpoint = component
5461 .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
5462 .unwrap();
5463 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5464
5465 let mut exchange = Exchange::new(Message::default());
5466 exchange.input.set_header(
5467 "CamelHttpUri",
5468 serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
5469 );
5470
5471 let result = producer.oneshot(exchange).await;
5472 assert!(result.is_err(), "Should block AWS metadata endpoint");
5473
5474 let err = result.unwrap_err();
5475 assert!(
5476 err.to_string().contains("Private IP"),
5477 "Error should mention private IP blocking, got: {}",
5478 err
5479 );
5480 }
5481
5482 #[test]
5483 fn test_ssrf_config_defaults() {
5484 let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
5485 assert!(
5486 !config.allow_internal,
5487 "Private IPs should be blocked by default"
5488 );
5489 assert!(
5490 config.blocked_hosts.is_empty(),
5491 "Blocked hosts should be empty by default"
5492 );
5493 }
5494
5495 #[test]
5496 fn test_ssrf_config_allow_internal() {
5497 let config =
5498 HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
5499 assert!(
5500 config.allow_internal,
5501 "Private IPs should be allowed when explicitly set"
5502 );
5503 }
5504
5505 #[test]
5506 fn test_ssrf_config_blocked_hosts() {
5507 let config = HttpEndpointConfig::from_uri(
5508 "http://example.com/api?blockedHosts=evil.com,malware.net",
5509 )
5510 .unwrap();
5511 assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
5512 }
5513
5514 #[tokio::test]
5515 async fn test_http_producer_blocks_localhost() {
5516 use tower::ServiceExt;
5517
5518 let ctx = test_producer_ctx();
5519 let component = HttpComponent::new();
5520 let endpoint_ctx = NoOpComponentContext;
5521 let endpoint = component
5522 .create_endpoint("http://example.com/api", &endpoint_ctx)
5523 .unwrap();
5524 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5525
5526 let mut exchange = Exchange::new(Message::default());
5527 exchange.input.set_header(
5528 "CamelHttpUri",
5529 serde_json::Value::String("http://localhost:8080/internal".to_string()),
5530 );
5531
5532 let result = producer.oneshot(exchange).await;
5533 assert!(result.is_err(), "Should block localhost");
5534 }
5535
5536 #[tokio::test]
5537 async fn test_http_producer_blocks_loopback_ip() {
5538 use tower::ServiceExt;
5539
5540 let ctx = test_producer_ctx();
5541 let component = HttpComponent::new();
5542 let endpoint_ctx = NoOpComponentContext;
5543 let endpoint = component
5544 .create_endpoint("http://example.com/api", &endpoint_ctx)
5545 .unwrap();
5546 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5547
5548 let mut exchange = Exchange::new(Message::default());
5549 exchange.input.set_header(
5550 "CamelHttpUri",
5551 serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
5552 );
5553
5554 let result = producer.oneshot(exchange).await;
5555 assert!(result.is_err(), "Should block loopback IP");
5556 }
5557
5558 #[tokio::test]
5559 async fn test_http_producer_allows_private_ip_when_enabled() {
5560 use tower::ServiceExt;
5561
5562 let ctx = test_producer_ctx();
5563 let component = HttpComponent::new();
5564 let endpoint_ctx = NoOpComponentContext;
5565 let endpoint = component
5568 .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
5569 .unwrap();
5570 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5571
5572 let exchange = Exchange::new(Message::default());
5573
5574 let result = producer.oneshot(exchange).await;
5577 if let Err(ref e) = result {
5579 let err_str = e.to_string();
5580 assert!(
5581 !err_str.contains("Private IP") && !err_str.contains("not allowed"),
5582 "Should not be SSRF error, got: {}",
5583 err_str
5584 );
5585 }
5586 }
5587
5588 #[test]
5593 fn test_http_server_config_parse() {
5594 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
5595 assert_eq!(cfg.host, "0.0.0.0");
5596 assert_eq!(cfg.port, 8080);
5597 assert_eq!(cfg.path, "/orders");
5598 assert_eq!(cfg.max_inflight_requests, 1024);
5599 }
5600
5601 #[test]
5602 fn test_http_server_config_scheme() {
5603 assert_eq!(HttpServerConfig::scheme(), "http");
5605 }
5606
5607 #[test]
5608 fn test_http_server_config_from_components() {
5609 let components = camel_component_api::UriComponents {
5611 scheme: "https".to_string(),
5612 path: "//0.0.0.0:8443/api".to_string(),
5613 params: std::collections::HashMap::from([
5614 ("maxRequestBody".to_string(), "5242880".to_string()),
5615 ("maxInflightRequests".to_string(), "7".to_string()),
5616 ]),
5617 raw_query: None,
5618 };
5619 let cfg = HttpServerConfig::from_components(components).unwrap();
5620 assert_eq!(cfg.host, "0.0.0.0");
5621 assert_eq!(cfg.port, 8443);
5622 assert_eq!(cfg.path, "/api");
5623 assert_eq!(cfg.max_request_body, 5242880);
5624 assert_eq!(cfg.max_inflight_requests, 7);
5625 }
5626
5627 #[test]
5628 fn test_http_server_config_default_path() {
5629 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
5630 assert_eq!(cfg.path, "/");
5631 }
5632
5633 #[test]
5634 fn test_http_server_config_wrong_scheme() {
5635 assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
5636 }
5637
5638 #[test]
5639 fn test_http_server_config_invalid_port() {
5640 assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
5641 }
5642
5643 #[test]
5644 fn test_http_server_config_default_port_by_scheme() {
5645 let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
5647 assert_eq!(cfg_http.port, 80);
5648
5649 let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
5651 assert_eq!(cfg_https.port, 443);
5652 }
5653
5654 #[test]
5655 fn test_request_envelope_and_reply_are_send() {
5656 fn assert_send<T: Send>() {}
5657 assert_send::<RequestEnvelope>();
5658 assert_send::<HttpReply>();
5659 }
5660
5661 #[test]
5666 fn test_server_registry_global_is_singleton() {
5667 let r1 = ServerRegistry::global();
5668 let r2 = ServerRegistry::global();
5669 assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
5670 }
5671
5672 #[allow(clippy::await_holding_lock)]
5673 #[tokio::test]
5674 async fn test_concurrent_get_or_spawn_returns_same_registry() {
5675 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5676 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5677 let port = listener.local_addr().unwrap().port();
5678 drop(listener);
5679
5680 let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
5681 Arc::new(std::sync::Mutex::new(Vec::new()));
5682
5683 let mut handles = Vec::new();
5684 for _ in 0..4 {
5685 let results = results.clone();
5686 handles.push(tokio::spawn(async move {
5687 let registry = ServerRegistry::global()
5688 .get_or_spawn(
5689 "127.0.0.1",
5690 port,
5691 2 * 1024 * 1024,
5692 10 * 1024 * 1024,
5693 1024,
5694 test_rt(),
5695 "test-route".into(),
5696 None,
5697 )
5698 .await
5699 .unwrap();
5700 results.lock().unwrap().push(registry);
5701 }));
5702 }
5703
5704 for h in handles {
5705 h.await.unwrap();
5706 }
5707
5708 let registries = results.lock().unwrap();
5709 assert_eq!(registries.len(), 4);
5710 for i in 1..registries.len() {
5711 assert!(
5712 Arc::ptr_eq(®istries[0].inner, ®istries[i].inner),
5713 "all concurrent callers should get same route registry"
5714 );
5715 }
5716 }
5717
5718 #[test]
5719 fn test_server_registry_distinguishes_host_and_port() {
5720 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5721 let rt = tokio::runtime::Runtime::new().expect("runtime");
5722 rt.block_on(async {
5723 let registry = ServerRegistry::global();
5724 let d1 = registry
5728 .get_or_spawn(
5729 "127.0.0.1",
5730 0,
5731 1024 * 1024,
5732 10 * 1024 * 1024,
5733 1024,
5734 test_rt(),
5735 "test-route-1".into(),
5736 None,
5737 )
5738 .await;
5739 let d2 = registry
5740 .get_or_spawn(
5741 "0.0.0.0",
5742 0,
5743 1024 * 1024,
5744 10 * 1024 * 1024,
5745 1024,
5746 test_rt(),
5747 "test-route-2".into(),
5748 None,
5749 )
5750 .await;
5751 assert!(d1.is_ok());
5752 assert!(d2.is_ok());
5753 assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
5754 });
5755 }
5756
5757 #[allow(clippy::await_holding_lock)]
5758 #[tokio::test]
5759 async fn test_shared_server_max_request_body_policy_is_deterministic() {
5760 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5761 let registry = ServerRegistry::global();
5762 let d1 = registry
5764 .get_or_spawn(
5765 "127.0.0.1",
5766 9991,
5767 1024 * 1024,
5768 10 * 1024 * 1024,
5769 1024,
5770 test_rt(),
5771 "test-route".into(),
5772 None,
5773 )
5774 .await;
5775 assert!(d1.is_ok());
5776
5777 let d2 = registry
5780 .get_or_spawn(
5781 "127.0.0.1",
5782 9991,
5783 2 * 1024 * 1024,
5784 10 * 1024 * 1024,
5785 1024,
5786 test_rt(),
5787 "test-route-2".into(),
5788 None,
5789 )
5790 .await;
5791 assert!(d2.is_err());
5792 let err = d2.unwrap_err();
5793 assert!(
5794 err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
5795 "Expected incompatible maxRequestBody error, got: {}",
5796 err
5797 );
5798 }
5799
5800 #[test]
5801 fn test_server_registry_reset_clears_entries() {
5802 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5803 let rt = tokio::runtime::Runtime::new().expect("runtime");
5804 rt.block_on(async {
5805 let d1 = ServerRegistry::global()
5807 .get_or_spawn(
5808 "127.0.0.1",
5809 9992,
5810 1024 * 1024,
5811 10 * 1024 * 1024,
5812 1024,
5813 test_rt(),
5814 "test-route".into(),
5815 None,
5816 )
5817 .await;
5818 assert!(d1.is_ok());
5819
5820 let guard = ServerRegistry::global().inner.lock().expect("lock");
5822 assert!(guard.entries.contains_key(&("127.0.0.1".to_string(), 9992)));
5823 drop(guard);
5824
5825 ServerRegistry::reset();
5827
5828 let guard = ServerRegistry::global().inner.lock().expect("lock");
5830 assert!(
5831 guard.entries.is_empty(),
5832 "registry should be empty after reset, has {} entries",
5833 guard.entries.len()
5834 );
5835 });
5836 }
5837
5838 #[tokio::test]
5839 async fn registry_rejects_tls_on_plain_port() {
5840 ServerRegistry::reset();
5841 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
5842
5843 let _r1 = ServerRegistry::global()
5845 .get_or_spawn(
5846 "127.0.0.1",
5847 0,
5848 1024,
5849 1024,
5850 16,
5851 Arc::clone(&rt),
5852 "route-1".into(),
5853 None, )
5855 .await;
5856
5857 let result = ServerRegistry::global()
5859 .get_or_spawn(
5860 "127.0.0.1",
5861 0,
5862 1024,
5863 1024,
5864 16,
5865 Arc::clone(&rt),
5866 "route-2".into(),
5867 Some(crate::config::ServerTlsConfig {
5868 cert_path: "/x.pem".into(),
5869 key_path: "/y.pem".into(),
5870 }),
5871 )
5872 .await;
5873 assert!(result.is_err(), "must reject TLS on plain port");
5874 }
5875
5876 #[allow(clippy::await_holding_lock)]
5881 #[tokio::test]
5882 async fn test_unregister_last_http_route_keeps_server_alive() {
5883 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5884 ServerRegistry::reset();
5885 let registry = ServerRegistry::global();
5886
5887 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5888 let port = listener.local_addr().unwrap().port();
5889 drop(listener); let rt = test_rt();
5891
5892 let _r1 = registry
5895 .get_or_spawn(
5896 "127.0.0.1",
5897 port,
5898 1024 * 1024,
5899 10 * 1024 * 1024,
5900 16,
5901 rt.clone(),
5902 "test-route-1".into(),
5903 None,
5904 )
5905 .await
5906 .unwrap();
5907 let _r2 = registry
5908 .get_or_spawn(
5909 "127.0.0.1",
5910 port,
5911 1024 * 1024,
5912 10 * 1024 * 1024,
5913 16,
5914 rt,
5915 "test-route-2".into(),
5916 None,
5917 )
5918 .await
5919 .unwrap();
5920
5921 let key = ("127.0.0.1".to_string(), port);
5922 let cell = {
5923 let guard = registry.inner.lock().expect("lock");
5924 guard.entries.get(&key).expect("entry should exist").clone()
5925 };
5926
5927 registry.unregister("127.0.0.1", port).await;
5929 {
5930 let handle = cell
5931 .get()
5932 .expect("handle should still exist after first unregister");
5933 assert!(
5934 !handle.monitor_task.is_finished(),
5935 "monitor task should still be alive after first unregister"
5936 );
5937 }
5938
5939 registry.unregister("127.0.0.1", port).await;
5941 tokio::time::sleep(Duration::from_millis(20)).await;
5942 {
5943 let handle = cell
5944 .get()
5945 .expect("handle should still exist after last unregister");
5946 assert!(
5947 !handle.monitor_task.is_finished(),
5948 "monitor task should still be alive — server is process-lifetime"
5949 );
5950 }
5951
5952 {
5954 let guard = registry.inner.lock().expect("lock");
5955 assert!(
5956 guard.entries.contains_key(&key),
5957 "entry should remain in registry — server kept alive for restart"
5958 );
5959 }
5960 }
5961
5962 async fn clone_fixture_listener() -> (
5971 tokio::net::TcpListener,
5972 std::net::TcpListener,
5973 std::net::SocketAddr,
5974 ) {
5975 let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind std listener");
5976 let probe = l.try_clone().expect("clone probe");
5977 l.set_nonblocking(true).expect("set_nonblocking");
5978 let listener = tokio::net::TcpListener::from_std(l).expect("from_std");
5979 let addr = listener.local_addr().expect("local_addr");
5980 (listener, probe, addr)
5981 }
5982
5983 fn staged_limits() -> (usize, usize, usize) {
5985 (1024 * 1024, 10 * 1024 * 1024, 1024)
5986 }
5987
5988 #[allow(clippy::await_holding_lock)]
5989 #[tokio::test]
5990 async fn staged_listener_first_spawn_serves_without_second_bind() {
5991 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5992 ServerRegistry::reset();
5993 let registry = ServerRegistry::global();
5994 let (listener, _probe, addr) = clone_fixture_listener().await;
5995 let port = addr.port();
5996 registry
5997 .stage_listener(listener)
5998 .await
5999 .expect("stage listener");
6000
6001 let (max_req, max_res, max_inflight) = staged_limits();
6002 let routes = registry
6003 .get_or_spawn(
6004 "127.0.0.1",
6005 port,
6006 max_req,
6007 max_res,
6008 max_inflight,
6009 test_rt(),
6010 "staged-first-spawn".into(),
6011 None,
6012 )
6013 .await
6014 .expect("spawn from staged listener must succeed");
6015
6016 assert_eq!(
6017 registry.bound_addr("127.0.0.1", port),
6018 Some(addr),
6019 "served socket must be the staged listener's addr"
6020 );
6021 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__staged_probe__"))
6024 .await
6025 .expect("http request against staged listener must connect");
6026 assert!(
6027 resp.status().as_u16() >= 200,
6028 "any status proves the staged socket serves"
6029 );
6030 drop(routes);
6031 }
6032
6033 #[allow(clippy::await_holding_lock)]
6034 #[tokio::test]
6035 async fn staged_entry_reused_by_second_caller() {
6036 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6037 ServerRegistry::reset();
6038 let registry = ServerRegistry::global();
6039 let (listener, _probe, addr) = clone_fixture_listener().await;
6040 let port = addr.port();
6041 registry
6042 .stage_listener(listener)
6043 .await
6044 .expect("stage listener");
6045
6046 let (max_req, max_res, max_inflight) = staged_limits();
6047 let first = registry
6048 .get_or_spawn(
6049 "127.0.0.1",
6050 port,
6051 max_req,
6052 max_res,
6053 max_inflight,
6054 test_rt(),
6055 "staged-reuse-1".into(),
6056 None,
6057 )
6058 .await
6059 .expect("first spawn from staged listener");
6060 let second = registry
6061 .get_or_spawn(
6062 "127.0.0.1",
6063 port,
6064 max_req,
6065 max_res,
6066 max_inflight,
6067 test_rt(),
6068 "staged-reuse-2".into(),
6069 None,
6070 )
6071 .await
6072 .expect("second caller must reuse the entry");
6073 assert_eq!(
6074 registry.bound_addr("127.0.0.1", port),
6075 Some(addr),
6076 "entry reused — bound addr unchanged, no second bind"
6077 );
6078 drop(first);
6079 drop(second);
6080 }
6081
6082 #[allow(clippy::await_holding_lock)]
6083 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6084 async fn staged_race_two_callers_single_resolver() {
6085 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6086 ServerRegistry::reset();
6087 let registry = ServerRegistry::global();
6088 let (listener, _probe, addr) = clone_fixture_listener().await;
6089 let port = addr.port();
6090 registry
6091 .stage_listener(listener)
6092 .await
6093 .expect("stage listener");
6094
6095 let (max_req, max_res, max_inflight) = staged_limits();
6100 let (first, second) = tokio::join!(
6101 registry.get_or_spawn(
6102 "127.0.0.1",
6103 port,
6104 max_req,
6105 max_res,
6106 max_inflight,
6107 test_rt(),
6108 "staged-race-1".into(),
6109 None,
6110 ),
6111 registry.get_or_spawn(
6112 "127.0.0.1",
6113 port,
6114 max_req,
6115 max_res,
6116 max_inflight,
6117 test_rt(),
6118 "staged-race-2".into(),
6119 None,
6120 ),
6121 );
6122 let first = first.expect("first racing caller must succeed");
6123 let second = second.expect("second racing caller must succeed");
6124 assert_eq!(
6125 registry.bound_addr("127.0.0.1", port),
6126 Some(addr),
6127 "single entry must be served from the staged socket — no EADDRINUSE path"
6128 );
6129 drop(first);
6130 drop(second);
6131 }
6132
6133 #[allow(clippy::await_holding_lock)]
6134 #[tokio::test]
6135 async fn unstaged_spawn_binds_legacy() {
6136 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6137 ServerRegistry::reset();
6138 let registry = ServerRegistry::global();
6139 let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe bind");
6141 let port = probe.local_addr().expect("local addr").port();
6142 drop(probe);
6143
6144 let (max_req, max_res, max_inflight) = staged_limits();
6145 registry
6146 .get_or_spawn(
6147 "127.0.0.1",
6148 port,
6149 max_req,
6150 max_res,
6151 max_inflight,
6152 test_rt(),
6153 "legacy-bind".into(),
6154 None,
6155 )
6156 .await
6157 .expect("legacy bind spawn");
6158 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__legacy_probe__"))
6159 .await
6160 .expect("connect to freshly bound port must succeed");
6161 assert!(resp.status().as_u16() >= 200);
6162 assert_eq!(
6163 registry.bound_addr("127.0.0.1", port),
6164 Some(std::net::SocketAddr::from(([127, 0, 0, 1], port))),
6165 "bound addr must be the legacy bound (host, port)"
6166 );
6167 }
6168
6169 #[allow(clippy::await_holding_lock)]
6170 #[tokio::test]
6171 async fn wrong_host_staged_port_fails_deterministically() {
6172 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6173 ServerRegistry::reset();
6174 let registry = ServerRegistry::global();
6175 let (listener, _probe, addr) = clone_fixture_listener().await;
6176 let port = addr.port();
6177 registry
6178 .stage_listener(listener)
6179 .await
6180 .expect("stage listener under 127.0.0.1");
6181
6182 let (max_req, max_res, max_inflight) = staged_limits();
6183 let err = registry
6184 .get_or_spawn(
6185 "localhost",
6186 port,
6187 max_req,
6188 max_res,
6189 max_inflight,
6190 test_rt(),
6191 "conflict-probe".into(),
6192 None,
6193 )
6194 .await
6195 .expect_err("wrong host on staged port must fail deterministically");
6196 assert!(
6197 err.to_string().contains("staged listener conflict on port"),
6198 "unexpected error: {err}"
6199 );
6200
6201 registry
6203 .get_or_spawn(
6204 "127.0.0.1",
6205 port,
6206 max_req,
6207 max_res,
6208 max_inflight,
6209 test_rt(),
6210 "conflict-after".into(),
6211 None,
6212 )
6213 .await
6214 .expect("correct host must serve the staged listener");
6215 assert_eq!(
6216 registry.bound_addr("127.0.0.1", port),
6217 Some(addr),
6218 "staged slot must be untouched by the conflicting call"
6219 );
6220 }
6221
6222 #[allow(clippy::await_holding_lock)]
6223 #[tokio::test]
6224 async fn duplicate_stage_same_key_rejected() {
6225 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6226 ServerRegistry::reset();
6227 let registry = ServerRegistry::global();
6228 let (listener, probe, addr) = clone_fixture_listener().await;
6229 registry
6230 .stage_listener(listener)
6231 .await
6232 .expect("stage listener A");
6233
6234 let dup = probe.try_clone().expect("clone2");
6236 dup.set_nonblocking(true).expect("set_nonblocking2");
6237 let b = tokio::net::TcpListener::from_std(dup).expect("from_std2");
6238
6239 let err = registry
6240 .stage_listener(b)
6241 .await
6242 .expect_err("duplicate stage must be rejected");
6243 assert!(
6244 err.to_string().contains("listener already staged"),
6245 "unexpected error: {err}"
6246 );
6247
6248 let (max_req, max_res, max_inflight) = staged_limits();
6249 registry
6250 .get_or_spawn(
6251 "127.0.0.1",
6252 addr.port(),
6253 max_req,
6254 max_res,
6255 max_inflight,
6256 test_rt(),
6257 "dup-stage-after".into(),
6258 None,
6259 )
6260 .await
6261 .expect("spawn from first staged listener");
6262 assert_eq!(
6263 registry.bound_addr("127.0.0.1", addr.port()),
6264 Some(addr),
6265 "first staged listener retained"
6266 );
6267 }
6268
6269 #[allow(clippy::await_holding_lock)]
6270 #[tokio::test]
6271 async fn distinct_keys_stage_independently() {
6272 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6273 ServerRegistry::reset();
6274 let registry = ServerRegistry::global();
6275 let (l1, _p1, addr1) = clone_fixture_listener().await;
6276 let (l2, _p2, addr2) = clone_fixture_listener().await;
6277 registry.stage_listener(l1).await.expect("stage P1");
6278 registry.stage_listener(l2).await.expect("stage P2");
6279
6280 let (max_req, max_res, max_inflight) = staged_limits();
6281 registry
6282 .get_or_spawn(
6283 "127.0.0.1",
6284 addr1.port(),
6285 max_req,
6286 max_res,
6287 max_inflight,
6288 test_rt(),
6289 "distinct-1".into(),
6290 None,
6291 )
6292 .await
6293 .expect("spawn P1");
6294 registry
6295 .get_or_spawn(
6296 "127.0.0.1",
6297 addr2.port(),
6298 max_req,
6299 max_res,
6300 max_inflight,
6301 test_rt(),
6302 "distinct-2".into(),
6303 None,
6304 )
6305 .await
6306 .expect("spawn P2");
6307 assert_eq!(
6308 registry.bound_addr("127.0.0.1", addr1.port()),
6309 Some(addr1),
6310 "P1 bound addr must be its own listener"
6311 );
6312 assert_eq!(
6313 registry.bound_addr("127.0.0.1", addr2.port()),
6314 Some(addr2),
6315 "P2 bound addr must be its own listener"
6316 );
6317 let r1 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr1.port()))
6318 .await
6319 .expect("connect P1");
6320 assert!(r1.status().as_u16() >= 200);
6321 let r2 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr2.port()))
6322 .await
6323 .expect("connect P2");
6324 assert!(r2.status().as_u16() >= 200);
6325 }
6326
6327 #[allow(clippy::await_holding_lock)]
6328 #[tokio::test]
6329 async fn tls_prebound_listener_served() {
6330 use camel_component_api::test_support::tls;
6331
6332 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
6335
6336 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6337 ServerRegistry::reset();
6338 let registry = ServerRegistry::global();
6339 let (listener, _probe, addr) = clone_fixture_listener().await;
6340 let port = addr.port();
6341
6342 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
6343 let cert_path = tls::write_pem_tmp("http-staged-tls-cert.pem", &cert_pem);
6344 let key_path = tls::write_pem_tmp("http-staged-tls-key.pem", &key_pem);
6345 let ca_path = tls::write_pem_tmp("http-staged-tls-ca.pem", &ca_pem);
6346
6347 let (max_req, max_res, max_inflight) = staged_limits();
6348 let routes = registry
6349 .get_or_spawn_with_listener(
6350 listener,
6351 max_req,
6352 max_res,
6353 max_inflight,
6354 test_rt(),
6355 "staged-tls".into(),
6356 Some(crate::config::ServerTlsConfig {
6357 cert_path: cert_path.to_string_lossy().into_owned(),
6358 key_path: key_path.to_string_lossy().into_owned(),
6359 }),
6360 )
6361 .await
6362 .expect("spawn TLS server from pre-bound listener");
6363
6364 let ca_bytes = std::fs::read(&ca_path).expect("read ca pem");
6367 let client = reqwest::Client::builder()
6368 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).expect("parse ca pem"))
6369 .build()
6370 .expect("build tls client");
6371
6372 let resp = client
6373 .get(format!("https://127.0.0.1:{port}/__staged_tls__"))
6374 .send()
6375 .await
6376 .expect("TLS handshake + request must succeed");
6377 assert_eq!(resp.status().as_u16(), 404, "unknown path 404s through TLS");
6378 assert_eq!(
6379 registry.bound_addr("127.0.0.1", port),
6380 Some(addr),
6381 "bound addr equals the pre-bound listener addr"
6382 );
6383 drop(routes);
6384 }
6385
6386 #[allow(clippy::await_holding_lock)]
6387 #[tokio::test]
6388 async fn with_listener_direct_spawn_keyed_by_actual_addr() {
6389 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6390 ServerRegistry::reset();
6391 let registry = ServerRegistry::global();
6392 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
6393 .await
6394 .expect("bind un-staged listener");
6395 let addr = listener.local_addr().expect("local addr");
6396 let port = addr.port();
6397
6398 let (max_req, max_res, max_inflight) = staged_limits();
6399 registry
6400 .get_or_spawn_with_listener(
6401 listener,
6402 max_req,
6403 max_res,
6404 max_inflight,
6405 test_rt(),
6406 "with-listener".into(),
6407 None,
6408 )
6409 .await
6410 .expect("direct spawn from un-staged listener");
6411 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__with_listener__"))
6412 .await
6413 .expect("connect on actual port");
6414 assert!(resp.status().as_u16() >= 200);
6415 assert_eq!(
6416 registry.bound_addr("127.0.0.1", port),
6417 Some(addr),
6418 "registry key is the listener's actual port"
6419 );
6420
6421 registry
6422 .get_or_spawn(
6423 "127.0.0.1",
6424 port,
6425 max_req,
6426 max_res,
6427 max_inflight,
6428 test_rt(),
6429 "with-listener-reuse".into(),
6430 None,
6431 )
6432 .await
6433 .expect("legacy caller must reuse the entry");
6434 assert_eq!(
6435 registry.bound_addr("127.0.0.1", port),
6436 Some(addr),
6437 "entry reused — no second bind"
6438 );
6439 }
6440
6441 #[tokio::test]
6446 async fn test_dispatch_handler_returns_404_for_unknown_path() {
6447 let registry = HttpRouteRegistry::new();
6448 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6450 let port = listener.local_addr().unwrap().port();
6451 tokio::spawn(run_axum_server(
6452 listener,
6453 registry,
6454 2 * 1024 * 1024,
6455 10 * 1024 * 1024,
6456 Arc::new(tokio::sync::Semaphore::new(1024)),
6457 test_rt(),
6458 "test-route".into(),
6459 ));
6460
6461 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
6463
6464 let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
6465 .await
6466 .unwrap();
6467 assert_eq!(resp.status().as_u16(), 404);
6468 }
6469
6470 #[tokio::test]
6475 async fn test_http_consumer_start_registers_path() {
6476 use camel_component_api::ConsumerContext;
6477
6478 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6480 let port = listener.local_addr().unwrap().port();
6481 drop(listener); let consumer_cfg = HttpServerConfig {
6484 scheme: "http".to_string(),
6485 host: "127.0.0.1".to_string(),
6486 port,
6487 path: "/ping".to_string(),
6488 max_request_body: 2 * 1024 * 1024,
6489 max_response_body: 10 * 1024 * 1024,
6490 max_inflight_requests: 1024,
6491 method: None,
6492 tls_config: None,
6493 };
6494 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6495
6496 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6497 let token = tokio_util::sync::CancellationToken::new();
6498 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6499
6500 tokio::spawn(async move {
6501 consumer.start(ctx).await.unwrap();
6502 });
6503
6504 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6505
6506 let client = reqwest::Client::new();
6507 let resp_future = client
6508 .post(format!("http://127.0.0.1:{port}/ping"))
6509 .body("hello world")
6510 .send();
6511
6512 let (http_result, _) = tokio::join!(resp_future, async {
6513 if let Some(mut envelope) = rx.recv().await {
6514 envelope.exchange.input.set_header(
6516 "CamelHttpResponseCode",
6517 serde_json::Value::Number(201.into()),
6518 );
6519 if let Some(reply_tx) = envelope.reply_tx {
6520 let _ = reply_tx.send(Ok(envelope.exchange));
6521 }
6522 }
6523 });
6524
6525 let resp = http_result.unwrap();
6526 assert_eq!(resp.status().as_u16(), 201);
6527
6528 token.cancel();
6529 }
6530
6531 #[test]
6535 fn test_envelope_channel_capacity_follows_max_inflight() {
6536 assert_eq!(envelope_channel_capacity(0), 1);
6537 assert_eq!(envelope_channel_capacity(1), 1);
6538 assert_eq!(envelope_channel_capacity(7), 7);
6539 assert_eq!(envelope_channel_capacity(64), 64);
6540 assert_eq!(envelope_channel_capacity(1024), 1024);
6541 }
6542
6543 #[tokio::test]
6547 async fn test_http_consumer_start_with_zero_max_inflight_rejects_503() {
6548 use camel_component_api::ConsumerContext;
6549
6550 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6551 let port = listener.local_addr().unwrap().port();
6552 drop(listener);
6553
6554 let consumer_cfg = HttpServerConfig {
6555 scheme: "http".to_string(),
6556 host: "127.0.0.1".to_string(),
6557 port,
6558 path: "/ping".to_string(),
6559 max_request_body: 2 * 1024 * 1024,
6560 max_response_body: 10 * 1024 * 1024,
6561 max_inflight_requests: 0,
6562 method: None,
6563 tls_config: None,
6564 };
6565 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6566
6567 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6568 let token = tokio_util::sync::CancellationToken::new();
6569 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6570
6571 let start_handle = tokio::spawn(async move {
6572 consumer.start(ctx).await.unwrap();
6573 });
6574
6575 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6576
6577 let client = reqwest::Client::new();
6578 let resp = client
6579 .post(format!("http://127.0.0.1:{port}/ping"))
6580 .body("hello world")
6581 .send()
6582 .await
6583 .unwrap();
6584 assert_eq!(resp.status().as_u16(), 503);
6585
6586 token.cancel();
6587 let _ = start_handle.await;
6588 }
6589
6590 #[test]
6593 fn test_http_consumer_startup_mode_is_explicit() {
6594 use camel_component_api::ConsumerStartupMode;
6595 let consumer_cfg = HttpServerConfig {
6596 scheme: "http".to_string(),
6597 host: "127.0.0.1".to_string(),
6598 port: 0,
6599 path: "/x".to_string(),
6600 max_request_body: 2 * 1024 * 1024,
6601 max_response_body: 10 * 1024 * 1024,
6602 max_inflight_requests: 1024,
6603 method: None,
6604 tls_config: None,
6605 };
6606 let consumer = HttpConsumer::new(consumer_cfg, test_rt());
6607 assert_eq!(
6608 consumer.startup_mode(),
6609 ConsumerStartupMode::Explicit,
6610 "HttpConsumer must opt into Explicit startup"
6611 );
6612 }
6613
6614 #[allow(clippy::await_holding_lock)]
6620 #[tokio::test]
6621 async fn test_http_consumer_emits_mark_ready_after_bind() {
6622 use camel_component_api::{ConsumerContext, StartupSignal};
6623
6624 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6625
6626 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6627 let port = listener.local_addr().unwrap().port();
6628 drop(listener);
6629
6630 let consumer_cfg = HttpServerConfig {
6631 scheme: "http".to_string(),
6632 host: "127.0.0.1".to_string(),
6633 port,
6634 path: "/ready-probe".to_string(),
6635 max_request_body: 2 * 1024 * 1024,
6636 max_response_body: 10 * 1024 * 1024,
6637 max_inflight_requests: 1024,
6638 method: None,
6639 tls_config: None,
6640 };
6641 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6642
6643 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6644 let token = tokio_util::sync::CancellationToken::new();
6645 let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
6646
6647 let (signal, startup_rx) = StartupSignal::pair();
6649 let ctx = ctx.with_startup(signal);
6650
6651 tokio::spawn(async move {
6654 let _ = consumer.start(ctx).await;
6655 });
6656
6657 let result =
6662 tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
6663 .await
6664 .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
6665 assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
6666
6667 token.cancel();
6669 }
6670
6671 #[tokio::test]
6672 async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
6673 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6674
6675 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6676 let port = listener.local_addr().unwrap().port();
6677 drop(listener);
6678
6679 let consumer_cfg = HttpServerConfig {
6680 scheme: "http".to_string(),
6681 host: "127.0.0.1".to_string(),
6682 port,
6683 path: "/saturation".to_string(),
6684 max_request_body: 2 * 1024 * 1024,
6685 max_response_body: 10 * 1024 * 1024,
6686 max_inflight_requests: 1,
6687 method: None,
6688 tls_config: None,
6689 };
6690 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6691
6692 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6693 let token = tokio_util::sync::CancellationToken::new();
6694 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6695 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6696 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6697
6698 let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
6699 let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
6700
6701 tokio::spawn(async move {
6702 let mut first_seen_tx = Some(first_seen_tx);
6703 let mut unblock_first_rx = Some(unblock_first_rx);
6704
6705 while let Some(envelope) = rx.recv().await {
6706 if let Some(tx) = first_seen_tx.take() {
6707 let _ = tx.send(());
6708 if let Some(rx_unblock) = unblock_first_rx.take() {
6709 let _ = rx_unblock.await;
6710 }
6711 }
6712
6713 if let Some(reply_tx) = envelope.reply_tx {
6714 let _ = reply_tx.send(Ok(envelope.exchange));
6715 }
6716 }
6717 });
6718
6719 let client = reqwest::Client::new();
6720 let first_req = {
6721 let client = client.clone();
6722 async move {
6723 client
6724 .get(format!("http://127.0.0.1:{port}/saturation"))
6725 .send()
6726 .await
6727 .unwrap()
6728 }
6729 };
6730
6731 let first_handle = tokio::spawn(first_req);
6732 first_seen_rx.await.unwrap();
6733
6734 let second_resp = client
6735 .get(format!("http://127.0.0.1:{port}/saturation"))
6736 .send()
6737 .await
6738 .unwrap();
6739
6740 assert_eq!(second_resp.status().as_u16(), 503);
6741
6742 let _ = unblock_first_tx.send(());
6743 let first_resp = first_handle.await.unwrap();
6744 assert_eq!(first_resp.status().as_u16(), 200);
6745
6746 token.cancel();
6747 }
6748
6749 #[tokio::test]
6753 async fn test_http_consumer_chunked_body_is_capped() {
6754 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6755
6756 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6757 let port = listener.local_addr().unwrap().port();
6758 drop(listener);
6759
6760 let consumer_cfg = HttpServerConfig {
6761 scheme: "http".to_string(),
6762 host: "127.0.0.1".to_string(),
6763 port,
6764 path: "/chunked-cap".to_string(),
6765 max_request_body: 1024, max_response_body: 10 * 1024 * 1024,
6767 max_inflight_requests: 16,
6768 method: None,
6769 tls_config: None,
6770 };
6771 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6772
6773 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6774 let token = tokio_util::sync::CancellationToken::new();
6775 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6776 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6777 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6778
6779 let chunks: Vec<Result<bytes::Bytes, std::io::Error>> = (0..8)
6781 .map(|_| Ok(bytes::Bytes::from(vec![b'A'; 512])))
6782 .collect();
6783 let stream_body = reqwest::Body::wrap_stream(futures::stream::iter(chunks));
6784
6785 let client = reqwest::Client::new();
6786 let send_fut = client
6787 .post(format!("http://127.0.0.1:{port}/chunked-cap"))
6788 .body(stream_body)
6789 .send();
6790
6791 let (http_result, _) = tokio::join!(send_fut, async {
6792 if let Some(mut envelope) = rx.recv().await {
6793 let materialized = envelope
6795 .exchange
6796 .input
6797 .body
6798 .clone()
6799 .into_bytes(64 * 1024)
6800 .await;
6801 assert!(
6802 materialized.is_err(),
6803 "materializing a 4 KiB chunked body under a 1 KiB cap must fail"
6804 );
6805 let err = materialized.unwrap_err().to_string();
6806 assert!(
6807 err.contains("limit") || err.contains("exceeds"),
6808 "error should mention the limit: {err}"
6809 );
6810 if let Some(reply_tx) = envelope.reply_tx {
6811 envelope.exchange.input.body =
6812 camel_component_api::Body::Text("handled".to_string());
6813 let _ = reply_tx.send(Ok(envelope.exchange));
6814 }
6815 }
6816 });
6817
6818 let resp = http_result.unwrap();
6819 assert_eq!(resp.status().as_u16(), 200);
6820
6821 token.cancel();
6822 }
6823
6824 #[tokio::test]
6825 #[allow(clippy::await_holding_lock)]
6826 async fn test_http_consumer_enforces_max_response_body_for_bytes() {
6827 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6828
6829 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6830
6831 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6832 let port = listener.local_addr().unwrap().port();
6833 drop(listener);
6834
6835 let consumer_cfg = HttpServerConfig {
6836 scheme: "http".to_string(),
6837 host: "127.0.0.1".to_string(),
6838 port,
6839 path: "/limit-bytes".to_string(),
6840 max_request_body: 2 * 1024 * 1024,
6841 max_response_body: 16,
6842 max_inflight_requests: 1024,
6843 method: None,
6844 tls_config: None,
6845 };
6846 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6847
6848 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6849 let token = tokio_util::sync::CancellationToken::new();
6850 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6851 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6852 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6853
6854 let client = reqwest::Client::new();
6855 let send_fut = client
6856 .get(format!("http://127.0.0.1:{port}/limit-bytes"))
6857 .send();
6858
6859 let (http_result, _) = tokio::join!(send_fut, async {
6860 if let Some(mut envelope) = rx.recv().await {
6861 envelope.exchange.input.body =
6862 camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
6863 if let Some(reply_tx) = envelope.reply_tx {
6864 let _ = reply_tx.send(Ok(envelope.exchange));
6865 }
6866 }
6867 });
6868
6869 let resp = http_result.unwrap();
6870 assert_eq!(resp.status().as_u16(), 500);
6871 let body = resp.text().await.unwrap();
6872 assert_eq!(body, "Response body exceeds configured limit");
6873 token.cancel();
6874 }
6875
6876 #[tokio::test]
6877 #[allow(clippy::await_holding_lock)]
6878 async fn test_http_consumer_enforces_max_response_body_for_json() {
6879 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6880
6881 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6882
6883 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6884 let port = listener.local_addr().unwrap().port();
6885 drop(listener);
6886
6887 let consumer_cfg = HttpServerConfig {
6888 scheme: "http".to_string(),
6889 host: "127.0.0.1".to_string(),
6890 port,
6891 path: "/limit-json".to_string(),
6892 max_request_body: 2 * 1024 * 1024,
6893 max_response_body: 16,
6894 max_inflight_requests: 1024,
6895 method: None,
6896 tls_config: None,
6897 };
6898 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6899
6900 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6901 let token = tokio_util::sync::CancellationToken::new();
6902 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6903 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6904 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6905
6906 let client = reqwest::Client::new();
6907 let send_fut = client
6908 .get(format!("http://127.0.0.1:{port}/limit-json"))
6909 .send();
6910
6911 let (http_result, _) = tokio::join!(send_fut, async {
6912 if let Some(mut envelope) = rx.recv().await {
6913 envelope.exchange.input.body = camel_component_api::Body::Json(
6914 serde_json::json!({"message":"this response is bigger than sixteen"}),
6915 );
6916 if let Some(reply_tx) = envelope.reply_tx {
6917 let _ = reply_tx.send(Ok(envelope.exchange));
6918 }
6919 }
6920 });
6921
6922 let resp = http_result.unwrap();
6923 assert_eq!(resp.status().as_u16(), 500);
6924 let body = resp.text().await.unwrap();
6925 assert_eq!(body, "Response body exceeds configured limit");
6926 token.cancel();
6927 }
6928
6929 #[tokio::test]
6930 #[allow(clippy::await_holding_lock)]
6931 async fn test_http_consumer_enforces_max_response_body_for_xml() {
6932 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6933
6934 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6935
6936 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6937 let port = listener.local_addr().unwrap().port();
6938 drop(listener);
6939
6940 let consumer_cfg = HttpServerConfig {
6941 scheme: "http".to_string(),
6942 host: "127.0.0.1".to_string(),
6943 port,
6944 path: "/limit-xml".to_string(),
6945 max_request_body: 2 * 1024 * 1024,
6946 max_response_body: 16,
6947 max_inflight_requests: 1024,
6948 method: None,
6949 tls_config: None,
6950 };
6951 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6952
6953 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6954 let token = tokio_util::sync::CancellationToken::new();
6955 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6956 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6957 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6958
6959 let client = reqwest::Client::new();
6960 let send_fut = client
6961 .get(format!("http://127.0.0.1:{port}/limit-xml"))
6962 .send();
6963
6964 let (http_result, _) = tokio::join!(send_fut, async {
6965 if let Some(mut envelope) = rx.recv().await {
6966 envelope.exchange.input.body = camel_component_api::Body::Xml(
6967 "<root><value>way-too-large</value></root>".into(),
6968 );
6969 if let Some(reply_tx) = envelope.reply_tx {
6970 let _ = reply_tx.send(Ok(envelope.exchange));
6971 }
6972 }
6973 });
6974
6975 let resp = http_result.unwrap();
6976 assert_eq!(resp.status().as_u16(), 500);
6977 let body = resp.text().await.unwrap();
6978 assert_eq!(body, "Response body exceeds configured limit");
6979 token.cancel();
6980 }
6981
6982 #[tokio::test]
6983 #[allow(clippy::await_holding_lock)]
6984 async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
6985 use camel_component_api::{
6986 CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
6987 };
6988 use futures::stream;
6989
6990 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6991
6992 let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
6993 let port = listener.local_addr().unwrap().port();
6994 drop(listener);
6995
6996 let consumer_cfg = HttpServerConfig {
6997 scheme: "http".to_string(),
6998 host: "0.0.0.0".to_string(),
6999 port,
7000 path: "/limit-stream".to_string(),
7001 max_request_body: 2 * 1024 * 1024,
7002 max_response_body: 16,
7003 max_inflight_requests: 1024,
7004 method: None,
7005 tls_config: None,
7006 };
7007 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7008
7009 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7010 let token = tokio_util::sync::CancellationToken::new();
7011 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7012 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7013 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7014
7015 let client = reqwest::Client::new();
7016 let send_fut = client
7017 .get(format!("http://127.0.0.1:{port}/limit-stream"))
7018 .send();
7019
7020 let (http_result, _) = tokio::join!(send_fut, async {
7021 if let Some(mut envelope) = rx.recv().await {
7022 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
7023 vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
7024 let stream = Box::pin(stream::iter(chunks));
7025 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
7026 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
7027 metadata: StreamMetadata {
7028 size_hint: Some(32),
7029 content_type: Some("application/octet-stream".into()),
7030 origin: None,
7031 },
7032 });
7033 if let Some(reply_tx) = envelope.reply_tx {
7034 let _ = reply_tx.send(Ok(envelope.exchange));
7035 }
7036 }
7037 });
7038
7039 let resp = http_result.unwrap();
7040 assert_eq!(resp.status().as_u16(), 200);
7041 let body = resp.bytes().await.unwrap();
7042 assert_eq!(body.len(), 32);
7043 token.cancel();
7044 }
7045
7046 #[tokio::test]
7051 #[allow(clippy::await_holding_lock)]
7052 async fn test_integration_single_consumer_round_trip() {
7053 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7054
7055 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7059
7060 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7062 let port = listener.local_addr().unwrap().port();
7063 drop(listener); let component = HttpComponent::new();
7066 let endpoint_ctx = NoOpComponentContext;
7067 let endpoint = component
7068 .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
7069 .unwrap();
7070 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7071
7072 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7073 let token = tokio_util::sync::CancellationToken::new();
7074 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7075
7076 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7077 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7078
7079 let client = reqwest::Client::new();
7080 let send_fut = client
7081 .post(format!("http://127.0.0.1:{port}/echo"))
7082 .header("Content-Type", "text/plain")
7083 .body("ping")
7084 .send();
7085
7086 let (http_result, _) = tokio::join!(send_fut, async {
7087 if let Some(mut envelope) = rx.recv().await {
7088 assert_eq!(
7089 envelope.exchange.input.header("CamelHttpMethod"),
7090 Some(&serde_json::Value::String("POST".into()))
7091 );
7092 assert_eq!(
7093 envelope.exchange.input.header("CamelHttpPath"),
7094 Some(&serde_json::Value::String("/echo".into()))
7095 );
7096 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
7097 if let Some(reply_tx) = envelope.reply_tx {
7098 let _ = reply_tx.send(Ok(envelope.exchange));
7099 }
7100 }
7101 });
7102
7103 let resp = http_result.unwrap();
7104 assert_eq!(resp.status().as_u16(), 200);
7105 let body = resp.text().await.unwrap();
7106 assert_eq!(body, "pong");
7107
7108 token.cancel();
7109 }
7110
7111 #[tokio::test]
7112 #[allow(clippy::await_holding_lock)]
7113 async fn test_integration_two_consumers_shared_port() {
7114 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7115
7116 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7117
7118 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7120 let port = listener.local_addr().unwrap().port();
7121 drop(listener);
7122
7123 let component = HttpComponent::new();
7124 let endpoint_ctx = NoOpComponentContext;
7125
7126 let endpoint_a = component
7128 .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
7129 .unwrap();
7130 let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
7131
7132 let endpoint_b = component
7134 .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
7135 .unwrap();
7136 let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
7137
7138 let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7139 let token_a = tokio_util::sync::CancellationToken::new();
7140 let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
7141
7142 let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7143 let token_b = tokio_util::sync::CancellationToken::new();
7144 let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
7145
7146 tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
7147 tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
7148 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7149
7150 let client = reqwest::Client::new();
7151
7152 let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
7154 let (resp_hello, _) = tokio::join!(fut_hello, async {
7155 if let Some(mut envelope) = rx_a.recv().await {
7156 envelope.exchange.input.body =
7157 camel_component_api::Body::Text("hello-response".to_string());
7158 if let Some(reply_tx) = envelope.reply_tx {
7159 let _ = reply_tx.send(Ok(envelope.exchange));
7160 }
7161 }
7162 });
7163
7164 let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
7166 let (resp_world, _) = tokio::join!(fut_world, async {
7167 if let Some(mut envelope) = rx_b.recv().await {
7168 envelope.exchange.input.body =
7169 camel_component_api::Body::Text("world-response".to_string());
7170 if let Some(reply_tx) = envelope.reply_tx {
7171 let _ = reply_tx.send(Ok(envelope.exchange));
7172 }
7173 }
7174 });
7175
7176 let body_a = resp_hello.unwrap().text().await.unwrap();
7177 let body_b = resp_world.unwrap().text().await.unwrap();
7178
7179 assert_eq!(body_a, "hello-response");
7180 assert_eq!(body_b, "world-response");
7181
7182 token_a.cancel();
7183 token_b.cancel();
7184 }
7185
7186 #[tokio::test]
7187 #[allow(clippy::await_holding_lock)]
7188 async fn test_integration_unregistered_path_returns_404() {
7189 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7190
7191 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7192
7193 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7195 let port = listener.local_addr().unwrap().port();
7196 drop(listener);
7197
7198 let component = HttpComponent::new();
7199 let endpoint_ctx = NoOpComponentContext;
7200 let endpoint = component
7201 .create_endpoint(
7202 &format!("http://127.0.0.1:{port}/registered"),
7203 &endpoint_ctx,
7204 )
7205 .unwrap();
7206 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7207
7208 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7209 let token = tokio_util::sync::CancellationToken::new();
7210 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7211
7212 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7213
7214 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
7216 loop {
7217 if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
7218 .await
7219 .is_ok()
7220 {
7221 break;
7222 }
7223 if std::time::Instant::now() >= deadline {
7224 panic!("HTTP server did not start within 5s on port {port}");
7225 }
7226 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
7227 }
7228
7229 let client = reqwest::Client::new();
7230 let resp = client
7231 .get(format!("http://127.0.0.1:{port}/not-there"))
7232 .send()
7233 .await
7234 .unwrap();
7235 assert_eq!(resp.status().as_u16(), 404);
7236
7237 token.cancel();
7238 }
7239
7240 #[test]
7241 fn test_http_consumer_declares_concurrent() {
7242 use camel_component_api::ConcurrencyModel;
7243
7244 let config = HttpServerConfig {
7245 scheme: "http".to_string(),
7246 host: "127.0.0.1".to_string(),
7247 port: 19999,
7248 path: "/test".to_string(),
7249 max_request_body: 2 * 1024 * 1024,
7250 max_response_body: 10 * 1024 * 1024,
7251 max_inflight_requests: 1024,
7252 method: None,
7253 tls_config: None,
7254 };
7255 let consumer = HttpConsumer::new(config, test_rt());
7256 assert_eq!(
7257 consumer.concurrency_model(),
7258 ConcurrencyModel::Concurrent { max: None }
7259 );
7260 }
7261
7262 #[test]
7263 fn server_config_parses_tls_cert_and_key() {
7264 let cfg = HttpServerConfig::from_uri(
7265 "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
7266 )
7267 .unwrap();
7268 assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
7269 assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
7270 }
7271
7272 #[test]
7273 fn server_config_no_tls_when_params_absent() {
7274 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
7275 assert!(cfg.tls_config.is_none());
7276 }
7277
7278 #[tokio::test]
7283 async fn test_http_reply_body_stream_variant_exists() {
7284 use bytes::Bytes;
7285 use camel_component_api::CamelError;
7286 use futures::stream;
7287
7288 let chunks: Vec<Result<Bytes, CamelError>> =
7289 vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
7290 let stream = Box::pin(stream::iter(chunks));
7291 let reply_body = HttpReplyBody::Stream(stream);
7292 match reply_body {
7294 HttpReplyBody::Stream(_) => {}
7295 HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
7296 }
7297 }
7298
7299 #[cfg(feature = "otel")]
7304 mod otel_tests {
7305 use super::*;
7306 use camel_component_api::Message;
7307 use tower::ServiceExt;
7308
7309 #[tokio::test]
7310 async fn test_producer_injects_traceparent_header() {
7311 let (url, _handle) = start_test_server_with_header_capture().await;
7312 let ctx = test_producer_ctx();
7313
7314 let component = HttpComponent::new();
7315 let endpoint_ctx = NoOpComponentContext;
7316 let endpoint = component
7317 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
7318 .unwrap();
7319 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7320
7321 let mut exchange = Exchange::new(Message::default());
7323 let mut headers = std::collections::HashMap::new();
7324 headers.insert(
7325 "traceparent".to_string(),
7326 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
7327 );
7328 camel_otel::extract_into_exchange(&mut exchange, &headers);
7329
7330 let result = producer.oneshot(exchange).await.unwrap();
7331
7332 let status = result
7334 .input
7335 .header("CamelHttpResponseCode")
7336 .and_then(|v| v.as_u64())
7337 .unwrap();
7338 assert_eq!(status, 200);
7339
7340 let traceparent = result.input.header("X-Received-Traceparent");
7342 assert!(
7343 traceparent.is_some(),
7344 "traceparent header should have been sent"
7345 );
7346
7347 let traceparent_str = traceparent.unwrap().as_str().unwrap();
7348 let parts: Vec<&str> = traceparent_str.split('-').collect();
7350 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
7351 assert_eq!(parts[0], "00", "version should be 00");
7352 assert_eq!(
7353 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
7354 "trace-id should match"
7355 );
7356 assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
7357 assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
7358 }
7359
7360 #[tokio::test]
7361 async fn test_consumer_extracts_traceparent_header() {
7362 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7363
7364 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7366 let port = listener.local_addr().unwrap().port();
7367 drop(listener);
7368
7369 let component = HttpComponent::new();
7370 let endpoint_ctx = NoOpComponentContext;
7371 let endpoint = component
7372 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
7373 .unwrap();
7374 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7375
7376 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7377 let token = tokio_util::sync::CancellationToken::new();
7378 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7379
7380 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7381 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7382
7383 let client = reqwest::Client::new();
7385 let send_fut = client
7386 .post(format!("http://127.0.0.1:{port}/trace"))
7387 .header(
7388 "traceparent",
7389 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
7390 )
7391 .body("test")
7392 .send();
7393
7394 let (http_result, _) = tokio::join!(send_fut, async {
7395 if let Some(envelope) = rx.recv().await {
7396 let mut injected_headers = std::collections::HashMap::new();
7399 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
7400
7401 assert!(
7402 injected_headers.contains_key("traceparent"),
7403 "Exchange should have traceparent after extraction"
7404 );
7405
7406 let traceparent = injected_headers.get("traceparent").unwrap();
7407 let parts: Vec<&str> = traceparent.split('-').collect();
7408 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
7409 assert_eq!(
7410 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
7411 "Trace ID should match the original traceparent header"
7412 );
7413
7414 if let Some(reply_tx) = envelope.reply_tx {
7415 let _ = reply_tx.send(Ok(envelope.exchange));
7416 }
7417 }
7418 });
7419
7420 let resp = http_result.unwrap();
7421 assert_eq!(resp.status().as_u16(), 200);
7422
7423 token.cancel();
7424 }
7425
7426 #[tokio::test]
7427 async fn test_consumer_extracts_mixed_case_traceparent_header() {
7428 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7429
7430 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7432 let port = listener.local_addr().unwrap().port();
7433 drop(listener);
7434
7435 let component = HttpComponent::new();
7436 let endpoint_ctx = NoOpComponentContext;
7437 let endpoint = component
7438 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
7439 .unwrap();
7440 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7441
7442 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7443 let token = tokio_util::sync::CancellationToken::new();
7444 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7445
7446 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7447 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7448
7449 let client = reqwest::Client::new();
7451 let send_fut = client
7452 .post(format!("http://127.0.0.1:{port}/trace"))
7453 .header(
7454 "TraceParent",
7455 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
7456 )
7457 .body("test")
7458 .send();
7459
7460 let (http_result, _) = tokio::join!(send_fut, async {
7461 if let Some(envelope) = rx.recv().await {
7462 let mut injected_headers = HashMap::new();
7465 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
7466
7467 assert!(
7468 injected_headers.contains_key("traceparent"),
7469 "Exchange should have traceparent after extraction from mixed-case header"
7470 );
7471
7472 let traceparent = injected_headers.get("traceparent").unwrap();
7473 let parts: Vec<&str> = traceparent.split('-').collect();
7474 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
7475 assert_eq!(
7476 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
7477 "Trace ID should match the original mixed-case TraceParent header"
7478 );
7479
7480 if let Some(reply_tx) = envelope.reply_tx {
7481 let _ = reply_tx.send(Ok(envelope.exchange));
7482 }
7483 }
7484 });
7485
7486 let resp = http_result.unwrap();
7487 assert_eq!(resp.status().as_u16(), 200);
7488
7489 token.cancel();
7490 }
7491
7492 #[tokio::test]
7493 async fn test_producer_no_trace_context_no_crash() {
7494 let (url, _handle) = start_test_server().await;
7495 let ctx = test_producer_ctx();
7496
7497 let component = HttpComponent::new();
7498 let endpoint_ctx = NoOpComponentContext;
7499 let endpoint = component
7500 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
7501 .unwrap();
7502 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7503
7504 let exchange = Exchange::new(Message::default());
7506
7507 let result = producer.oneshot(exchange).await.unwrap();
7509
7510 let status = result
7512 .input
7513 .header("CamelHttpResponseCode")
7514 .and_then(|v| v.as_u64())
7515 .unwrap();
7516 assert_eq!(status, 200);
7517 }
7518
7519 async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
7521 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7522 let addr = listener.local_addr().unwrap();
7523 let url = format!("http://127.0.0.1:{}", addr.port());
7524
7525 let handle = tokio::spawn(async move {
7526 loop {
7527 if let Ok((mut stream, _)) = listener.accept().await {
7528 tokio::spawn(async move {
7529 use tokio::io::{AsyncReadExt, AsyncWriteExt};
7530 let mut buf = vec![0u8; 8192];
7531 let n = stream.read(&mut buf).await.unwrap_or(0);
7532 let request = String::from_utf8_lossy(&buf[..n]).to_string();
7533
7534 let traceparent = request
7536 .lines()
7537 .find(|line| line.to_lowercase().starts_with("traceparent:"))
7538 .map(|line| {
7539 line.split(':')
7540 .nth(1)
7541 .map(|s| s.trim().to_string())
7542 .unwrap_or_default()
7543 })
7544 .unwrap_or_default();
7545
7546 let body =
7547 format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
7548 let response = format!(
7549 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
7550 body.len(),
7551 traceparent,
7552 body
7553 );
7554 let _ = stream.write_all(response.as_bytes()).await;
7555 });
7556 }
7557 }
7558 });
7559
7560 (url, handle)
7561 }
7562 }
7563
7564 #[tokio::test]
7573 async fn test_request_body_arrives_as_stream() {
7574 use camel_component_api::Body;
7575 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7576
7577 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7578 let port = listener.local_addr().unwrap().port();
7579 drop(listener);
7580
7581 let component = HttpComponent::new();
7582 let endpoint_ctx = NoOpComponentContext;
7583 let endpoint = component
7584 .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
7585 .unwrap();
7586 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7587
7588 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7589 let token = tokio_util::sync::CancellationToken::new();
7590 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7591
7592 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7593 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7594
7595 let client = reqwest::Client::new();
7596 let send_fut = client
7597 .post(format!("http://127.0.0.1:{port}/upload"))
7598 .body("hello streaming world")
7599 .send();
7600
7601 let (http_result, _) = tokio::join!(send_fut, async {
7602 if let Some(mut envelope) = rx.recv().await {
7603 assert!(
7605 matches!(envelope.exchange.input.body, Body::Stream(_)),
7606 "expected Body::Stream, got discriminant {:?}",
7607 std::mem::discriminant(&envelope.exchange.input.body)
7608 );
7609 let bytes = envelope
7611 .exchange
7612 .input
7613 .body
7614 .into_bytes(1024 * 1024)
7615 .await
7616 .unwrap();
7617 assert_eq!(&bytes[..], b"hello streaming world");
7618
7619 envelope.exchange.input.body = camel_component_api::Body::Empty;
7620 if let Some(reply_tx) = envelope.reply_tx {
7621 let _ = reply_tx.send(Ok(envelope.exchange));
7622 }
7623 }
7624 });
7625
7626 let resp = http_result.unwrap();
7627 assert_eq!(resp.status().as_u16(), 200);
7628
7629 token.cancel();
7630 }
7631
7632 #[tokio::test]
7637 async fn test_streaming_response_chunked() {
7638 use bytes::Bytes;
7639 use camel_component_api::Body;
7640 use camel_component_api::CamelError;
7641 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7642 use camel_component_api::{StreamBody, StreamMetadata};
7643 use futures::stream;
7644 use std::sync::Arc;
7645 use tokio::sync::Mutex;
7646
7647 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7648 let port = listener.local_addr().unwrap().port();
7649 drop(listener);
7650
7651 let component = HttpComponent::new();
7652 let endpoint_ctx = NoOpComponentContext;
7653 let endpoint = component
7654 .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
7655 .unwrap();
7656 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7657
7658 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7659 let token = tokio_util::sync::CancellationToken::new();
7660 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7661
7662 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7663 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7664
7665 let client = reqwest::Client::new();
7666 let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
7667
7668 let (http_result, _) = tokio::join!(send_fut, async {
7669 if let Some(mut envelope) = rx.recv().await {
7670 let chunks: Vec<Result<Bytes, CamelError>> =
7672 vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
7673 let stream = Box::pin(stream::iter(chunks));
7674 envelope.exchange.input.body = Body::Stream(StreamBody {
7675 stream: Arc::new(Mutex::new(Some(stream))),
7676 metadata: StreamMetadata::default(),
7677 });
7678 if let Some(reply_tx) = envelope.reply_tx {
7679 let _ = reply_tx.send(Ok(envelope.exchange));
7680 }
7681 }
7682 });
7683
7684 let resp = http_result.unwrap();
7685 assert_eq!(resp.status().as_u16(), 200);
7686 let body = resp.text().await.unwrap();
7687 assert_eq!(body, "chunk1chunk2");
7688
7689 token.cancel();
7690 }
7691
7692 #[tokio::test]
7697 async fn test_413_when_content_length_exceeds_limit() {
7698 use camel_component_api::ConsumerContext;
7699
7700 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7701 let port = listener.local_addr().unwrap().port();
7702 drop(listener);
7703
7704 let component = HttpComponent::new();
7706 let endpoint_ctx = NoOpComponentContext;
7707 let endpoint = component
7708 .create_endpoint(
7709 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
7710 &endpoint_ctx,
7711 )
7712 .unwrap();
7713 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7714
7715 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7716 let token = tokio_util::sync::CancellationToken::new();
7717 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7718
7719 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7720 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7721
7722 let client = reqwest::Client::new();
7723 let resp = client
7724 .post(format!("http://127.0.0.1:{port}/upload"))
7725 .header("Content-Length", "1000") .body("x".repeat(1000))
7727 .send()
7728 .await
7729 .unwrap();
7730
7731 assert_eq!(resp.status().as_u16(), 413);
7732
7733 token.cancel();
7734 }
7735
7736 #[tokio::test]
7740 async fn test_chunked_upload_without_content_length_bypasses_limit() {
7741 use bytes::Bytes;
7742 use camel_component_api::Body;
7743 use camel_component_api::ConsumerContext;
7744 use futures::stream;
7745
7746 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7747 let port = listener.local_addr().unwrap().port();
7748 drop(listener);
7749
7750 let component = HttpComponent::new();
7752 let endpoint_ctx = NoOpComponentContext;
7753 let endpoint = component
7754 .create_endpoint(
7755 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
7756 &endpoint_ctx,
7757 )
7758 .unwrap();
7759 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7760
7761 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7762 let token = tokio_util::sync::CancellationToken::new();
7763 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7764
7765 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7766 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7767
7768 let client = reqwest::Client::new();
7769
7770 let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
7774 Ok(Bytes::from("y".repeat(50))),
7775 Ok(Bytes::from("y".repeat(50))),
7776 ];
7777 let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
7778 let send_fut = client
7779 .post(format!("http://127.0.0.1:{port}/upload"))
7780 .body(stream_body)
7781 .send();
7782
7783 let consumer_fut = async {
7784 match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
7786 Ok(Some(mut envelope)) => {
7787 assert!(
7788 matches!(envelope.exchange.input.body, Body::Stream(_)),
7789 "expected Body::Stream"
7790 );
7791 envelope.exchange.input.body = camel_component_api::Body::Empty;
7792 if let Some(reply_tx) = envelope.reply_tx {
7793 let _ = reply_tx.send(Ok(envelope.exchange));
7794 }
7795 }
7796 Ok(None) => panic!("consumer channel closed unexpectedly"),
7797 Err(_) => {
7798 }
7801 }
7802 };
7803
7804 let (http_result, _) = tokio::join!(send_fut, consumer_fut);
7805
7806 let resp = http_result.unwrap();
7807 assert_ne!(
7814 resp.status().as_u16(),
7815 413,
7816 "chunked upload has no Content-Length to pre-check"
7817 );
7818 assert_eq!(resp.status().as_u16(), 200);
7819
7820 token.cancel();
7821 }
7822
7823 #[test]
7824 fn test_is_private_ip_ranges() {
7825 use camel_api::is_ssrf_blocked_ip;
7826 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(
7845 &"2001:4860:4860::8888".parse().unwrap()
7846 )); }
7848
7849 #[test]
7850 fn test_title_case_header() {
7851 assert_eq!(title_case_header("content-type"), "Content-Type");
7852 assert_eq!(title_case_header("authorization"), "Authorization");
7853 assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
7854 assert_eq!(title_case_header("host"), "Host");
7855 assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
7856 assert_eq!(title_case_header("single"), "Single");
7857 assert_eq!(title_case_header(""), "");
7858 }
7859
7860 #[test]
7861 fn test_resolve_url_combines_path_and_query_sources() {
7862 let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
7863 let mut exchange = Exchange::new(Message::default());
7864 exchange.input.set_header(
7865 "CamelHttpPath",
7866 serde_json::Value::String("next".to_string()),
7867 );
7868 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7869 assert!(url.starts_with("http://example.com/base/next?"));
7870 assert!(url.contains("foo=bar"));
7871
7872 exchange.input.set_header(
7873 "CamelHttpUri",
7874 serde_json::Value::String("http://other.test/root".to_string()),
7875 );
7876 exchange.input.set_header(
7877 "CamelHttpQuery",
7878 serde_json::Value::String("a=1&b=2".to_string()),
7879 );
7880
7881 let override_url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7882 assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
7883 }
7884
7885 fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
7886 let mut exchange = Exchange::new(Message::default());
7887 exchange
7888 .input
7889 .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
7890 exchange.input.set_header(
7891 "CamelHttpQuery",
7892 serde_json::Value::String(query.to_string()),
7893 );
7894 exchange
7895 }
7896
7897 #[test]
7898 fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
7899 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
7900 cfg.bridge_endpoint = true;
7901 cfg.query_params
7902 .push(("token".to_string(), "secret".to_string()));
7903 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
7904 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7905 assert_eq!(url, "http://x?token=secret");
7909 assert!(!url.contains("/foo"));
7910 assert!(!url.contains("dropme"));
7911 }
7912
7913 #[test]
7914 fn resolve_url_bridge_endpoint_false_merges_path() {
7915 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
7916 cfg.bridge_endpoint = false;
7917 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
7918 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7919 assert!(url.contains("/foo"), "url should contain /foo: {url}");
7920 assert!(
7921 url.contains("dropme=1"),
7922 "url should contain dropme=1: {url}"
7923 );
7924 }
7925
7926 #[test]
7927 fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
7928 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
7929 cfg.bridge_endpoint = true;
7930 let mut exchange = Exchange::new(Message::default());
7931 exchange.input.set_header(
7932 "CamelHttpPath",
7933 serde_json::Value::String("/foo".to_string()),
7934 );
7935 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7936 assert_eq!(url, "http://x");
7937 assert!(!url.contains("/foo"));
7938 }
7939
7940 #[test]
7941 fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
7942 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
7943 cfg.bridge_endpoint = true;
7944 let mut exchange = Exchange::new(Message::default());
7946 exchange.input.set_header(
7947 "CamelHttpUri",
7948 serde_json::Value::String("http://dest/explicit".to_string()),
7949 );
7950 exchange.input.set_header(
7951 "CamelHttpPath",
7952 serde_json::Value::String("/foo".to_string()),
7953 );
7954 exchange.input.set_header(
7955 "CamelHttpQuery",
7956 serde_json::Value::String("x=1".to_string()),
7957 );
7958 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7959 assert_eq!(url, "http://x");
7963 }
7964
7965 #[test]
7966 fn bridge_programmatic_params_use_percent20() {
7967 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
7968 cfg.bridge_endpoint = true;
7969 cfg.query_params = vec![("b".to_string(), "x y".to_string())];
7970 let exchange = Exchange::new(Message::default());
7971
7972 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7973
7974 assert_eq!(url, "http://x?b=x%20y");
7978 assert!(!url.contains('+'));
7979 }
7980
7981 #[test]
7982 fn bridge_arm_carries_authored_raw_query() {
7983 let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
7984 let exchange = exchange_with_path_and_query("/ignored", "dropme=1");
7987
7988 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7989
7990 assert_eq!(url, "http://h/p?a=1");
7993 assert!(!url.contains("dropme"), "exchange query leaked: {url}");
7994 assert!(!url.contains("/ignored"), "exchange path leaked: {url}");
7995 }
7996
7997 #[test]
8004 fn resolve_url_bridge_preserves_dot_segments() {
8005 let mut cfg = HttpEndpointConfig::from_uri("http://h/a/../b").unwrap();
8006 cfg.bridge_endpoint = true;
8007 cfg.query_params.push(("k".to_string(), "1".to_string()));
8008 let exchange = Exchange::new(Message::default());
8009
8010 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8011
8012 assert_eq!(url, "http://h/a/../b?k=1");
8015 }
8016
8017 #[test]
8018 fn resolve_url_bridge_preserves_default_port() {
8019 let mut cfg = HttpEndpointConfig::from_uri("http://h:80/p").unwrap();
8020 cfg.bridge_endpoint = true;
8021 cfg.query_params.push(("k".to_string(), "1".to_string()));
8022 let exchange = Exchange::new(Message::default());
8023
8024 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8025
8026 assert_eq!(url, "http://h:80/p?k=1");
8029 }
8030
8031 #[test]
8032 fn resolve_url_bridge_preserves_scheme_and_host_case() {
8033 let mut cfg = HttpEndpointConfig::from_uri("http://ExAMPLE.COM/p").unwrap();
8034 cfg.bridge_endpoint = true;
8035 cfg.query_params.push(("k".to_string(), "1".to_string()));
8036 cfg.base_url = "HTTP://ExAMPLE.COM/p".to_string();
8040 let exchange = Exchange::new(Message::default());
8041
8042 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8043
8044 assert_eq!(url, "HTTP://ExAMPLE.COM/p?k=1");
8047 }
8048
8049 #[test]
8050 fn resolve_url_bridge_no_query_emits_base_verbatim() {
8051 let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8052 cfg.bridge_endpoint = true;
8053 let exchange = Exchange::new(Message::default());
8054
8055 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8056
8057 assert_eq!(url, "http://h/p");
8060 }
8061
8062 #[test]
8063 fn resolve_url_bridge_and_non_bridge_byte_identical() {
8064 let mut bridged = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
8067 bridged.bridge_endpoint = true;
8068 bridged
8069 .query_params
8070 .push(("k".to_string(), "1".to_string()));
8071 let bridge_url =
8072 HttpProducer::resolve_url(&Exchange::new(Message::default()), &bridged).unwrap();
8073
8074 let plain = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
8077 let mut exchange = Exchange::new(Message::default());
8078 exchange.input.set_header(
8079 "CamelHttpQuery",
8080 serde_json::Value::String("k=1".to_string()),
8081 );
8082 let plain_url = HttpProducer::resolve_url(&exchange, &plain).unwrap();
8083
8084 assert_eq!(bridge_url, plain_url);
8085 assert_eq!(bridge_url, "http://H:80/a/../b?k=1");
8086 }
8087
8088 #[test]
8089 fn resolve_url_bridge_preserves_ipv6_authority_verbatim() {
8090 let mut cfg = HttpEndpointConfig::from_uri("http://[::1]:8080/p").unwrap();
8091 cfg.bridge_endpoint = true;
8092 cfg.query_params.push(("k".to_string(), "1".to_string()));
8093 let exchange = Exchange::new(Message::default());
8094
8095 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8096
8097 assert_eq!(url, "http://[::1]:8080/p?k=1");
8098 }
8099
8100 #[test]
8101 fn resolve_url_bridge_empty_base_path_keeps_no_synthetic_slash() {
8102 let cfg = HttpEndpointConfig::from_uri("http://h?x=1&bridgeEndpoint=true").unwrap();
8103 let exchange = Exchange::new(Message::default());
8104
8105 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8106
8107 assert_eq!(url, "http://h?x=1");
8110 }
8111
8112 #[test]
8117 fn resolve_url_preserves_authored_query_order_and_bytes() {
8118 let config =
8119 HttpEndpointConfig::from_uri("http://h/p?a=1&b=x,y&c=t:1&connectTimeout=5000").unwrap();
8120 let exchange = Exchange::new(Message::default());
8121
8122 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8123
8124 assert_eq!(url, "http://h/p?a=1&b=x,y&c=t:1");
8127 }
8128
8129 #[test]
8130 fn resolve_url_consumes_encoded_option_key() {
8131 let config = HttpEndpointConfig::from_uri("http://h/p?connect%54imeout=5000&a=1").unwrap();
8132 let exchange = Exchange::new(Message::default());
8133
8134 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8135
8136 assert_eq!(url, "http://h/p?a=1");
8138 }
8139
8140 #[test]
8141 fn resolve_url_all_options_consumed_drops_query() {
8142 let config = HttpEndpointConfig::from_uri("http://h/p?connectTimeout=5000").unwrap();
8143 let exchange = Exchange::new(Message::default());
8144
8145 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8146
8147 assert_eq!(url, "http://h/p");
8150 assert!(!url.contains('?'));
8151 }
8152
8153 #[test]
8154 fn resolve_url_preserves_empty_query_marker() {
8155 let config = HttpEndpointConfig::from_uri("http://h/p?").unwrap();
8156 let exchange = Exchange::new(Message::default());
8157
8158 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8159
8160 assert_eq!(url, "http://h/p?");
8163 }
8164
8165 #[test]
8166 fn resolve_url_raw_wrapper_not_re_encoded() {
8167 let config = HttpEndpointConfig::from_uri("http://h/p?token=RAW(abc)").unwrap();
8168 let exchange = Exchange::new(Message::default());
8169
8170 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8171
8172 assert_eq!(url, "http://h/p?token=RAW(abc)");
8174 assert!(!url.contains("%28"), "RAW( wrapper re-encoded: {url}");
8175 }
8176
8177 #[test]
8178 fn resolve_url_camel_http_query_composes_verbatim_span() {
8179 let config = HttpEndpointConfig::from_uri("http://h/p?x=1").unwrap();
8180 let mut exchange = Exchange::new(Message::default());
8181 exchange.input.set_header(
8182 "CamelHttpQuery",
8183 serde_json::Value::String("userFilter=a%2Cb".to_string()),
8184 );
8185
8186 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8187
8188 assert_eq!(url, "http://h/p?x=1&userFilter=a%2Cb");
8193 assert!(!url.contains("%252C"), "header bytes re-encoded: {url}");
8194 }
8195
8196 #[test]
8201 fn header_composes_with_endpoint_query() {
8202 let config =
8203 HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret&lang=en").unwrap();
8204 let mut exchange = Exchange::new(Message::default());
8205 exchange.input.set_header(
8206 "CamelHttpQuery",
8207 serde_json::Value::String("lang=es&page=2".to_string()),
8208 );
8209
8210 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8211
8212 assert_eq!(url, "http://upstream/api?apiKey=secret&lang=en&page=2");
8215 }
8216
8217 #[test]
8218 fn header_alone_still_rides() {
8219 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
8220 let mut exchange = Exchange::new(Message::default());
8221 exchange.input.set_header(
8222 "CamelHttpQuery",
8223 serde_json::Value::String("page=2".to_string()),
8224 );
8225
8226 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8227
8228 assert_eq!(url, "http://upstream/api?page=2");
8230 }
8231
8232 #[test]
8233 fn empty_reflected_query_leaves_endpoint_query_intact() {
8234 let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
8235 let mut exchange = Exchange::new(Message::default());
8236 exchange
8239 .input
8240 .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
8241
8242 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8243
8244 assert_eq!(url, "http://upstream/api?apiKey=secret");
8246 assert!(!url.ends_with('?'), "dangling '?' marker: {url}");
8247 }
8248
8249 #[test]
8250 fn forbidden_byte_in_header_query_errors() {
8251 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
8252 let mut exchange = Exchange::new(Message::default());
8253 exchange.input.set_header(
8254 "CamelHttpQuery",
8255 serde_json::Value::String("q=ab<cd".to_string()),
8256 );
8257
8258 let err = HttpProducer::resolve_url(&exchange, &config)
8259 .unwrap_err()
8260 .to_string();
8261
8262 assert!(err.contains("0x3C"), "error must name the byte: {err}");
8265 }
8266
8267 #[test]
8268 fn override_uri_with_query_plus_header_query() {
8269 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
8270 let mut exchange = Exchange::new(Message::default());
8271 exchange.input.set_header(
8272 "CamelHttpUri",
8273 serde_json::Value::String("http://host/api?a=1".to_string()),
8274 );
8275 exchange.input.set_header(
8276 "CamelHttpQuery",
8277 serde_json::Value::String("a=2&b=3".to_string()),
8278 );
8279
8280 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8281
8282 assert_eq!(url, "http://host/api?a=1&b=3");
8285 }
8286
8287 #[test]
8288 fn path_applies_before_query_composition() {
8289 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
8290 let mut exchange = Exchange::new(Message::default());
8291 exchange.input.set_header(
8292 "CamelHttpUri",
8293 serde_json::Value::String("http://host/api?a=1".to_string()),
8294 );
8295 exchange.input.set_header(
8296 "CamelHttpPath",
8297 serde_json::Value::String("/extra".to_string()),
8298 );
8299 exchange.input.set_header(
8300 "CamelHttpQuery",
8301 serde_json::Value::String("b=2".to_string()),
8302 );
8303
8304 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8305
8306 assert_eq!(url, "http://host/api/extra?a=1&b=2");
8309 }
8310
8311 #[test]
8312 fn plain_proxy_reflection_composes() {
8313 let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
8314 let exchange = exchange_with_path_and_query("/in/extra", "page=2");
8316
8317 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8318
8319 assert_eq!(url, "http://upstream/api/in/extra?apiKey=secret&page=2");
8322 }
8323
8324 #[test]
8325 fn bridge_endpoint_ignores_url_headers() {
8326 let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
8327 let mut exchange = Exchange::new(Message::default());
8328 exchange.input.set_header(
8329 "CamelHttpUri",
8330 serde_json::Value::String("http://evil.test/x".to_string()),
8331 );
8332 exchange.input.set_header(
8333 "CamelHttpPath",
8334 serde_json::Value::String("/foo".to_string()),
8335 );
8336 exchange.input.set_header(
8337 "CamelHttpQuery",
8338 serde_json::Value::String("z=9".to_string()),
8339 );
8340
8341 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8342
8343 assert_eq!(url, "http://h/p?a=1");
8346 assert!(!url.contains("evil"), "override leaked: {url}");
8347 assert!(!url.contains("z=9"), "header query leaked: {url}");
8348 assert!(!url.contains("/foo"), "header path leaked: {url}");
8349 }
8350
8351 #[test]
8352 fn resolve_url_programmatic_params_use_percent20_deterministic() {
8353 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8354 config.query_params = vec![
8355 ("b".to_string(), "x y".to_string()),
8356 ("a".to_string(), "1".to_string()),
8357 ];
8358 let exchange = Exchange::new(Message::default());
8359
8360 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8361
8362 assert_eq!(url, "http://h/p?b=x%20y&a=1");
8365 assert!(!url.contains('+'));
8366 }
8367
8368 #[test]
8369 fn resolve_url_authored_and_programmatic_merge() {
8370 let mut config = HttpEndpointConfig::from_uri("http://h/p?a=1&c=t:1").unwrap();
8371 config.query_params = vec![
8372 ("b".to_string(), "2".to_string()),
8373 ("a".to_string(), "9".to_string()),
8374 ];
8375 let exchange = Exchange::new(Message::default());
8376
8377 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8378
8379 assert_eq!(url, "http://h/p?a=1&c=t:1&b=2");
8382 }
8383
8384 #[test]
8385 fn from_uri_no_longer_fills_query_params_from_uri() {
8386 let config = HttpEndpointConfig::from_uri("http://h/p?a=1&connectTimeout=5000").unwrap();
8387
8388 assert!(
8390 config.query_params.is_empty(),
8391 "query_params is programmatic-only: {:?}",
8392 config.query_params
8393 );
8394 assert_eq!(config.raw_query.as_deref(), Some("a=1&connectTimeout=5000"));
8395 }
8396
8397 #[test]
8398 fn resolve_url_forbidden_raw_byte_errors() {
8399 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8400 config.raw_query = Some("a=x y".to_string());
8401 let exchange = Exchange::new(Message::default());
8402
8403 let err = HttpProducer::resolve_url(&exchange, &config)
8404 .expect_err("literal space in raw query must error");
8405
8406 assert!(
8408 err.to_string().contains("0x20"),
8409 "error must name the forbidden byte: {err}"
8410 );
8411 }
8412
8413 #[test]
8417 fn resolve_url_override_query_forbidden_byte_errors() {
8418 let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8419 let mut exchange = Exchange::new(Message::default());
8420 exchange.input.set_header(
8421 "CamelHttpUri",
8422 serde_json::Value::String("http://h2/p?a=x y".to_string()),
8423 );
8424
8425 let err = HttpProducer::resolve_url(&exchange, &config)
8426 .expect_err("literal space in the override URI's query must error");
8427
8428 assert!(
8429 err.to_string().contains("0x20"),
8430 "error must name the forbidden byte from the override query: {err}"
8431 );
8432 }
8433
8434 #[test]
8439 fn merge_header_query_decoded_key_collision_drops_header_pair() {
8440 let merged = merge_header_query(Some("a=1"), "%61=2")
8441 .expect("decoded-key collision must not be a parse error");
8442 assert_eq!(
8443 merged.as_deref(),
8444 Some("a=1"),
8445 "the higher-precedence span wins and the colliding header pair is dropped"
8446 );
8447 }
8448
8449 #[test]
8452 fn merge_header_query_duplicate_keys_within_header_ride_verbatim() {
8453 let merged = merge_header_query(None, "k=1&k=2")
8454 .expect("duplicate header keys must not be a parse error");
8455 assert_eq!(
8456 merged.as_deref(),
8457 Some("k=1&k=2"),
8458 "intra-header duplicate keys ride verbatim"
8459 );
8460 }
8461
8462 #[test]
8465 fn endpoint_config_debug_masks_base_url_userinfo() {
8466 let mut config = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
8467 config.base_url = "http://user:pass@h.example/p".to_string();
8468 let rendered = format!("{config:?}");
8469 assert!(
8470 rendered.contains("***@h.example"),
8471 "userinfo must render masked: {rendered}"
8472 );
8473 assert!(
8474 !rendered.contains("user:pass"),
8475 "no credentials in Debug output: {rendered}"
8476 );
8477
8478 let plain = HttpEndpointConfig::from_uri("http://h.example/p").unwrap();
8479 let rendered_plain = format!("{plain:?}");
8480 assert!(
8481 rendered_plain.contains("http://h.example/p"),
8482 "a base without userinfo renders unchanged: {rendered_plain}"
8483 );
8484 }
8485
8486 #[test]
8492 fn resolve_url_authored_apostrophe_rejected_percent_escape_rides() {
8493 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8494
8495 config.raw_query = Some("q=it's".to_string());
8496 let exchange = Exchange::new(Message::default());
8497 let err = HttpProducer::resolve_url(&exchange, &config)
8498 .expect_err("authored apostrophe must be rejected, not silently %27-normalized");
8499 assert!(
8500 err.to_string().contains("0x27"),
8501 "error must name the apostrophe byte: {err}"
8502 );
8503
8504 config.raw_query = Some("q=it%27s".to_string());
8505 let url = HttpProducer::resolve_url(&exchange, &config)
8506 .expect("authored %27 escape is wire-legal");
8507 assert!(
8508 url.contains("q=it%27s"),
8509 "the authored escape must ride byte-for-byte: {url}"
8510 );
8511
8512 for &byte in b"\"`<>" {
8516 config.raw_query = Some(format!("k={}x", byte as char));
8517 let err = HttpProducer::resolve_url(&exchange, &config)
8518 .expect_err("WHATWG special-query byte must be rejected");
8519 assert!(
8520 err.to_string().contains(&format!("0x{byte:02X}")),
8521 "error must name byte 0x{byte:02X}: {err}"
8522 );
8523 }
8524 }
8525
8526 #[test]
8527 fn armed_fence_rejects_unknown_host_redacted() {
8528 let cfg = HttpEndpointConfig::from_uri(
8529 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
8530 )
8531 .unwrap();
8532 let mut exchange = Exchange::new(Message::default());
8533 exchange.input.set_header(
8534 "CamelHttpUri",
8535 serde_json::Value::String(
8536 "http://user:pass@evil.example.com/x?token=s3cret".to_string(),
8537 ),
8538 );
8539
8540 let err = HttpProducer::resolve_url(&exchange, &cfg)
8541 .expect_err("override host outside the fence must fail resolution");
8542
8543 let message = err.to_string();
8544 assert!(!message.contains("pass"), "userinfo leaked: {message}");
8545 assert!(!message.contains("s3cret"), "query leaked: {message}");
8546 }
8547
8548 #[test]
8549 fn armed_fence_allows_listed_host() {
8550 let cfg = HttpEndpointConfig::from_uri(
8551 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
8552 )
8553 .unwrap();
8554 let mut exchange = Exchange::new(Message::default());
8555 exchange.input.set_header(
8556 "CamelHttpUri",
8557 serde_json::Value::String("http://cdn.example.com/x".to_string()),
8558 );
8559
8560 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8561 assert_eq!(url, "http://cdn.example.com/x");
8562 }
8563
8564 #[test]
8565 fn host_only_entry_permits_any_port() {
8566 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=cdn.example.com").unwrap();
8567 let mut exchange = Exchange::new(Message::default());
8568 exchange.input.set_header(
8569 "CamelHttpUri",
8570 serde_json::Value::String("http://cdn.example.com:9443/x".to_string()),
8571 );
8572
8573 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8574 assert_eq!(url, "http://cdn.example.com:9443/x");
8575 }
8576
8577 #[test]
8578 fn unarmed_endpoint_unchanged() {
8579 let cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8580 let mut exchange = Exchange::new(Message::default());
8581 exchange.input.set_header(
8582 "CamelHttpUri",
8583 serde_json::Value::String("http://any.example.com/path".to_string()),
8584 );
8585
8586 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8587 assert_eq!(url, "http://any.example.com/path");
8588 }
8589
8590 #[test]
8591 fn empty_allowlist_fails_endpoint_creation() {
8592 assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=,,").is_err());
8593 }
8594
8595 #[test]
8596 fn malformed_entry_fails_endpoint_creation() {
8597 assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=not a host!").is_err());
8598 }
8599
8600 #[test]
8601 fn fence_entry_with_path_fails_creation() {
8602 assert!(
8605 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=api.internal:8443/v2").is_err()
8606 );
8607 }
8608
8609 #[test]
8610 fn fence_entry_with_userinfo_fails_creation() {
8611 assert!(
8612 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=user@cdn.example.com").is_err()
8613 );
8614 }
8615
8616 #[test]
8617 fn ipv6_fence_entry_allows_bracketed_host() {
8618 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=[::1]:8443").unwrap();
8619 for uri in ["http://[::1]:8443/x", "http://[0:0:0:0:0:0:0:1]:8443/x"] {
8622 let mut exchange = Exchange::new(Message::default());
8623 exchange
8624 .input
8625 .set_header("CamelHttpUri", serde_json::Value::String(uri.to_string()));
8626 let url = HttpProducer::resolve_url(&exchange, &cfg)
8627 .unwrap_or_else(|e| panic!("override {uri} must be honored: {e}"));
8628 assert_eq!(url, uri, "bracketed IPv6 override not honored");
8629 }
8630 }
8631
8632 #[test]
8633 fn dns_case_insensitive_fence_match() {
8634 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=CDN.Example.COM").unwrap();
8637 let mut exchange = Exchange::new(Message::default());
8638 exchange.input.set_header(
8639 "CamelHttpUri",
8640 serde_json::Value::String("http://cdn.example.com/x".to_string()),
8641 );
8642 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8643 assert_eq!(url, "http://cdn.example.com/x");
8644 }
8645
8646 #[test]
8647 fn fence_allowed_override_query_merges_with_header() {
8648 let cfg =
8651 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example&k=v").unwrap();
8652 let mut exchange = Exchange::new(Message::default());
8653 exchange.input.set_header(
8654 "CamelHttpUri",
8655 serde_json::Value::String("http://host.example/api?a=1".to_string()),
8656 );
8657 exchange.input.set_header(
8658 "CamelHttpQuery",
8659 serde_json::Value::String("b=2".to_string()),
8660 );
8661
8662 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8663 assert_eq!(url, "http://host.example/api?a=1&b=2");
8664 }
8665
8666 #[test]
8667 fn empty_header_with_armed_fence_leaves_no_query() {
8668 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example").unwrap();
8669 let mut exchange = Exchange::new(Message::default());
8670 exchange.input.set_header(
8671 "CamelHttpUri",
8672 serde_json::Value::String("http://host.example/api".to_string()),
8673 );
8674 exchange
8675 .input
8676 .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
8677
8678 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8679 assert_eq!(url, "http://host.example/api");
8680 assert!(!url.contains('?'), "query marker leaked: {url}");
8681 }
8682
8683 #[test]
8684 fn fence_option_is_consumed() {
8685 let cfg =
8689 HttpEndpointConfig::from_uri("http://h/p?x=1&allowedUriHosts=cdn.example.com").unwrap();
8690 let exchange = Exchange::new(Message::default());
8691
8692 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8693 assert!(!url.contains("allowedUriHosts"), "option leaked: {url}");
8694 assert!(url.contains("x=1"), "authored query lost: {url}");
8695 }
8696
8697 #[tokio::test]
8698 async fn resolve_url_malformed_base_url_errors_no_panic() {
8699 use tower::ServiceExt;
8700
8701 let (url, _handle) = start_test_server().await;
8702 let mut config = HttpEndpointConfig::from_uri("http://[::1:bad").unwrap();
8703 config.allow_internal = true; let producer = HttpProducer {
8705 config: Arc::new(config),
8706 client: build_client(&HttpConfig::default(), None),
8707 pinned_cache: Arc::new(PinnedClientCache::new(
8708 PINNED_CLIENT_TTL,
8709 PINNED_CLIENT_MAX_ENTRIES,
8710 )),
8711 http_config: Arc::new(HttpConfig::default()),
8712 runtime: rt(),
8713 };
8714
8715 let first = producer
8718 .clone()
8719 .oneshot(Exchange::new(Message::default()))
8720 .await;
8721 let err = first.expect_err("malformed base URL must error, not panic");
8722 assert!(
8723 err.to_string().to_lowercase().contains("url"),
8724 "error must name the malformed URL: {err}"
8725 );
8726
8727 let mut exchange = Exchange::new(Message::default());
8730 exchange.input.set_header(
8731 "CamelHttpUri",
8732 serde_json::Value::String(format!("{url}/api")),
8733 );
8734 let response = producer
8735 .oneshot(exchange)
8736 .await
8737 .expect("valid request through same producer must succeed");
8738 let status = response
8739 .input
8740 .header("CamelHttpResponseCode")
8741 .and_then(|v| v.as_u64())
8742 .unwrap();
8743 assert_eq!(status, 200);
8744 }
8745
8746 #[test]
8747 fn resolve_url_bridge_malformed_base_errors_no_panic() {
8748 let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8749 cfg.bridge_endpoint = true;
8750 cfg.query_params.push(("k".to_string(), "1".to_string()));
8751 cfg.base_url = "http://[::1:bad".to_string();
8756 let exchange = Exchange::new(Message::default());
8757
8758 let err = HttpProducer::resolve_url(&exchange, &cfg)
8759 .expect_err("malformed bridge base URL must error");
8760 assert!(
8761 err.to_string().contains("invalid base URL"),
8762 "error must name the invalid base URL: {err}"
8763 );
8764 }
8765
8766 #[test]
8767 fn test_http_producer_helpers_status_and_size_boundaries() {
8768 assert!(HttpProducer::is_ok_status(200, (200, 299)));
8769 assert!(HttpProducer::is_ok_status(299, (200, 299)));
8770 assert!(!HttpProducer::is_ok_status(199, (200, 299)));
8771 assert!(!HttpProducer::is_ok_status(300, (200, 299)));
8772
8773 assert!(!exceeds_max_response_body(10, 10));
8774 assert!(exceeds_max_response_body(11, 10));
8775 }
8776
8777 async fn setup_consumer_on_free_port(
8782 path: &str,
8783 ) -> (
8784 u16,
8785 tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
8786 tokio_util::sync::CancellationToken,
8787 ) {
8788 use camel_component_api::ConsumerContext;
8789
8790 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8795 let port = listener.local_addr().unwrap().port();
8796 ServerRegistry::global()
8797 .stage_listener(listener)
8798 .await
8799 .expect("stage consumer test listener");
8800
8801 let consumer_cfg = HttpServerConfig {
8802 scheme: "http".to_string(),
8803 host: "127.0.0.1".to_string(),
8804 port,
8805 path: path.to_string(),
8806 max_request_body: 2 * 1024 * 1024,
8807 max_response_body: 10 * 1024 * 1024,
8808 max_inflight_requests: 1024,
8809 method: None,
8810 tls_config: None,
8811 };
8812 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8813
8814 let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8815 let token = tokio_util::sync::CancellationToken::new();
8816 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8817
8818 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8819
8820 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
8826 while ServerRegistry::global()
8827 .bound_addr("127.0.0.1", port)
8828 .is_none()
8829 {
8830 assert!(
8831 tokio::time::Instant::now() < deadline,
8832 "consumer server did not become ready on port {port}"
8833 );
8834 tokio::time::sleep(std::time::Duration::from_millis(1)).await;
8835 }
8836 for _ in 0..8 {
8837 tokio::task::yield_now().await;
8838 }
8839
8840 (port, rx, token)
8841 }
8842
8843 #[tokio::test]
8844 async fn test_content_type_inferred_for_json_body() {
8845 let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
8846
8847 let client = reqwest::Client::new();
8848 let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
8849
8850 let (http_result, _) = tokio::join!(send_fut, async {
8851 if let Some(mut envelope) = rx.recv().await {
8852 envelope.exchange.input.body =
8853 camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
8854 if let Some(reply_tx) = envelope.reply_tx {
8855 let _ = reply_tx.send(Ok(envelope.exchange));
8856 }
8857 }
8858 });
8859
8860 let resp = http_result.unwrap();
8861 assert_eq!(resp.status().as_u16(), 200);
8862 let ct = resp
8863 .headers()
8864 .get("content-type")
8865 .expect("Content-Type header should be present");
8866 assert_eq!(ct, "application/json");
8867 let body = resp.text().await.unwrap();
8868 assert_eq!(body, r#"{"message":"hello"}"#);
8869
8870 token.cancel();
8871 }
8872
8873 #[tokio::test]
8874 async fn test_content_type_inferred_for_text_body() {
8875 let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
8876
8877 let client = reqwest::Client::new();
8878 let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
8879
8880 let (http_result, _) = tokio::join!(send_fut, async {
8881 if let Some(mut envelope) = rx.recv().await {
8882 envelope.exchange.input.body =
8883 camel_component_api::Body::Text("plain text response".to_string());
8884 if let Some(reply_tx) = envelope.reply_tx {
8885 let _ = reply_tx.send(Ok(envelope.exchange));
8886 }
8887 }
8888 });
8889
8890 let resp = http_result.unwrap();
8891 assert_eq!(resp.status().as_u16(), 200);
8892 let ct = resp
8893 .headers()
8894 .get("content-type")
8895 .expect("Content-Type header should be present");
8896 assert_eq!(ct, "text/plain; charset=utf-8");
8897 let body = resp.text().await.unwrap();
8898 assert_eq!(body, "plain text response");
8899
8900 token.cancel();
8901 }
8902
8903 #[tokio::test]
8904 async fn test_content_type_inferred_for_xml_body() {
8905 let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
8906
8907 let client = reqwest::Client::new();
8908 let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
8909
8910 let (http_result, _) = tokio::join!(send_fut, async {
8911 if let Some(mut envelope) = rx.recv().await {
8912 envelope.exchange.input.body =
8913 camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
8914 if let Some(reply_tx) = envelope.reply_tx {
8915 let _ = reply_tx.send(Ok(envelope.exchange));
8916 }
8917 }
8918 });
8919
8920 let resp = http_result.unwrap();
8921 assert_eq!(resp.status().as_u16(), 200);
8922 let ct = resp
8923 .headers()
8924 .get("content-type")
8925 .expect("Content-Type header should be present");
8926 assert_eq!(ct, "application/xml");
8927 let body = resp.text().await.unwrap();
8928 assert_eq!(body, "<root><item>value</item></root>");
8929
8930 token.cancel();
8931 }
8932
8933 #[tokio::test]
8934 async fn test_no_content_type_for_empty_body() {
8935 let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
8936
8937 let client = reqwest::Client::new();
8938 let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
8939
8940 let (http_result, _) = tokio::join!(send_fut, async {
8941 if let Some(mut envelope) = rx.recv().await {
8942 envelope.exchange.input.body = camel_component_api::Body::Empty;
8943 if let Some(reply_tx) = envelope.reply_tx {
8944 let _ = reply_tx.send(Ok(envelope.exchange));
8945 }
8946 }
8947 });
8948
8949 let resp = http_result.unwrap();
8950 assert_eq!(resp.status().as_u16(), 200);
8951 assert!(
8952 resp.headers().get("content-type").is_none(),
8953 "Empty body should not set Content-Type"
8954 );
8955
8956 token.cancel();
8957 }
8958
8959 #[tokio::test]
8960 async fn test_no_content_type_for_raw_bytes_body() {
8961 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
8962
8963 let client = reqwest::Client::new();
8964 let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
8965
8966 let (http_result, _) = tokio::join!(send_fut, async {
8967 if let Some(mut envelope) = rx.recv().await {
8968 envelope.exchange.input.body =
8969 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
8970 if let Some(reply_tx) = envelope.reply_tx {
8971 let _ = reply_tx.send(Ok(envelope.exchange));
8972 }
8973 }
8974 });
8975
8976 let resp = http_result.unwrap();
8977 assert_eq!(resp.status().as_u16(), 200);
8978 assert!(
8979 resp.headers().get("content-type").is_none(),
8980 "Raw Bytes body should not set Content-Type"
8981 );
8982
8983 token.cancel();
8984 }
8985
8986 #[tokio::test]
8987 async fn test_content_type_from_stream_metadata() {
8988 use camel_component_api::{StreamBody, StreamMetadata};
8989 use futures::stream;
8990
8991 let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
8992
8993 let client = reqwest::Client::new();
8994 let send_fut = client
8995 .get(format!("http://127.0.0.1:{port}/stream-ct"))
8996 .send();
8997
8998 let (http_result, _) = tokio::join!(send_fut, async {
8999 if let Some(mut envelope) = rx.recv().await {
9000 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
9001 vec![Ok(bytes::Bytes::from("audio data"))];
9002 let stream = Box::pin(stream::iter(chunks));
9003 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
9004 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
9005 metadata: StreamMetadata {
9006 size_hint: None,
9007 content_type: Some("audio/mpeg".to_string()),
9008 origin: None,
9009 },
9010 });
9011 if let Some(reply_tx) = envelope.reply_tx {
9012 let _ = reply_tx.send(Ok(envelope.exchange));
9013 }
9014 }
9015 });
9016
9017 let resp = http_result.unwrap();
9018 assert_eq!(resp.status().as_u16(), 200);
9019 let ct = resp
9020 .headers()
9021 .get("content-type")
9022 .expect("Content-Type header should be present");
9023 assert_eq!(ct, "audio/mpeg");
9024 let body = resp.text().await.unwrap();
9025 assert_eq!(body, "audio data");
9026
9027 token.cancel();
9028 }
9029
9030 #[tokio::test]
9031 async fn test_user_content_type_overrides_inferred() {
9032 let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
9033
9034 let client = reqwest::Client::new();
9035 let send_fut = client
9036 .get(format!("http://127.0.0.1:{port}/override-ct"))
9037 .send();
9038
9039 let (http_result, _) = tokio::join!(send_fut, async {
9040 if let Some(mut envelope) = rx.recv().await {
9041 envelope.exchange.input.body =
9042 camel_component_api::Body::Json(serde_json::json!({"ok": true}));
9043 envelope.exchange.input.set_header(
9044 "Content-Type",
9045 serde_json::Value::String("text/html".to_string()),
9046 );
9047 if let Some(reply_tx) = envelope.reply_tx {
9048 let _ = reply_tx.send(Ok(envelope.exchange));
9049 }
9050 }
9051 });
9052
9053 let resp = http_result.unwrap();
9054 assert_eq!(resp.status().as_u16(), 200);
9055 let ct = resp
9056 .headers()
9057 .get("content-type")
9058 .expect("Content-Type header should be present");
9059 assert_eq!(
9060 ct, "text/html",
9061 "User-set Content-Type should take precedence over inferred type"
9062 );
9063
9064 token.cancel();
9065 }
9066
9067 #[tokio::test]
9068 async fn test_user_content_type_with_bytes_body() {
9069 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
9070
9071 let client = reqwest::Client::new();
9072 let send_fut = client
9073 .get(format!("http://127.0.0.1:{port}/bytes-ct"))
9074 .send();
9075
9076 let (http_result, _) = tokio::join!(send_fut, async {
9077 if let Some(mut envelope) = rx.recv().await {
9078 envelope.exchange.input.body =
9079 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
9080 envelope.exchange.input.set_header(
9081 "Content-Type",
9082 serde_json::Value::String("application/json".to_string()),
9083 );
9084 if let Some(reply_tx) = envelope.reply_tx {
9085 let _ = reply_tx.send(Ok(envelope.exchange));
9086 }
9087 }
9088 });
9089
9090 let resp = http_result.unwrap();
9091 assert_eq!(resp.status().as_u16(), 200);
9092 let ct = resp
9093 .headers()
9094 .get("content-type")
9095 .expect("Content-Type header should be present for Bytes body with user header");
9096 assert_eq!(
9097 ct, "application/json",
9098 "User Content-Type should be sent for Bytes body"
9099 );
9100
9101 token.cancel();
9102 }
9103
9104 #[tokio::test]
9109 async fn monitor_task_silent_on_clean_exit() {
9110 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
9111 monitor_axum_task(
9113 handle,
9114 "127.0.0.1:0".to_string(),
9115 noop_rt(),
9116 "test-monitor".into(),
9117 )
9118 .await;
9119 }
9120
9121 #[tokio::test]
9122 async fn monitor_task_handles_panicked_task() {
9123 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
9124 panic!("simulated server crash");
9125 });
9126 monitor_axum_task(
9128 handle,
9129 "127.0.0.1:9999".to_string(),
9130 noop_rt(),
9131 "test-monitor".into(),
9132 )
9133 .await;
9134 }
9135
9136 #[test]
9141 fn http_auth_basic_debug_redacts_password() {
9142 let auth = HttpAuth::Basic {
9143 username: "admin".to_string(),
9144 password: "hunter2".to_string(),
9145 };
9146 let debug = format!("{:?}", auth);
9147 assert!(
9148 !debug.contains("hunter2"),
9149 "password must be redacted: {debug}"
9150 );
9151 assert!(debug.contains("admin"), "username should appear: {debug}");
9152 }
9153
9154 #[test]
9155 fn http_auth_bearer_debug_redacts_token() {
9156 let auth = HttpAuth::Bearer {
9157 token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
9158 };
9159 let debug = format!("{:?}", auth);
9160 assert!(
9161 !debug.contains("eyJhbGci"),
9162 "token must be redacted: {debug}"
9163 );
9164 }
9165
9166 #[test]
9167 fn http_auth_none_debug_shows_variant() {
9168 let debug = format!("{:?}", HttpAuth::None);
9169 assert!(
9170 debug.contains("None"),
9171 "None variant should appear: {debug}"
9172 );
9173 }
9174
9175 #[test]
9176 fn http_endpoint_config_debug_redacts_auth_credentials() {
9177 let config = HttpEndpointConfig::from_uri(
9178 "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
9179 )
9180 .unwrap();
9181 let debug = format!("{:?}", config);
9182 assert!(
9183 !debug.contains("secret123"),
9184 "password must be redacted in HttpEndpointConfig debug: {debug}"
9185 );
9186 }
9187
9188 #[test]
9189 fn debug_lists_all_public_fields() {
9190 let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
9191 let debug = format!("{:?}", config);
9192 for field in [
9193 "base_url",
9194 "http_method",
9195 "throw_exception_on_failure",
9196 "ok_status_code_range",
9197 "response_timeout",
9198 "query_params",
9199 "raw_query",
9200 "allow_internal",
9201 "blocked_hosts",
9202 "max_body_size",
9203 "read_timeout_ms",
9204 "max_response_bytes",
9205 "auth",
9206 "token_provider",
9207 "user_agent",
9208 "bridge_endpoint",
9209 "connection_close",
9210 "skip_request_headers",
9211 "skip_response_headers",
9212 "follow_redirects",
9213 "max_redirects",
9214 ] {
9215 assert!(
9216 debug.contains(field),
9217 "Debug output missing field '{field}': {debug}"
9218 );
9219 }
9220 }
9221
9222 use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
9227 use tower_http::services::ServeDir;
9228
9229 fn make_test_registry() -> HttpRouteRegistry {
9230 HttpRouteRegistry::new()
9231 }
9232
9233 fn make_test_state(registry: HttpRouteRegistry) -> AppState {
9234 AppState {
9235 registry,
9236 max_request_body: 2 * 1024 * 1024,
9237 max_response_body: 10 * 1024 * 1024,
9238 inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
9239 }
9240 }
9241
9242 #[allow(clippy::await_holding_lock)]
9243 #[tokio::test]
9244 async fn test_static_file_serving_serves_file_contents() {
9245 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
9246 ServerRegistry::reset();
9247
9248 let temp_dir =
9250 std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
9251 std::fs::create_dir_all(&temp_dir).unwrap();
9252 std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
9253 std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
9254
9255 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
9256
9257 let registry = make_test_registry();
9258 let serve_dir = ServeDir::new(&canonical_dir)
9259 .precompressed_gzip()
9260 .precompressed_br()
9261 .append_index_html_on_directories(true);
9262
9263 let mount = StaticMount {
9264 mount_path: "/".to_string(),
9265 mode: MountMode::Static,
9266 dir: canonical_dir.clone(),
9267 cache_control: "public, max-age=3600".to_string(),
9268 error_pages: std::collections::HashMap::new(),
9269 serve_dir,
9270 };
9271 registry.register_static_mount(mount).await.unwrap();
9272
9273 let state = make_test_state(registry);
9274
9275 let req = Request::builder()
9277 .uri("/hello.txt")
9278 .body(AxumBody::empty())
9279 .unwrap();
9280 let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
9281 assert_eq!(resp.status(), StatusCode::OK);
9282 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9283 .await
9284 .unwrap();
9285 assert_eq!(&body[..], b"Hello, static world!");
9286
9287 let req = Request::builder()
9289 .uri("/style.css")
9290 .body(AxumBody::empty())
9291 .unwrap();
9292 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
9293 assert_eq!(resp.status(), StatusCode::OK);
9294 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9295 .await
9296 .unwrap();
9297 assert_eq!(&body[..], b"body { color: red; }");
9298
9299 let req = Request::builder()
9301 .uri("/missing.txt")
9302 .body(AxumBody::empty())
9303 .unwrap();
9304 let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
9305 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9306
9307 std::fs::remove_dir_all(&temp_dir).ok();
9309 }
9310
9311 #[allow(clippy::await_holding_lock)]
9312 #[tokio::test]
9313 async fn test_spa_fallback_serves_index_for_unknown_paths() {
9314 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
9315 ServerRegistry::reset();
9316
9317 let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
9318 std::fs::create_dir_all(&temp_dir).unwrap();
9319 std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
9320 std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
9321
9322 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
9323
9324 let registry = make_test_registry();
9325 let serve_dir = ServeDir::new(&canonical_dir)
9326 .precompressed_gzip()
9327 .precompressed_br()
9328 .append_index_html_on_directories(true);
9329
9330 let mount = StaticMount {
9331 mount_path: "/".to_string(),
9332 mode: MountMode::Spa,
9333 dir: canonical_dir.clone(),
9334 cache_control: "public, max-age=0".to_string(),
9335 error_pages: std::collections::HashMap::new(),
9336 serve_dir,
9337 };
9338 registry.register_static_mount(mount).await.unwrap();
9340
9341 let state = make_test_state(registry);
9342
9343 let req = Request::builder()
9345 .method("GET")
9346 .uri("/dashboard")
9347 .header("Accept", "text/html")
9348 .body(AxumBody::empty())
9349 .unwrap();
9350 let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
9351 assert_eq!(resp.status(), StatusCode::OK);
9352 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9353 .await
9354 .unwrap();
9355 assert_eq!(&body[..], b"<h1>SPA App</h1>");
9356
9357 let req = Request::builder()
9359 .method("GET")
9360 .uri("/app.js")
9361 .body(AxumBody::empty())
9362 .unwrap();
9363 let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
9364 assert_eq!(resp.status(), StatusCode::OK);
9365 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9366 .await
9367 .unwrap();
9368 assert_eq!(&body[..], b"console.log('app')");
9369
9370 let req = Request::builder()
9372 .method("GET")
9373 .uri("/api/data")
9374 .header("Accept", "application/json")
9375 .body(AxumBody::empty())
9376 .unwrap();
9377 let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
9378 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9379
9380 let req = Request::builder()
9382 .method("GET")
9383 .uri("/style.css")
9384 .header("Accept", "text/html")
9385 .body(AxumBody::empty())
9386 .unwrap();
9387 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
9388 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9389
9390 std::fs::remove_dir_all(&temp_dir).ok();
9392 }
9393
9394 #[allow(clippy::await_holding_lock)]
9401 async fn run_conditional_get_returns_304(mode: MountMode) {
9402 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
9403 ServerRegistry::reset();
9404
9405 let temp_dir = std::env::temp_dir().join(format!(
9406 "http_cond_get_{}_{}",
9407 if mode == MountMode::Spa {
9408 "spa"
9409 } else {
9410 "static"
9411 },
9412 std::process::id()
9413 ));
9414 std::fs::create_dir_all(&temp_dir).unwrap();
9415 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
9416
9417 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
9418
9419 let registry = make_test_registry();
9420 let serve_dir = ServeDir::new(&canonical_dir)
9421 .precompressed_gzip()
9422 .precompressed_br()
9423 .append_index_html_on_directories(true);
9424
9425 let mount = StaticMount {
9426 mount_path: "/".to_string(),
9427 mode,
9428 dir: canonical_dir.clone(),
9429 cache_control: "public, max-age=3600".to_string(),
9430 error_pages: std::collections::HashMap::new(),
9431 serve_dir,
9432 };
9433 registry.register_static_mount(mount).await.unwrap();
9434
9435 let state = make_test_state(registry);
9436
9437 let req = Request::builder()
9439 .method("GET")
9440 .uri("/index.html")
9441 .body(AxumBody::empty())
9442 .unwrap();
9443 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
9444 assert_eq!(
9445 resp.status(),
9446 StatusCode::OK,
9447 "first GET should return 200, got {}",
9448 resp.status()
9449 );
9450 assert!(
9452 resp.headers().contains_key(http::header::CACHE_CONTROL),
9453 "200 response missing Cache-Control"
9454 );
9455 let etag = resp
9456 .headers()
9457 .get(http::header::ETAG)
9458 .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
9459 .clone();
9460 let last_modified = resp
9461 .headers()
9462 .get(http::header::LAST_MODIFIED)
9463 .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
9464 .clone();
9465 let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
9467 .await
9468 .unwrap();
9469
9470 let req = Request::builder()
9474 .method("GET")
9475 .uri("/index.html")
9476 .header(http::header::IF_NONE_MATCH, etag.clone())
9477 .body(AxumBody::empty())
9478 .unwrap();
9479 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
9480 assert_eq!(
9481 resp.status(),
9482 StatusCode::NOT_MODIFIED,
9483 "If-None-Match with matching ETag should return 304, got {}",
9484 resp.status()
9485 );
9486 assert!(
9488 resp.headers().contains_key(http::header::CACHE_CONTROL),
9489 "304 (If-None-Match) missing Cache-Control"
9490 );
9491 assert_eq!(
9494 resp.headers().get(http::header::ETAG),
9495 Some(&etag),
9496 "304 (If-None-Match) must echo the ETag validator"
9497 );
9498 assert_eq!(
9499 resp.headers().get(http::header::LAST_MODIFIED),
9500 Some(&last_modified),
9501 "304 (If-None-Match) must carry Last-Modified"
9502 );
9503
9504 let req = Request::builder()
9506 .method("GET")
9507 .uri("/index.html")
9508 .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
9509 .body(AxumBody::empty())
9510 .unwrap();
9511 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
9512 assert_eq!(
9513 resp.status(),
9514 StatusCode::NOT_MODIFIED,
9515 "If-Modified-Since with matching timestamp should return 304, got {}",
9516 resp.status()
9517 );
9518 assert!(
9519 resp.headers().contains_key(http::header::CACHE_CONTROL),
9520 "304 (If-Modified-Since) missing Cache-Control"
9521 );
9522 assert_eq!(
9523 resp.headers().get(http::header::ETAG),
9524 Some(&etag),
9525 "304 (If-Modified-Since) must carry the ETag validator"
9526 );
9527 assert_eq!(
9528 resp.headers().get(http::header::LAST_MODIFIED),
9529 Some(&last_modified),
9530 "304 (If-Modified-Since) must echo Last-Modified"
9531 );
9532
9533 let req = Request::builder()
9539 .method("GET")
9540 .uri("/index.html")
9541 .header(
9542 http::header::IF_MODIFIED_SINCE,
9543 "Wed, 21 Oct 2000 07:28:00 GMT",
9544 )
9545 .body(AxumBody::empty())
9546 .unwrap();
9547 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
9548 assert_eq!(
9549 resp.status(),
9550 StatusCode::OK,
9551 "past If-Modified-Since should return 200 (file modified after it), got {}",
9552 resp.status()
9553 );
9554
9555 std::fs::remove_dir_all(&temp_dir).ok();
9557 }
9558
9559 #[tokio::test]
9560 async fn test_conditional_get_returns_304_static_mode() {
9561 run_conditional_get_returns_304(MountMode::Static).await;
9562 }
9563
9564 #[tokio::test]
9565 async fn test_conditional_get_returns_304_spa_mode() {
9566 run_conditional_get_returns_304(MountMode::Spa).await;
9567 }
9568
9569 #[allow(clippy::await_holding_lock)]
9570 #[tokio::test]
9571 async fn test_error_page_mapping_serves_custom_404() {
9572 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
9573 ServerRegistry::reset();
9574
9575 let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
9576 let errors_dir = temp_dir.join("errors");
9577 std::fs::create_dir_all(&errors_dir).unwrap();
9578 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
9579 std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
9580
9581 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
9582 let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
9583
9584 let registry = make_test_registry();
9585 let serve_dir = ServeDir::new(&canonical_dir)
9586 .precompressed_gzip()
9587 .precompressed_br()
9588 .append_index_html_on_directories(true);
9589
9590 let mut error_pages = std::collections::HashMap::new();
9591 error_pages.insert(404, canonical_404);
9592
9593 let mount = StaticMount {
9594 mount_path: "/".to_string(),
9595 mode: MountMode::Static,
9596 dir: canonical_dir.clone(),
9597 cache_control: "public, max-age=0".to_string(),
9598 error_pages,
9599 serve_dir,
9600 };
9601 registry.register_static_mount(mount).await.unwrap();
9602
9603 let state = make_test_state(registry);
9604
9605 let req = Request::builder()
9607 .method("GET")
9608 .uri("/missing.html")
9609 .body(AxumBody::empty())
9610 .unwrap();
9611 let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
9612 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9613 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9614 .await
9615 .unwrap();
9616 assert_eq!(&body[..], b"<h1>Custom 404</h1>");
9617
9618 let req = Request::builder()
9620 .method("GET")
9621 .uri("/index.html")
9622 .body(AxumBody::empty())
9623 .unwrap();
9624 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
9625 assert_eq!(resp.status(), StatusCode::OK);
9626 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9627 .await
9628 .unwrap();
9629 assert_eq!(&body[..], b"<h1>Home</h1>");
9630
9631 std::fs::remove_dir_all(&temp_dir).ok();
9633 }
9634
9635 #[tokio::test]
9636 async fn http_consumer_returns_body_and_code_on_stop() {
9637 use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
9638 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
9639 use tower::ServiceExt;
9640
9641 let set_body_step = CompiledStep::Process {
9643 kind_hint: camel_api::SpanKindHint::Internal,
9644 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
9645 ex.input.body = Body::Text("nope".into());
9646 Box::pin(async move { Ok(ex) })
9647 }),
9648 body_contract: None,
9649 lifecycle: None,
9650 label: None,
9651 };
9652 let set_status_step = CompiledStep::Process {
9653 kind_hint: camel_api::SpanKindHint::Internal,
9654 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
9655 ex.input.set_header(
9656 "CamelHttpResponseCode",
9657 serde_json::Value::Number(409.into()),
9658 );
9659 Box::pin(async move { Ok(ex) })
9660 }),
9661 body_contract: None,
9662 lifecycle: None,
9663 label: None,
9664 };
9665 let pipeline = compose_pipeline_with_handler(
9666 vec![set_body_step, set_status_step, CompiledStep::Stop],
9667 None,
9668 PipelineRuntimeCtx::compile_time(),
9669 );
9670
9671 let ex = Exchange::new(Message::default());
9672 let result = pipeline.oneshot(ex).await;
9673 assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
9674 let returned = result.unwrap();
9675 assert_eq!(returned.input.body.as_text(), Some("nope"));
9676 assert_eq!(
9677 returned
9678 .input
9679 .header("CamelHttpResponseCode")
9680 .and_then(|v| v.as_u64()),
9681 Some(409)
9682 );
9683 }
9684
9685 #[tokio::test]
9686 async fn http_consumer_returns_200_when_body_empty_on_stop() {
9687 use camel_api::{Exchange, Message};
9695 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
9696 use tower::ServiceExt;
9697
9698 let pipeline = compose_pipeline_with_handler(
9699 vec![CompiledStep::Stop],
9700 None,
9701 PipelineRuntimeCtx::compile_time(),
9702 );
9703 let ex = Exchange::new(Message::default());
9704 let result = pipeline.oneshot(ex).await;
9705 assert!(result.is_ok(), "Stop with empty body arrives as Ok");
9706 }
9709
9710 async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
9718 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9719 let port = listener.local_addr().unwrap().port();
9720 let registry = HttpRouteRegistry::new();
9721 tokio::spawn(run_axum_server(
9722 listener,
9723 registry.clone(),
9724 2 * 1024 * 1024,
9725 10 * 1024 * 1024,
9726 Arc::new(tokio::sync::Semaphore::new(1024)),
9727 test_rt(),
9728 "test-route".into(),
9729 ));
9730 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9732 (port, registry)
9733 }
9734
9735 fn spawn_responder(
9740 mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
9741 status: u16,
9742 body: String,
9743 ) -> tokio::task::JoinHandle<()> {
9744 tokio::spawn(async move {
9745 if let Some(envelope) = rx.recv().await {
9746 let _ = envelope.reply_tx.send(HttpReply {
9747 status,
9748 headers: vec![],
9749 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
9750 });
9751 }
9752 })
9753 }
9754
9755 #[tokio::test]
9756 async fn method_aware_dispatch_same_path_different_verbs() {
9757 let (port, registry) = spawn_test_server().await;
9758
9759 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9764 registry
9765 .register_rest_endpoint(
9766 "GET".into(),
9767 vec![PathSegment::Literal("users".into())],
9768 get_tx,
9769 )
9770 .await;
9771
9772 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9773 registry
9774 .register_rest_endpoint(
9775 "POST".into(),
9776 vec![PathSegment::Literal("users".into())],
9777 post_tx,
9778 )
9779 .await;
9780
9781 let get_handle = spawn_responder(get_rx, 200, "list".into());
9782 let post_handle = spawn_responder(post_rx, 201, "create".into());
9783
9784 let client = reqwest::Client::new();
9785
9786 let resp = client
9788 .get(format!("http://127.0.0.1:{port}/users"))
9789 .send()
9790 .await
9791 .unwrap();
9792 assert_eq!(resp.status().as_u16(), 200);
9793 let body = resp.text().await.unwrap();
9794 assert_eq!(body, "list");
9795
9796 let resp = client
9798 .post(format!("http://127.0.0.1:{port}/users"))
9799 .send()
9800 .await
9801 .unwrap();
9802 assert_eq!(resp.status().as_u16(), 201);
9803 let body = resp.text().await.unwrap();
9804 assert_eq!(body, "create");
9805
9806 let _ = tokio::join!(get_handle, post_handle);
9807 }
9808
9809 #[tokio::test]
9810 async fn method_aware_dispatch_templated_path_extracts_params() {
9811 let (port, registry) = spawn_test_server().await;
9812
9813 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9817 registry
9818 .register_rest_endpoint(
9819 "GET".into(),
9820 vec![
9821 PathSegment::Literal("users".into()),
9822 PathSegment::Param("id".into()),
9823 ],
9824 tx,
9825 )
9826 .await;
9827
9828 let handle = tokio::spawn(async move {
9831 if let Some(envelope) = rx.recv().await {
9832 let id = envelope.path_params.get("id").cloned().unwrap_or_default();
9833 let _ = envelope.reply_tx.send(HttpReply {
9834 status: 200,
9835 headers: vec![],
9836 body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
9837 });
9838 }
9839 });
9840
9841 let client = reqwest::Client::new();
9842 let resp = client
9843 .get(format!("http://127.0.0.1:{port}/users/42"))
9844 .send()
9845 .await
9846 .unwrap();
9847 assert_eq!(resp.status().as_u16(), 200);
9848 let body = resp.text().await.unwrap();
9849 assert_eq!(body, "id=42");
9850
9851 let _ = handle.await;
9852 }
9853
9854 #[tokio::test]
9855 async fn method_aware_dispatch_unmatched_method_falls_through() {
9856 let (port, _registry) = spawn_test_server().await;
9861
9862 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9864 _registry
9865 .register_rest_endpoint(
9866 "GET".into(),
9867 vec![PathSegment::Literal("users".into())],
9868 get_tx,
9869 )
9870 .await;
9871
9872 let drain = tokio::spawn(async move {
9875 let mut get_rx = get_rx;
9876 while get_rx.recv().await.is_some() {}
9877 });
9878
9879 let client = reqwest::Client::new();
9880 let resp = client
9881 .delete(format!("http://127.0.0.1:{port}/users"))
9882 .send()
9883 .await
9884 .unwrap();
9885 assert_eq!(resp.status().as_u16(), 404);
9886
9887 drop(drain);
9888 }
9889
9890 #[tokio::test]
9891 async fn regression_legacy_exact_api_route_still_works() {
9892 let (port, registry) = spawn_test_server().await;
9897
9898 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9899 registry.register_api_route("/legacy/path".into(), tx).await;
9900
9901 let handle = tokio::spawn(async move {
9902 if let Some(envelope) = rx.recv().await {
9903 let _ = envelope.reply_tx.send(HttpReply {
9904 status: 200,
9905 headers: vec![],
9906 body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
9907 });
9908 }
9909 });
9910
9911 let client = reqwest::Client::new();
9912 let resp = client
9913 .get(format!("http://127.0.0.1:{port}/legacy/path"))
9914 .send()
9915 .await
9916 .unwrap();
9917 assert_eq!(resp.status().as_u16(), 200);
9918 let body = resp.text().await.unwrap();
9919 assert_eq!(body, "legacy ok");
9920
9921 let _ = handle.await;
9922 }
9923
9924 #[allow(clippy::await_holding_lock)]
9925 #[tokio::test]
9926 async fn regression_static_mount_still_works() {
9927 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
9931 ServerRegistry::reset();
9932
9933 let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
9934 std::fs::create_dir_all(&temp_dir).unwrap();
9935 std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
9936 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
9937
9938 let registry = make_test_registry();
9939 let serve_dir = ServeDir::new(&canonical_dir)
9940 .precompressed_gzip()
9941 .precompressed_br()
9942 .append_index_html_on_directories(true);
9943 let mount = StaticMount {
9944 mount_path: "/".to_string(),
9945 mode: MountMode::Static,
9946 dir: canonical_dir.clone(),
9947 cache_control: "public, max-age=3600".to_string(),
9948 error_pages: std::collections::HashMap::new(),
9949 serve_dir,
9950 };
9951 registry.register_static_mount(mount).await.unwrap();
9952
9953 let state = make_test_state(registry);
9954 let req = Request::builder()
9955 .uri("/regress.txt")
9956 .body(AxumBody::empty())
9957 .unwrap();
9958 let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
9959 assert_eq!(resp.status(), StatusCode::OK);
9960 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9961 .await
9962 .unwrap();
9963 assert_eq!(&body[..], b"static works");
9964
9965 std::fs::remove_dir_all(&temp_dir).ok();
9966 }
9967
9968 #[tokio::test]
9977 async fn deregister_one_method_keeps_sibling_verbs() {
9978 let (port, registry) = spawn_test_server().await;
9982
9983 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9984 registry
9985 .register_rest_endpoint(
9986 "GET".into(),
9987 vec![PathSegment::Literal("users".into())],
9988 get_tx,
9989 )
9990 .await;
9991
9992 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9993 registry
9994 .register_rest_endpoint(
9995 "POST".into(),
9996 vec![PathSegment::Literal("users".into())],
9997 post_tx,
9998 )
9999 .await;
10000
10001 let drain = tokio::spawn(async move {
10003 let mut get_rx = get_rx;
10004 while get_rx.recv().await.is_some() {}
10005 });
10006
10007 registry.unregister_rest_endpoint("GET", "/users").await;
10009 drop(drain);
10010
10011 let post_handle = spawn_responder(post_rx, 201, "create".into());
10012
10013 let client = reqwest::Client::new();
10014 let resp = client
10016 .post(format!("http://127.0.0.1:{port}/users"))
10017 .send()
10018 .await
10019 .unwrap();
10020 assert_eq!(resp.status().as_u16(), 201);
10021 assert_eq!(resp.text().await.unwrap(), "create");
10022
10023 let _ = post_handle.await;
10024 }
10025
10026 #[tokio::test]
10027 async fn dispatch_exact_legacy_beats_rest_template() {
10028 let (port, registry) = spawn_test_server().await;
10033
10034 let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10036 registry
10037 .register_api_route("/api/users".into(), exact_tx)
10038 .await;
10039 let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
10040
10041 let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10043 registry
10044 .register_rest_endpoint(
10045 "GET".into(),
10046 vec![
10047 PathSegment::Literal("api".into()),
10048 PathSegment::Param("resource".into()),
10049 ],
10050 tpl_tx,
10051 )
10052 .await;
10053 let _tpl_drain = tokio::spawn(async move {
10059 let mut tpl_rx = tpl_rx;
10060 if let Some(env) = tpl_rx.recv().await {
10061 let _ = env.reply_tx.send(HttpReply {
10062 status: 200,
10063 headers: vec![],
10064 body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
10065 });
10066 }
10067 });
10068
10069 let client = reqwest::Client::new();
10070 let resp = client
10071 .get(format!("http://127.0.0.1:{port}/api/users"))
10072 .send()
10073 .await
10074 .unwrap();
10075 assert_eq!(resp.status().as_u16(), 200);
10076 assert_eq!(resp.text().await.unwrap(), "exact");
10078
10079 let _ = exact_handle.await;
10080 }
10081
10082 #[tokio::test]
10083 async fn ambiguous_rest_templates_return_500_not_silent_404() {
10084 let (port, registry) = spawn_test_server().await;
10089
10090 let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10091 registry
10092 .register_rest_endpoint(
10093 "GET".into(),
10094 vec![
10095 PathSegment::Literal("users".into()),
10096 PathSegment::Param("id".into()),
10097 ],
10098 a_tx,
10099 )
10100 .await;
10101
10102 let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10103 registry
10104 .register_rest_endpoint(
10105 "GET".into(),
10106 vec![
10107 PathSegment::Literal("users".into()),
10108 PathSegment::Param("name".into()),
10109 ],
10110 b_tx,
10111 )
10112 .await;
10113
10114 let client = reqwest::Client::new();
10115 let resp = client
10116 .get(format!("http://127.0.0.1:{port}/users/42"))
10117 .send()
10118 .await
10119 .unwrap();
10120 assert_eq!(resp.status().as_u16(), 500);
10122 }
10123
10124 #[test]
10125 fn from_uri_round_trips_templated_path_with_http_method() {
10126 use crate::UriConfig;
10132 let cfg =
10133 HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
10134 assert_eq!(cfg.host, "0.0.0.0");
10135 assert_eq!(cfg.port, 8080);
10136 assert_eq!(cfg.path, "/users/{id}");
10137 assert_eq!(cfg.method.as_deref(), Some("GET"));
10138
10139 let cfg_lc =
10141 HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
10142 assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
10143 assert_eq!(cfg_lc.path, "/orders");
10144 }
10145
10146 #[test]
10151 fn type_conversion_failed_maps_to_400() {
10152 let reply = pipeline_error_to_reply(
10153 CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
10154 "/api/users",
10155 );
10156 assert_eq!(reply.status, 400);
10157 let ct = reply
10159 .headers
10160 .iter()
10161 .find(|(k, _)| k == "Content-Type")
10162 .map(|(_, v)| v.as_str());
10163 assert_eq!(ct, Some("application/json"));
10164 let body = match &reply.body {
10166 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
10167 _ => panic!("expected bytes body"),
10168 };
10169 assert!(body.contains("\"error\""));
10170 assert!(body.contains("bad_request"));
10171 assert!(body.contains("invalid JSON at line 1"));
10172 }
10173
10174 #[test]
10175 fn other_error_still_maps_to_500() {
10176 let reply =
10177 pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
10178 assert_eq!(reply.status, 500);
10179 }
10180
10181 #[test]
10182 fn unauthenticated_maps_to_401() {
10183 let reply = pipeline_error_to_reply(
10184 CamelError::Unauthenticated("no token".to_string()),
10185 "/api/users",
10186 );
10187 assert_eq!(reply.status, 401);
10188 }
10189
10190 #[test]
10191 fn unauthorized_maps_to_403() {
10192 let reply = pipeline_error_to_reply(
10193 CamelError::Unauthorized("forbidden".to_string()),
10194 "/api/users",
10195 );
10196 assert_eq!(reply.status, 403);
10197 }
10198
10199 #[test]
10200 fn validation_error_maps_to_400() {
10201 let reply = pipeline_error_to_reply(
10202 CamelError::ValidationError("body does not match schema".to_string()),
10203 "/api/users",
10204 );
10205 assert_eq!(reply.status, 400);
10206 let ct = reply
10207 .headers
10208 .iter()
10209 .find(|(k, _)| k == "Content-Type")
10210 .map(|(_, v)| v.as_str());
10211 assert_eq!(ct, Some("application/json"));
10212 let body = match &reply.body {
10213 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
10214 _ => panic!("expected bytes body"),
10215 };
10216 assert!(body.contains("\"error\""));
10217 assert!(body.contains("validation_error"));
10218 assert!(body.contains("body does not match schema"));
10219 }
10220
10221 #[test]
10226 fn finalizer_maps_unsupported_media_type() {
10227 let reply = pipeline_error_to_reply(
10228 CamelError::UnsupportedMediaType {
10229 consumed: "text/plain".to_string(),
10230 declared: "application/json".to_string(),
10231 },
10232 "/x",
10233 );
10234 assert_eq!(reply.status, 415);
10235 let ct = reply
10236 .headers
10237 .iter()
10238 .find(|(k, _)| k == "Content-Type")
10239 .map(|(_, v)| v.as_str());
10240 assert_eq!(ct, Some("application/json"));
10241 let body = match &reply.body {
10242 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
10243 _ => panic!("expected bytes body"),
10244 };
10245 let parsed: serde_json::Value =
10246 serde_json::from_str(&body).expect("body must be valid JSON");
10247 assert_eq!(parsed["error"], "unsupported_media_type");
10248 let message = parsed["message"]
10249 .as_str()
10250 .expect("message must be a string");
10251 assert!(message.contains("text/plain"));
10252 assert!(message.contains("application/json"));
10253 }
10254
10255 #[test]
10256 fn finalizer_maps_not_acceptable() {
10257 let reply = pipeline_error_to_reply(
10258 CamelError::NotAcceptable {
10259 accept: "application/xml".to_string(),
10260 produced: "application/json".to_string(),
10261 },
10262 "/x",
10263 );
10264 assert_eq!(reply.status, 406);
10265 let ct = reply
10266 .headers
10267 .iter()
10268 .find(|(k, _)| k == "Content-Type")
10269 .map(|(_, v)| v.as_str());
10270 assert_eq!(ct, Some("application/json"));
10271 let body = match &reply.body {
10272 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
10273 _ => panic!("expected bytes body"),
10274 };
10275 let parsed: serde_json::Value =
10276 serde_json::from_str(&body).expect("body must be valid JSON");
10277 assert_eq!(parsed["error"], "not_acceptable");
10278 let message = parsed["message"]
10279 .as_str()
10280 .expect("message must be a string");
10281 assert!(message.contains("application/xml"));
10282 assert!(message.contains("application/json"));
10283 }
10284
10285 #[test]
10286 fn https_consumer_without_tls_cert_errors() {
10287 let endpoint = HttpEndpoint {
10288 uri: "https://0.0.0.0:8443/api".to_string(),
10289 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
10290 server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
10291 client: reqwest::Client::new(),
10292 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
10293 PINNED_CLIENT_TTL,
10294 PINNED_CLIENT_MAX_ENTRIES,
10295 )),
10296 http_config: HttpConfig::default(),
10297 };
10298 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
10299 let result = endpoint.create_consumer(rt);
10300 assert!(result.is_err(), "expected error for https without tls cert");
10301 if let Err(e) = result {
10302 let msg = e.to_string();
10303 assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
10304 }
10305 }
10306
10307 #[test]
10308 fn http_consumer_with_tls_config_errors() {
10309 let endpoint = HttpEndpoint {
10310 uri: "http://0.0.0.0:8080/api".to_string(),
10311 config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
10312 server_config: HttpServerConfig::from_uri(
10313 "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
10314 )
10315 .unwrap(),
10316 client: reqwest::Client::new(),
10317 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
10318 PINNED_CLIENT_TTL,
10319 PINNED_CLIENT_MAX_ENTRIES,
10320 )),
10321 http_config: HttpConfig::default(),
10322 };
10323 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
10324 let result = endpoint.create_consumer(rt);
10325 assert!(result.is_err(), "expected error for http with tls config");
10326 if let Err(e) = result {
10327 let msg = e.to_string();
10328 assert!(msg.contains("https"), "error must mention https: {msg}");
10329 }
10330 }
10331
10332 #[test]
10333 fn https_consumer_with_partial_tls_cert_only_errors() {
10334 let server_config =
10337 HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
10338 assert!(
10339 server_config.tls_config.is_none(),
10340 "partial tlsCert must not create ServerTlsConfig"
10341 );
10342 let endpoint = HttpEndpoint {
10343 uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
10344 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
10345 .unwrap(),
10346 server_config,
10347 client: reqwest::Client::new(),
10348 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
10349 PINNED_CLIENT_TTL,
10350 PINNED_CLIENT_MAX_ENTRIES,
10351 )),
10352 http_config: HttpConfig::default(),
10353 };
10354 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
10355 let result = endpoint.create_consumer(rt);
10356 assert!(
10357 result.is_err(),
10358 "must error: https:// requires both tlsCert and tlsKey"
10359 );
10360 }
10361
10362 #[test]
10363 fn load_tls_config_parses_valid_pem() {
10364 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
10366 use camel_component_api::test_support::tls;
10367 let (_, cert_pem, key_pem) = tls::gen_server_cert();
10368 let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
10369 let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
10370
10371 let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
10372 assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
10373 }
10374
10375 #[tokio::test(flavor = "multi_thread")]
10376 #[allow(clippy::await_holding_lock)]
10377 async fn consumer_tls_handshake_roundtrip() {
10378 use camel_component_api::test_support::tls;
10379 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
10380
10381 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
10383
10384 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
10386
10387 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
10389 let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
10390 let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
10391 let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
10392
10393 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10395 let port = probe.local_addr().unwrap().port();
10396 drop(probe);
10397
10398 ServerRegistry::reset();
10399
10400 let component = HttpComponent::new();
10402 let endpoint_ctx = NoOpComponentContext;
10403 let uri = format!(
10404 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
10405 cert_path.to_string_lossy(),
10406 key_path.to_string_lossy(),
10407 );
10408 let endpoint = component
10409 .create_endpoint(&uri, &endpoint_ctx)
10410 .expect("create TLS endpoint");
10411 let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
10412
10413 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
10415 let token = tokio_util::sync::CancellationToken::new();
10416 let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
10417 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10418
10419 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
10421
10422 let ca_bytes = std::fs::read(&ca_path).unwrap();
10424 let client = reqwest::Client::builder()
10425 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
10426 .build()
10427 .unwrap();
10428
10429 let send_fut = client
10430 .post(format!("https://localhost:{port}/test"))
10431 .body("ping")
10432 .send();
10433
10434 let (http_result, _) = tokio::join!(send_fut, async {
10436 if let Some(mut envelope) = rx.recv().await {
10437 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
10438 if let Some(reply_tx) = envelope.reply_tx {
10439 let _ = reply_tx.send(Ok(envelope.exchange));
10440 }
10441 }
10442 });
10443
10444 let resp = http_result.expect("TLS handshake + request must succeed");
10445
10446 assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
10447 let body = resp.text().await.unwrap();
10448 assert_eq!(body, "pong");
10449
10450 token.cancel();
10451 }
10452
10453 #[tokio::test(flavor = "multi_thread")]
10454 #[allow(clippy::await_holding_lock)]
10455 async fn consumer_tls_rejects_client_without_ca() {
10456 use camel_component_api::test_support::tls;
10457 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
10458
10459 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
10460
10461 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
10463
10464 let (_, cert_pem, key_pem) = tls::gen_server_cert();
10465 let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
10466 let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
10467
10468 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10469 let port = probe.local_addr().unwrap().port();
10470 drop(probe);
10471
10472 ServerRegistry::reset();
10473
10474 let component = HttpComponent::new();
10476 let endpoint_ctx = NoOpComponentContext;
10477 let uri = format!(
10478 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
10479 cert_path.to_string_lossy(),
10480 key_path.to_string_lossy(),
10481 );
10482 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
10483 let mut consumer = endpoint.create_consumer(rt()).unwrap();
10484 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
10485 let token = tokio_util::sync::CancellationToken::new();
10486 let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
10487 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10488
10489 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
10490
10491 let client = reqwest::Client::builder().build().unwrap();
10493
10494 let result = client
10495 .get(format!("https://localhost:{port}/test"))
10496 .send()
10497 .await;
10498
10499 assert!(
10500 result.is_err(),
10501 "must reject without CA — proves real verification"
10502 );
10503
10504 token.cancel();
10505 }
10506
10507 #[test]
10508 fn server_config_partial_tls_cert_without_key() {
10509 let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
10511 assert!(cfg.tls_config.is_none());
10513 }
10514
10515 #[test]
10516 fn endpoint_uri_options_count_parity() {
10517 assert_eq!(
10519 HttpEndpointConfig::uri_options().len(),
10520 22,
10521 "HttpEndpointUriConfig #[uri_param] count drifted from parser"
10522 );
10523 }
10524
10525 fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
10526 pairs
10527 .iter()
10528 .map(|(k, v)| {
10529 (
10530 (*k).to_string(),
10531 serde_json::Value::String((*v).to_string()),
10532 )
10533 })
10534 .collect()
10535 }
10536
10537 #[test]
10538 fn response_emits_cache_control_via_pragma_warning() {
10539 let headers = make_headers(&[
10540 ("Cache-Control", "public, max-age=3600"),
10541 ("Via", "1.1 myproxy"),
10542 ("Pragma", "no-cache"),
10543 ("Warning", "199 misc"),
10544 ]);
10545 let selected = select_response_headers(&headers, None, None);
10546 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
10547 for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
10548 assert!(
10549 names.contains(&expected),
10550 "{expected} should pass through to the response"
10551 );
10552 }
10553 }
10554
10555 #[test]
10556 fn response_excludes_request_only_and_server_owned() {
10557 let headers = make_headers(&[
10558 ("User-Agent", "x"),
10559 ("Accept", "*/*"),
10560 ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
10561 ]);
10562 let selected = select_response_headers(&headers, None, None);
10563 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
10564 for excluded in ["User-Agent", "Accept", "Date"] {
10565 assert!(
10566 !names.contains(&excluded),
10567 "{excluded} should NOT appear in the response"
10568 );
10569 }
10570 }
10571
10572 #[test]
10573 fn response_re_derives_content_type() {
10574 let headers = make_headers(&[("Content-Type", "text/plain")]);
10575 let selected = select_response_headers(&headers, Some("application/json".into()), None);
10576 let ct_entries: Vec<&str> = selected
10577 .iter()
10578 .filter(|(k, _)| k == "Content-Type")
10579 .map(|(_, v)| v.as_str())
10580 .collect();
10581 assert_eq!(
10582 ct_entries,
10583 ["application/json"],
10584 "exactly one Content-Type entry, re-derived from user_content_type"
10585 );
10586 }
10587
10588 #[test]
10589 fn response_excludes_camel_headers() {
10590 let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
10591 let selected = select_response_headers(&headers, None, None);
10592 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
10593 assert!(
10594 !names.contains(&"CamelHttpPath"),
10595 "Camel-namespace headers must be excluded"
10596 );
10597 assert!(
10598 names.contains(&"Cache-Control"),
10599 "Cache-Control must pass through"
10600 );
10601 }
10602
10603 #[test]
10604 fn response_stringifies_scalar_header_values() {
10605 let mut headers = make_headers(&[("X-Label", "keep")]);
10606 headers.insert("X-Retries".to_string(), serde_json::json!(3));
10607 headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
10608 headers.insert("X-Enabled".to_string(), serde_json::json!(true));
10609 let selected = select_response_headers(&headers, None, None);
10610 let get = |name: &str| -> Option<&str> {
10611 selected
10612 .iter()
10613 .find(|(k, _)| k == name)
10614 .map(|(_, v)| v.as_str())
10615 };
10616 assert_eq!(
10617 get("X-Retries"),
10618 Some("3"),
10619 "integer header must be stringified"
10620 );
10621 assert_eq!(
10622 get("X-Ratio"),
10623 Some("3.5"),
10624 "float header must be stringified"
10625 );
10626 assert_eq!(
10627 get("X-Enabled"),
10628 Some("true"),
10629 "bool header must be stringified"
10630 );
10631 assert_eq!(
10632 get("X-Label"),
10633 Some("keep"),
10634 "string header must pass through"
10635 );
10636 }
10637
10638 #[test]
10639 fn response_drops_null_and_structured_header_values() {
10640 let mut headers = make_headers(&[("X-Keep", "yes")]);
10641 headers.insert("X-Null".to_string(), serde_json::Value::Null);
10642 headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
10643 headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
10644 let selected = select_response_headers(&headers, None, None);
10645 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
10646 for dropped in ["X-Null", "X-Obj", "X-Arr"] {
10647 assert!(
10648 !names.contains(&dropped),
10649 "{dropped} must not be emitted: no single-value form"
10650 );
10651 }
10652 assert!(names.contains(&"X-Keep"), "scalar headers must survive");
10653 }
10654
10655 #[test]
10656 fn response_stringifies_scalars_despite_excluded_names() {
10657 let mut headers = HashMap::new();
10661 headers.insert("Content-Length".to_string(), serde_json::json!(999));
10662 headers.insert("Date".to_string(), serde_json::json!(12345));
10663 let selected = select_response_headers(&headers, None, None);
10664 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
10665 assert!(
10666 !names.contains(&"Content-Length"),
10667 "content-length is re-derived by the server"
10668 );
10669 assert!(!names.contains(&"Date"), "date is server-owned");
10670 }
10671
10672 #[test]
10673 fn outbound_stringifies_scalar_header_values() {
10674 let mut headers = make_headers(&[("X-Label", "keep")]);
10675 headers.insert("X-Retries".to_string(), serde_json::json!(3));
10676 headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
10677 headers.insert("X-Enabled".to_string(), serde_json::json!(true));
10678 let outbound = select_outbound_headers(&headers, &[], &[]);
10679 let get = |name: &str| -> Option<String> {
10681 outbound
10682 .accepted
10683 .iter()
10684 .find(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
10685 .map(|(_, v)| v.to_str().unwrap().to_string())
10686 };
10687 assert_eq!(
10688 get("X-Retries").as_deref(),
10689 Some("3"),
10690 "integer header must be stringified"
10691 );
10692 assert_eq!(
10693 get("X-Ratio").as_deref(),
10694 Some("3.5"),
10695 "float header must be stringified"
10696 );
10697 assert_eq!(
10698 get("X-Enabled").as_deref(),
10699 Some("true"),
10700 "bool header must be stringified"
10701 );
10702 assert_eq!(
10703 get("X-Label").as_deref(),
10704 Some("keep"),
10705 "string header must pass through"
10706 );
10707 assert!(outbound.drops.is_empty(), "scalar headers must not drop");
10708 }
10709
10710 #[test]
10711 fn outbound_drops_null_and_structured_header_values() {
10712 let mut headers = make_headers(&[("X-Keep", "yes")]);
10713 headers.insert("X-Null".to_string(), serde_json::Value::Null);
10714 headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
10715 headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
10716 let outbound = select_outbound_headers(&headers, &[], &[]);
10717 let has = |name: &str| {
10718 outbound
10719 .accepted
10720 .iter()
10721 .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
10722 };
10723 assert!(has("X-Keep"), "scalar headers must survive");
10724 for (name, kind) in [("X-Null", "null"), ("X-Obj", "object"), ("X-Arr", "array")] {
10725 let dropped = outbound
10726 .drops
10727 .iter()
10728 .find(|d| d.name == name)
10729 .unwrap_or_else(|| panic!("{name} must have a drop record: {:?}", outbound.drops));
10730 assert_eq!(
10731 dropped.reason, "no scalar string form",
10732 "{name} drop reason must name the value kind absence"
10733 );
10734 assert_eq!(dropped.value_kind, Some(kind), "{name} kind recorded");
10735 }
10736 }
10737
10738 #[test]
10739 fn outbound_stringifies_scalars_despite_excluded_names() {
10740 let mut headers = HashMap::new();
10744 headers.insert("Transfer-Encoding".to_string(), serde_json::json!(7));
10745 headers.insert("Host".to_string(), serde_json::json!(12345));
10746 headers.insert("X-Ok".to_string(), serde_json::json!(7));
10747 let outbound = select_outbound_headers(&headers, &[], &[]);
10748 let has = |name: &str| {
10749 outbound
10750 .accepted
10751 .iter()
10752 .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
10753 };
10754 assert!(
10755 !has("Transfer-Encoding"),
10756 "hop-by-hop header must stay excluded"
10757 );
10758 assert!(!has("Host"), "host is destination-derived");
10759 assert!(has("X-Ok"), "non-excluded scalar must be stringified");
10760 assert!(
10761 outbound
10762 .drops
10763 .iter()
10764 .any(|d| d.name == "Transfer-Encoding" && d.reason == "outbound emission policy"),
10765 "policy drop must be recorded before coercion"
10766 );
10767 }
10768
10769 #[test]
10770 fn outbound_drops_invalid_names_values_and_skip_config() {
10771 let mut headers = make_headers(&[("X-Good", "fine")]);
10772 headers.insert("X Bad Name".to_string(), serde_json::json!("v"));
10773 headers.insert(
10774 "X-Control-Value".to_string(),
10775 serde_json::json!("line1\nline2"),
10776 );
10777 headers.insert("X-Secret".to_string(), serde_json::json!("s3cr3t"));
10778 headers.insert("CamelHttpQuery".to_string(), serde_json::json!("q=1"));
10779 let skip = vec!["x-secret".to_string()];
10780 let outbound = select_outbound_headers(&headers, &skip, &[]);
10781 let has = |name: &str| {
10782 outbound
10783 .accepted
10784 .iter()
10785 .any(|(k, _)| k.as_str().eq_ignore_ascii_case(name))
10786 };
10787 assert!(has("X-Good"), "valid header must survive");
10788 assert!(!has("X Bad Name"), "invalid header name must drop");
10789 assert!(!has("X-Control-Value"), "control-char value must drop");
10790 assert!(!has("X-Secret"), "skipped header must drop");
10791 assert!(!has("CamelHttpQuery"), "Camel-namespace header must drop");
10792 let reason = |n: &str| {
10793 outbound
10794 .drops
10795 .iter()
10796 .find(|d| d.name == n)
10797 .map(|d| d.reason)
10798 };
10799 assert_eq!(reason("X Bad Name"), Some("invalid header name"));
10800 assert_eq!(reason("X-Control-Value"), Some("invalid header value"));
10801 assert_eq!(reason("X-Secret"), Some("skip_request_headers"));
10802 assert_eq!(reason("CamelHttpQuery"), Some("Camel namespace"));
10803 }
10804
10805 async fn start_host_capturing_destination() -> (
10817 String,
10818 Arc<std::sync::Mutex<Option<(String, String)>>>,
10819 tokio::task::JoinHandle<()>,
10820 ) {
10821 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10822 let port = listener.local_addr().unwrap().port();
10823 let url = format!("http://127.0.0.1:{port}");
10824 let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
10825 Arc::new(std::sync::Mutex::new(None));
10826 let captured_clone = Arc::clone(&captured);
10827 let handle = tokio::spawn(async move {
10828 use tokio::io::{AsyncReadExt, AsyncWriteExt};
10829 if let Ok((mut stream, _)) = listener.accept().await {
10830 let mut buf = vec![0u8; 16384];
10831 let n = stream.read(&mut buf).await.unwrap_or(0);
10832 let request = String::from_utf8_lossy(&buf[..n]).to_string();
10833 if request.contains("\r\n\r\n") {
10834 let request_line = request.lines().next().unwrap_or("").to_string();
10835 let host_value = request
10836 .lines()
10837 .find(|l| l.to_lowercase().starts_with("host:"))
10838 .and_then(|l| l.split_once(':'))
10839 .map(|(_, v)| v.trim().to_string())
10840 .unwrap_or_default();
10841 *captured_clone.lock().unwrap() = Some((host_value, request_line));
10842 }
10843 let body = r#"{"echo":"ok"}"#;
10844 let resp = format!(
10845 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
10846 body.len(),
10847 body
10848 );
10849 let _ = stream.write_all(resp.as_bytes()).await;
10850 }
10851 });
10852 (url, captured, handle)
10853 }
10854
10855 #[tokio::test]
10860 async fn bridge_proxy_outbound_host_matches_destination() {
10861 use tower::ServiceExt;
10862
10863 let (url, captured, _handle) = start_host_capturing_destination().await;
10864 let expected_host = url.strip_prefix("http://").unwrap();
10867
10868 let ctx = test_producer_ctx();
10869 let component = HttpComponent::new();
10870 let endpoint_ctx = NoOpComponentContext;
10871 let endpoint = component
10872 .create_endpoint(
10873 &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
10874 &endpoint_ctx,
10875 )
10876 .unwrap();
10877 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
10878
10879 let mut exchange = Exchange::new(Message::default());
10882 exchange.input.set_header("Host", "localhost");
10883 exchange.input.set_header("CamelHttpPath", "/foo");
10884
10885 let result = producer.oneshot(exchange).await;
10886 assert!(result.is_ok(), "producer call failed: {:?}", result);
10887
10888 tokio::time::sleep(Duration::from_millis(100)).await;
10889 let (host_value, request_line) = captured
10890 .lock()
10891 .unwrap()
10892 .take()
10893 .expect("destination capture mutex empty — producer did not reach the destination");
10894
10895 assert_ne!(
10896 host_value, "localhost",
10897 "bridge producer must not forward the exchange Host: localhost"
10898 );
10899 assert_eq!(
10900 host_value, expected_host,
10901 "Host must be derived from the destination authority (no scheme)"
10902 );
10903 assert!(
10904 !request_line.contains("/foo"),
10905 "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
10906 );
10907 }
10908
10909 #[tokio::test]
10914 async fn bridge_proxy_route_set_response_header_survives() {
10915 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
10916
10917 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10918 let port = listener.local_addr().unwrap().port();
10919 drop(listener);
10920
10921 let component = HttpComponent::new();
10922 let endpoint_ctx = NoOpComponentContext;
10923 let endpoint = component
10924 .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
10925 .unwrap();
10926 let mut consumer = endpoint.create_consumer(rt()).unwrap();
10927
10928 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
10929 let token = tokio_util::sync::CancellationToken::new();
10930 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
10931
10932 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10933 tokio::time::sleep(Duration::from_millis(50)).await;
10934
10935 let client = reqwest::Client::new();
10936 let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
10937
10938 let (http_result, _) = tokio::join!(send_fut, async {
10942 if let Some(mut envelope) = rx.recv().await {
10943 envelope
10944 .exchange
10945 .input
10946 .set_header("Cache-Control", "public, max-age=3600");
10947 if let Some(reply_tx) = envelope.reply_tx {
10948 let _ = reply_tx.send(Ok(envelope.exchange));
10949 }
10950 }
10951 });
10952
10953 let resp = http_result.unwrap();
10954 assert_eq!(resp.status().as_u16(), 200);
10955
10956 let cache_control = resp.headers().get("cache-control");
10957 assert!(
10958 cache_control.is_some(),
10959 "Cache-Control header must survive to the wire response"
10960 );
10961 assert_eq!(
10962 cache_control.unwrap().to_str().unwrap(),
10963 "public, max-age=3600"
10964 );
10965
10966 token.cancel();
10967 }
10968
10969 use camel_api::security_policy::CredentialSource;
10988 use camel_auth::credential_source::extract_token_from_exchange;
10989 use camel_auth::native_auth::NativeCredentialStore;
10990 use camel_auth::{StaticTokenAuthenticator, TokenAuthenticator};
10991
10992 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 {
11000 let mut msg = Message::default();
11001 msg.set_header(
11002 "CamelHttpMethod",
11003 serde_json::Value::String(envelope.method.clone()),
11004 );
11005 msg.set_header(
11006 "CamelHttpPath",
11007 serde_json::Value::String(envelope.path.clone()),
11008 );
11009 msg.set_header(
11010 "CamelHttpQuery",
11011 serde_json::Value::String(envelope.query.clone()),
11012 );
11013 for (k, v) in &envelope.headers {
11014 if let Ok(val_str) = v.to_str() {
11015 msg.set_header(
11016 title_case_header(k.as_str()),
11017 serde_json::Value::String(val_str.to_string()),
11018 );
11019 }
11020 }
11021 Exchange::new(msg)
11022 }
11023
11024 async fn spawn_failing_auth_route(
11031 registry: &HttpRouteRegistry,
11032 path: &str,
11033 sources: Vec<CredentialSource>,
11034 ) {
11035 let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
11036 NativeCredentialStore::try_new(vec![]).unwrap(),
11037 ));
11038 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
11039 registry.register_api_route(path.to_string(), tx).await;
11040 let path_owned = path.to_string();
11041 tokio::spawn(async move {
11042 while let Some(envelope) = rx.recv().await {
11043 let exchange = envelope_to_exchange(&envelope);
11044 let reply_tx = envelope.reply_tx;
11045 let result: Result<(), CamelError> = async {
11046 let token = extract_token_from_exchange(&exchange, &sources)
11047 .map(|extracted| extracted.token)
11048 .ok_or_else(|| {
11049 CamelError::Unauthenticated("no credential in any source".into())
11050 })?;
11051 authenticator.authenticate_bearer(&token).await?;
11052 Ok(())
11053 }
11054 .await;
11055 let reply = match result {
11056 Ok(()) => HttpReply {
11057 status: 200,
11058 headers: vec![],
11059 body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
11060 },
11061 Err(e) => pipeline_error_to_reply(e, &path_owned),
11062 };
11063 let _ = reply_tx.send(reply);
11064 }
11065 });
11066 }
11067
11068 fn captured_logs_contain(needle: &str) -> bool {
11072 let buf = tracing_test::internal::global_buf().lock().unwrap();
11073 String::from_utf8_lossy(&buf).contains(needle)
11074 }
11075
11076 #[tracing_test::traced_test]
11077 #[tokio::test]
11078 async fn error_context_redacts_query_sentinel() {
11079 let (port, registry) = spawn_test_server().await;
11080 spawn_failing_auth_route(
11081 ®istry,
11082 "/secure-query",
11083 vec![CredentialSource::QueryParam {
11084 param: "token".to_string(),
11085 }],
11086 )
11087 .await;
11088
11089 let client = reqwest::Client::new();
11090 let resp = client
11091 .get(format!(
11093 "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
11094 ))
11095 .send()
11096 .await
11097 .unwrap();
11098
11099 assert_eq!(resp.status().as_u16(), 401);
11100 let body = resp.text().await.unwrap();
11101 assert_eq!(body, "Unauthorized");
11102 assert!(
11103 !body.contains(SENTINEL_QRY_42),
11104 "reply body must not contain the query credential"
11105 );
11106 assert!(
11107 !captured_logs_contain(SENTINEL_QRY_42),
11108 "no tracing record during request handling may render the query credential"
11109 );
11110 assert!(
11114 captured_logs_contain("Authentication failed"),
11115 "positive control: the failed-auth warn! must be captured by the test subscriber"
11116 );
11117 }
11118
11119 #[tracing_test::traced_test]
11120 #[tokio::test]
11121 async fn error_context_redacts_cookie_sentinel() {
11122 let (port, registry) = spawn_test_server().await;
11123 spawn_failing_auth_route(
11124 ®istry,
11125 "/secure-cookie",
11126 vec![CredentialSource::Cookie {
11127 name: "session".to_string(),
11128 }],
11129 )
11130 .await;
11131
11132 let client = reqwest::Client::new();
11133 let resp = client
11134 .get(format!("http://127.0.0.1:{port}/secure-cookie"))
11135 .header("Cookie", format!("session={SENTINEL_CKY_7}"))
11136 .send()
11137 .await
11138 .unwrap();
11139
11140 assert_eq!(resp.status().as_u16(), 401);
11141 let body = resp.text().await.unwrap();
11142 assert_eq!(body, "Unauthorized");
11143 assert!(
11144 !body.contains(SENTINEL_CKY_7),
11145 "reply body must not contain the cookie credential"
11146 );
11147 assert!(
11148 !captured_logs_contain(SENTINEL_CKY_7),
11149 "no tracing record during request handling may render the cookie credential"
11150 );
11151 }
11152
11153 #[tracing_test::traced_test]
11154 #[tokio::test]
11155 async fn error_reply_no_credential_value() {
11156 let (port, registry) = spawn_test_server().await;
11157 spawn_failing_auth_route(
11158 ®istry,
11159 "/secure-bad",
11160 vec![CredentialSource::Cookie {
11161 name: "session".to_string(),
11162 }],
11163 )
11164 .await;
11165
11166 let client = reqwest::Client::new();
11167 let resp = client
11168 .get(format!("http://127.0.0.1:{port}/secure-bad"))
11169 .header("Cookie", format!("session={SENTINEL_BAD_1}"))
11170 .send()
11171 .await
11172 .unwrap();
11173
11174 assert_eq!(resp.status().as_u16(), 401);
11175 let body = resp.text().await.unwrap();
11176 assert_eq!(body, "Unauthorized");
11177 assert!(
11178 !body.contains(SENTINEL_BAD_1),
11179 "reply body must not contain the credential value"
11180 );
11181 assert!(
11182 !captured_logs_contain(SENTINEL_BAD_1),
11183 "error logs must not render the credential value"
11184 );
11185 }
11186
11187 async fn spawn_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
11201 use tokio::io::AsyncWriteExt;
11202
11203 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
11204 .await
11205 .expect("bind ephemeral 127.0.0.1 listener");
11206 let port = listener.local_addr().expect("local addr").port();
11207 let base_url = format!("http://localhost:{port}");
11208 let handle = tokio::spawn(async move {
11209 while let Ok((mut conn, _)) = listener.accept().await {
11210 let _ = conn
11211 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
11212 .await;
11213 let _ = conn.shutdown().await;
11214 }
11215 });
11216 (base_url, handle)
11217 }
11218
11219 async fn spawn_tls_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
11226 use tokio::io::AsyncWriteExt;
11227
11228 let (_ca_pem, cert_pem, key_pem) =
11229 camel_component_api::test_support::tls::gen_server_cert();
11230 let certs: Vec<_> = rustls_pemfile::certs(&mut cert_pem.as_bytes())
11231 .collect::<Result<_, _>>()
11232 .expect("parse server cert pem");
11233 let key = rustls_pemfile::private_key(&mut key_pem.as_bytes())
11234 .expect("parse server key pem")
11235 .expect("server key present");
11236 let provider =
11239 std::sync::Arc::new(tokio_rustls::rustls::crypto::aws_lc_rs::default_provider());
11240 let tls_cfg = tokio_rustls::rustls::ServerConfig::builder_with_provider(provider)
11241 .with_safe_default_protocol_versions()
11242 .expect("safe default protocol versions")
11243 .with_no_client_auth()
11244 .with_single_cert(certs, key)
11245 .expect("build rustls server config");
11246 let acceptor = tokio_rustls::TlsAcceptor::from(std::sync::Arc::new(tls_cfg));
11247
11248 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
11249 .await
11250 .expect("bind ephemeral 127.0.0.1 listener");
11251 let port = listener.local_addr().expect("local addr").port();
11252 let base_url = format!("https://localhost:{port}");
11253 let handle = tokio::spawn(async move {
11254 while let Ok((conn, _)) = listener.accept().await {
11255 let acceptor = acceptor.clone();
11256 tokio::spawn(async move {
11257 if let Ok(mut tls) = acceptor.accept(conn).await {
11258 let _ = tls
11259 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
11260 .await;
11261 let _ = tls.shutdown().await;
11262 }
11263 });
11264 }
11265 });
11266 (base_url, handle)
11267 }
11268
11269 fn responder_port(base_url: &str) -> u16 {
11273 url::Url::parse(base_url)
11274 .expect("responder base URL parses")
11275 .port()
11276 .expect("responder base URL carries an explicit port")
11277 }
11278
11279 fn endpoint_with_shared_cache(
11284 base_url: &str,
11285 pinned_cache: &Arc<PinnedClientCache>,
11286 ) -> HttpEndpoint {
11287 let uri = format!("{base_url}?allowInternal=true");
11288 HttpEndpoint {
11289 uri: uri.clone(),
11290 config: HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses"),
11291 server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
11292 client: reqwest::Client::new(),
11293 pinned_cache: Arc::clone(pinned_cache),
11294 http_config: HttpConfig::default(),
11295 }
11296 }
11297
11298 #[tokio::test]
11299 async fn producers_share_endpoint_cache() {
11300 use tower::ServiceExt;
11301
11302 let (base_url, _handle) = spawn_multi_accept_200().await;
11303 let pinned_cache = Arc::new(PinnedClientCache::new(
11304 PINNED_CLIENT_TTL,
11305 PINNED_CLIENT_MAX_ENTRIES,
11306 ));
11307
11308 let ctx = test_producer_ctx();
11309 let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
11310 let producer_a = endpoint.create_producer(rt(), &ctx);
11311 let producer_b = endpoint.create_producer(rt(), &ctx);
11312
11313 for producer in [producer_a, producer_b] {
11316 let producer = producer.expect("create producer");
11317 let exchange = Exchange::new(Message::default());
11318 let reply = producer.oneshot(exchange).await;
11319 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
11320 }
11321
11322 assert_eq!(
11323 pinned_cache.build_count(),
11324 1,
11325 "both producers must hit the same shared cache entry; a second \
11326 build means sharing is broken"
11327 );
11328 }
11329
11330 #[tokio::test]
11331 async fn producer_repeated_hostname_requests_build_one_client() {
11332 use tower::ServiceExt;
11333
11334 let (base_url, _handle) = spawn_multi_accept_200().await;
11335 let pinned_cache = Arc::new(PinnedClientCache::new(
11336 PINNED_CLIENT_TTL,
11337 PINNED_CLIENT_MAX_ENTRIES,
11338 ));
11339 let ctx = test_producer_ctx();
11340 let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
11341 let producer = endpoint
11342 .create_producer(rt(), &ctx)
11343 .expect("create producer");
11344
11345 for i in 0..2 {
11348 let exchange = Exchange::new(Message::default());
11349 let reply = producer.clone().oneshot(exchange).await;
11350 assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
11351 }
11352
11353 assert_eq!(
11354 pinned_cache.build_count(),
11355 1,
11356 "repeated hostname requests must reuse the one pinned client; \
11357 0 builds means the producer bypassed the cache, more than 1 \
11358 means the entry was dropped"
11359 );
11360 }
11361
11362 #[tokio::test]
11363 async fn ip_literal_request_never_enters_cache() {
11364 use tower::ServiceExt;
11365
11366 let (base_url, _handle) = spawn_multi_accept_200().await;
11367 let pinned_cache = Arc::new(PinnedClientCache::new(
11368 PINNED_CLIENT_TTL,
11369 PINNED_CLIENT_MAX_ENTRIES,
11370 ));
11371
11372 let ctx = test_producer_ctx();
11373 let destination = format!("http://127.0.0.1:{}/ping", responder_port(&base_url));
11374 let endpoint = endpoint_with_shared_cache(&destination, &pinned_cache);
11375 let producer = endpoint
11376 .create_producer(rt(), &ctx)
11377 .expect("create producer");
11378
11379 let exchange = Exchange::new(Message::default());
11380 let reply = producer.oneshot(exchange).await;
11381 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
11382
11383 assert_eq!(
11384 pinned_cache.build_count(),
11385 0,
11386 "an IP-literal URL must use the shared unpinned client and \
11387 never enter the pinned cache"
11388 );
11389 }
11390
11391 #[tokio::test]
11392 async fn test_component_endpoints_share_pinned_cache() {
11393 use tower::ServiceExt;
11394
11395 let component = HttpComponent::new();
11396 let (base_url, _handle) = spawn_multi_accept_200().await;
11397 let baseline = component.pinned_cache.build_count();
11398
11399 let ctx = test_producer_ctx();
11400 let endpoint_ctx = NoOpComponentContext;
11401 for uri in [
11402 format!("{base_url}/a?allowInternal=true&k=a"),
11403 format!("{base_url}/b?allowInternal=true&k=b"),
11404 ] {
11405 let endpoint = component
11406 .create_endpoint(&uri, &endpoint_ctx)
11407 .expect("create endpoint");
11408 let producer = endpoint
11409 .create_producer(rt(), &ctx)
11410 .expect("create producer");
11411 let exchange = Exchange::new(Message::default());
11412 let reply = producer.oneshot(exchange).await;
11413 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
11414 }
11415
11416 assert_eq!(
11417 component.pinned_cache.build_count() - baseline,
11418 1,
11419 "endpoints created by one component must share its pinned cache; \
11420 0 builds means the endpoints bypassed it, more than 1 means \
11421 per-endpoint caches came back"
11422 );
11423 }
11424
11425 #[tokio::test]
11426 async fn test_dynamic_resolution_sequence_hits_shared_cache() {
11427 use tower::ServiceExt;
11428
11429 let component = HttpComponent::new();
11430 let (base_url, _handle) = spawn_multi_accept_200().await;
11431 let baseline = component.pinned_cache.build_count();
11432
11433 let ctx = test_producer_ctx();
11434 let endpoint_ctx = NoOpComponentContext;
11435 for i in 0..3 {
11436 let endpoint = component
11437 .create_endpoint(
11438 &format!("{base_url}/r{i}?allowInternal=true&k={i}"),
11439 &endpoint_ctx,
11440 )
11441 .expect("create endpoint");
11442 let producer = endpoint
11443 .create_producer(rt(), &ctx)
11444 .expect("create producer");
11445 let exchange = Exchange::new(Message::default());
11446 let reply = producer.oneshot(exchange).await;
11447 assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
11448 }
11449
11450 assert_eq!(
11451 component.pinned_cache.build_count() - baseline,
11452 1,
11453 "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
11454 must reuse the component's one pinned cache entry; 0 builds \
11455 means the endpoints bypassed it, more than 1 means \
11456 per-endpoint caches came back"
11457 );
11458 }
11459
11460 #[tokio::test]
11468 async fn test_https_component_endpoints_share_pinned_cache_behaviorally() {
11469 use tower::ServiceExt;
11470
11471 let http_config = HttpConfig {
11472 tls: Some(crate::config::TlsConfig {
11473 enabled: true,
11474 insecure: true,
11475 ..Default::default()
11476 }),
11477 ..Default::default()
11478 };
11479 let component = HttpsComponent::with_config(http_config);
11480 let (base_url, _handle) = spawn_tls_multi_accept_200().await;
11481 let baseline = component.pinned_cache.build_count();
11482
11483 let ctx = test_producer_ctx();
11484 let endpoint_ctx = NoOpComponentContext;
11485 for uri in [
11486 format!("{base_url}/a?allowInternal=true&k=a"),
11487 format!("{base_url}/b?allowInternal=true&k=b"),
11488 ] {
11489 let endpoint = component
11490 .create_endpoint(&uri, &endpoint_ctx)
11491 .expect("create https endpoint");
11492 let producer = endpoint
11493 .create_producer(rt(), &ctx)
11494 .expect("create producer");
11495 let exchange = Exchange::new(Message::default());
11496 let reply = producer.oneshot(exchange).await;
11497 assert!(reply.is_ok(), "https request failed: {reply:?}");
11498 }
11499
11500 assert_eq!(
11501 component.pinned_cache.build_count() - baseline,
11502 1,
11503 "endpoints of one HttpsComponent must share its pinned cache over \
11504 real https requests; 0 builds means the endpoints bypassed it \
11505 (per-endpoint cache regression), more than 1 means \
11506 per-endpoint caches came back"
11507 );
11508 }
11509
11510 #[test]
11511 fn test_https_component_owns_distinct_cache() {
11512 let http = HttpComponent::new();
11513 let https = HttpsComponent::new();
11514
11515 assert!(
11516 !Arc::ptr_eq(&http.pinned_cache, &https.pinned_cache),
11517 "http and https components must each own their own pinned cache"
11518 );
11519
11520 let endpoint_ctx = NoOpComponentContext;
11521 let _ = http
11522 .create_endpoint("http://localhost:1/?allowInternal=true", &endpoint_ctx)
11523 .expect("http endpoint");
11524 let _ = https
11525 .create_endpoint("https://localhost:1/?allowInternal=true", &endpoint_ctx)
11526 .expect("https endpoint");
11527
11528 assert_eq!(
11529 http.pinned_cache.build_count(),
11530 0,
11531 "endpoint creation must not build a pinned client"
11532 );
11533 assert_eq!(
11534 https.pinned_cache.build_count(),
11535 0,
11536 "endpoint creation must not build a pinned client"
11537 );
11538 }
11539
11540 #[test]
11541 fn test_component_constructor_builds_one_unpinned_client() {
11542 let baseline = build_client_call_count();
11543
11544 let _http = HttpComponent::new();
11545 assert_eq!(
11546 build_client_call_count() - baseline,
11547 1,
11548 "HttpComponent::new() must build exactly one shared unpinned client"
11549 );
11550
11551 let _https = HttpsComponent::new();
11552 assert_eq!(
11553 build_client_call_count() - baseline,
11554 2,
11555 "HttpsComponent::new() must build exactly one more shared unpinned client"
11556 );
11557 }
11558
11559 #[test]
11560 fn test_component_endpoints_share_unpinned_client() {
11561 let component = HttpComponent::new();
11562 let baseline = build_client_call_count();
11563
11564 let endpoint_ctx = NoOpComponentContext;
11565 for uri in [
11566 "http://localhost:1/a?allowInternal=true",
11567 "http://localhost:1/b?allowInternal=true",
11568 ] {
11569 let _endpoint = component
11570 .create_endpoint(uri, &endpoint_ctx)
11571 .expect("create endpoint");
11572 }
11573
11574 assert_eq!(
11575 build_client_call_count() - baseline,
11576 0,
11577 "create_endpoint must clone the component's shared unpinned client, \
11578 never build a fresh one"
11579 );
11580 }
11581
11582 #[test]
11583 fn test_dynamic_resolution_adds_no_unpinned_client_builds() {
11584 let component = HttpComponent::new();
11585 let baseline = build_client_call_count();
11586
11587 let ctx = test_producer_ctx();
11588 let endpoint_ctx = NoOpComponentContext;
11589 for i in 0..3 {
11590 let endpoint = component
11591 .create_endpoint(
11592 &format!("http://localhost:1/r{i}?allowInternal=true&k={i}"),
11593 &endpoint_ctx,
11594 )
11595 .expect("create endpoint");
11596 let _producer = endpoint
11597 .create_producer(rt(), &ctx)
11598 .expect("create producer");
11599 }
11600
11601 assert_eq!(
11602 build_client_call_count() - baseline,
11603 0,
11604 "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
11605 must reuse the component's shared unpinned client and build \
11606 no additional clients"
11607 );
11608 }
11609}