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