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", &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 let mut url = base.to_string();
2559 if let Some(path) = exchange
2560 .input
2561 .header("CamelHttpPath")
2562 .and_then(|v| v.as_str())
2563 {
2564 if !url.ends_with('/') && !path.starts_with('/') {
2565 url.push('/');
2566 }
2567 url.push_str(path);
2568 }
2569 if let Some(query) = exchange
2570 .input
2571 .header("CamelHttpQuery")
2572 .and_then(|v| v.as_str())
2573 {
2574 if let Some(merged) = merge_header_query(override_query, query)? {
2575 url.push('?');
2576 url.push_str(&merged);
2577 }
2578 return Ok(url);
2579 }
2580 if let Some(query) = override_query {
2581 url.push('?');
2582 url.push_str(query);
2583 }
2584 return Ok(url);
2585 }
2586
2587 let mut url = config.base_url.clone();
2588
2589 if let Some(path) = exchange
2590 .input
2591 .header("CamelHttpPath")
2592 .and_then(|v| v.as_str())
2593 {
2594 if !url.ends_with('/') && !path.starts_with('/') {
2595 url.push('/');
2596 }
2597 url.push_str(path);
2598 }
2599
2600 if let Some(query) = exchange
2601 .input
2602 .header("CamelHttpQuery")
2603 .and_then(|v| v.as_str())
2604 {
2605 if let Some(merged) =
2610 merge_header_query(resolve_endpoint_query(config)?.as_deref(), query)?
2611 {
2612 url.push('?');
2613 url.push_str(&merged);
2614 }
2615 return Ok(url);
2616 }
2617
2618 if let Some(query) = resolve_endpoint_query(config)? {
2619 url.push('?');
2620 url.push_str(&query);
2621 }
2622
2623 Ok(url)
2624 }
2625
2626 fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
2627 status >= range.0 && status <= range.1
2628 }
2629}
2630
2631#[derive(Clone, Debug, PartialEq, Eq)]
2636pub struct AllowedUriHost {
2637 pub host: String,
2639 pub port: Option<u16>,
2641}
2642
2643fn parse_allowed_uri_hosts(raw: &str) -> Result<Vec<AllowedUriHost>, CamelError> {
2651 let mut entries = Vec::new();
2652 for segment in raw.split(',') {
2653 let segment = segment.trim();
2654 if segment.is_empty() {
2655 continue;
2656 }
2657 let parsed = url::Url::parse(&format!("http://{segment}"))
2658 .map_err(|_| invalid_allowed_uri_host_entry(segment))?;
2659 if parsed.path() != "/" || !parsed.username().is_empty() || parsed.password().is_some() {
2663 return Err(invalid_allowed_uri_host_entry(segment));
2664 }
2665 let Some(host) = parsed.host_str() else {
2666 return Err(invalid_allowed_uri_host_entry(segment));
2667 };
2668 entries.push(AllowedUriHost {
2669 host: host.to_string(),
2670 port: parsed.port(),
2671 });
2672 }
2673 if entries.is_empty() {
2674 return Err(CamelError::InvalidUri(
2675 "allowedUriHosts declares no valid host entries".to_string(),
2676 ));
2677 }
2678 Ok(entries)
2679}
2680
2681fn invalid_allowed_uri_host_entry(segment: &str) -> CamelError {
2682 CamelError::InvalidUri(format!("invalid allowedUriHosts entry '{segment}'"))
2683}
2684
2685fn uri_host_allowed(url_str: &str, fence: &[AllowedUriHost]) -> Result<bool, CamelError> {
2692 let Ok(parsed) = url::Url::parse(url_str) else {
2693 return Ok(false);
2694 };
2695 let Some(host) = parsed.host_str() else {
2696 return Ok(false);
2697 };
2698 let effective_port = parsed.port().or(match parsed.scheme() {
2699 "https" => Some(443_u16),
2700 "http" => Some(80),
2701 _ => None,
2702 });
2703 Ok(fence.iter().any(|entry| {
2704 entry.host == host
2705 && match entry.port {
2706 None => true,
2707 Some(port) => effective_port == Some(port),
2708 }
2709 }))
2710}
2711
2712fn resolve_endpoint_query(config: &HttpEndpointConfig) -> Result<Option<String>, CamelError> {
2725 let mut parts: Vec<String> = Vec::new();
2726 let mut authored_keys = std::collections::HashSet::new();
2727
2728 if let Some(raw) = config.raw_query.as_deref() {
2729 for (key, span) in raw_query_pairs(raw)? {
2730 authored_keys.insert(key.clone());
2731 if is_consumed_option(&key) {
2732 continue;
2733 }
2734 validate_raw_query_span(span)?;
2735 parts.push(span.to_string());
2736 }
2737 }
2738
2739 for (key, value) in &config.query_params {
2740 if !authored_keys.contains(key.as_str()) {
2741 parts.push(format!(
2742 "{}={}",
2743 encode_query_component(key),
2744 encode_query_component(value)
2745 ));
2746 }
2747 }
2748
2749 if parts.is_empty() && config.raw_query.as_deref() != Some("") {
2750 return Ok(None);
2751 }
2752 Ok(Some(parts.join("&")))
2753}
2754
2755fn merge_header_query(
2764 higher_precedence: Option<&str>,
2765 header_query: &str,
2766) -> Result<Option<String>, CamelError> {
2767 if header_query.is_empty() {
2768 return Ok(higher_precedence.map(str::to_string));
2769 }
2770 let mut parts: Vec<String> = Vec::new();
2771 let mut higher_keys = std::collections::HashSet::new();
2772 for (key, span) in raw_query_pairs(higher_precedence.unwrap_or(""))? {
2773 higher_keys.insert(key);
2774 parts.push(span.to_string());
2775 }
2776 for (key, span) in raw_query_pairs(header_query)? {
2777 validate_raw_query_span(span)?;
2778 if !higher_keys.contains(key.as_str()) {
2779 parts.push(span.to_string());
2780 }
2781 }
2782 if parts.is_empty() {
2783 return Ok(None);
2784 }
2785 Ok(Some(parts.join("&")))
2786}
2787
2788fn is_legal_query_byte(byte: u8) -> bool {
2792 matches!(byte,
2793 b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z'
2794 | b'-' | b'.' | b'_' | b'~'
2795 | b'!' | b'$' | b'&' | b'\'' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'='
2796 | b':' | b'@' | b'/' | b'?'
2797 | b'%')
2798}
2799
2800fn validate_raw_query_span(span: &str) -> Result<(), CamelError> {
2806 for &byte in span.as_bytes() {
2807 if !is_legal_query_byte(byte) {
2808 return Err(CamelError::ProcessorError(format!(
2809 "raw query pair '{span}' contains byte 0x{byte:02X}, which is not legal in a URL query component"
2810 )));
2811 }
2812 }
2813 Ok(())
2814}
2815
2816fn encode_query_component(component: &str) -> String {
2820 const HEX: &[u8; 16] = b"0123456789ABCDEF";
2821 let mut out = String::with_capacity(component.len());
2822 for &byte in component.as_bytes() {
2823 match byte {
2824 b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z' | b'-' | b'.' | b'_' | b'~' => {
2825 out.push(byte as char);
2826 }
2827 _ => {
2828 out.push('%');
2829 out.push(HEX[(byte >> 4) as usize] as char);
2830 out.push(HEX[(byte & 0x0f) as usize] as char);
2831 }
2832 }
2833 }
2834 out
2835}
2836
2837fn redact_url_for_diagnostics(raw: &str) -> String {
2843 const MAX_URL_LOG_LEN: usize = 256;
2844 match url::Url::parse(raw) {
2845 Ok(mut u) => {
2846 if !u.username().is_empty() {
2847 let _ = u.set_username("***");
2848 let _ = u.set_password(None);
2849 }
2850 if u.query().is_some() {
2851 u.set_query(None);
2852 let mut s = u.to_string();
2854 if let Some(stripped) = s.strip_suffix('?') {
2855 s = stripped.to_string();
2856 }
2857 s.push_str("?[redacted]");
2858 if s.len() > MAX_URL_LOG_LEN {
2859 s.truncate(MAX_URL_LOG_LEN);
2860 }
2861 return s;
2862 }
2863 let mut s = u.to_string();
2864 if s.len() > MAX_URL_LOG_LEN {
2865 s.truncate(MAX_URL_LOG_LEN);
2866 }
2867 s
2868 }
2869 Err(_) => {
2870 let mut s = raw.to_string();
2871 s.truncate(MAX_URL_LOG_LEN);
2872 s
2873 }
2874 }
2875}
2876
2877const MAX_ERROR_RESPONSE_BODY_BYTES: usize = 4096;
2882
2883fn truncate_error_body(body: &[u8]) -> String {
2884 if body.len() <= MAX_ERROR_RESPONSE_BODY_BYTES {
2885 String::from_utf8_lossy(body).into_owned()
2886 } else {
2887 let mut s = String::from_utf8_lossy(&body[..MAX_ERROR_RESPONSE_BODY_BYTES]).into_owned();
2888 s.push_str("...[truncated]");
2889 s
2890 }
2891}
2892
2893impl HttpProducer {
2894 fn is_entity_enclosing(method: &str) -> bool {
2899 matches!(method, "POST" | "PUT" | "PATCH")
2900 }
2901}
2902
2903impl Service<Exchange> for HttpProducer {
2904 type Response = Exchange;
2905 type Error = CamelError;
2906 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
2907
2908 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
2909 Poll::Ready(Ok(()))
2910 }
2911
2912 fn call(&mut self, exchange: Exchange) -> Self::Future {
2913 let config = self.config.clone();
2914 let shared_client = self.client.clone();
2915 let pinned_cache = std::sync::Arc::clone(&self.pinned_cache);
2916 let http_config = self.http_config.clone();
2917 let component_metrics = self.runtime.component_metrics();
2918
2919 Box::pin(async move {
2920 let mut exchange = exchange;
2921 let outcome = async {
2922 let method_str = HttpProducer::resolve_method(&exchange, &config);
2923 let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
2928 let url = HttpProducer::resolve_url(&exchange, &config)?;
2929
2930 ssrf::validate_url_for_ssrf(&url, &config)?;
2932
2933 let resolved =
2941 ssrf::resolve_initial_url_for_ssrf(&url, config.allow_internal).await?;
2942 let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
2943 pinned_cache
2944 .get_or_build(host.as_str(), addrs, || {
2945 build_client(&http_config, Some((host.as_str(), addrs)))
2946 })
2947 .await
2948 } else {
2949 shared_client.clone()
2950 };
2951
2952 debug!(
2953 correlation_id = %exchange.correlation_id(),
2954 method = %method_str,
2955 url = %redact_url_for_diagnostics(&url),
2956 "HTTP request"
2957 );
2958
2959 let method = method_str.parse::<reqwest::Method>().map_err(|e| {
2960 CamelError::ProcessorError(format!(
2961 "Invalid HTTP method '{}': {}",
2962 method_str, e
2963 ))
2964 })?;
2965
2966 let mut collected_headers: Vec<(
2968 reqwest::header::HeaderName,
2969 reqwest::header::HeaderValue,
2970 )> = Vec::new();
2971
2972 if let Some(user_agent) = &config.user_agent
2973 && !config.bridge_endpoint
2974 && let Ok(val) = reqwest::header::HeaderValue::from_str(user_agent)
2975 {
2976 collected_headers.push((reqwest::header::USER_AGENT, val));
2977 }
2978
2979 #[cfg(feature = "otel")]
2981 let should_inject_otel = !config.bridge_endpoint;
2982 #[cfg(feature = "otel")]
2983 if should_inject_otel {
2984 let mut otel_headers = HashMap::new();
2985 camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
2986 for (k, v) in otel_headers {
2987 if let (Ok(name), Ok(val)) = (
2988 reqwest::header::HeaderName::from_bytes(k.as_bytes()),
2989 reqwest::header::HeaderValue::from_str(&v),
2990 ) {
2991 collected_headers.push((name, val));
2992 }
2993 }
2994 }
2995
2996 let conn_tokens = header_policy::connection_tokens(
2997 exchange
2998 .input
2999 .headers
3000 .iter()
3001 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3002 .filter_map(|(_, v)| v.as_str()),
3003 );
3004
3005 for (key, value) in &exchange.input.headers {
3006 if !key.starts_with("Camel")
3007 && !config
3008 .skip_request_headers
3009 .iter()
3010 .any(|h| h.eq_ignore_ascii_case(key))
3011 && !header_policy::excluded_outbound(key, &conn_tokens)
3012 && let Some(val_str) = value.as_str()
3013 && let (Ok(name), Ok(val)) = (
3014 reqwest::header::HeaderName::from_bytes(key.as_bytes()),
3015 reqwest::header::HeaderValue::from_str(val_str),
3016 )
3017 {
3018 collected_headers.push((name, val));
3019 }
3020 }
3021
3022 if !config.bridge_endpoint {
3024 match &config.auth {
3025 HttpAuth::None => {}
3026 HttpAuth::Basic { username, password } => {
3027 use base64::Engine;
3028 let credentials = format!("{username}:{password}");
3030 let encoded =
3031 base64::engine::general_purpose::STANDARD.encode(credentials);
3032 if let Ok(val) =
3033 reqwest::header::HeaderValue::from_str(&format!("Basic {encoded}"))
3034 {
3035 collected_headers.push((reqwest::header::AUTHORIZATION, val));
3036 }
3037 }
3038 HttpAuth::Bearer { token } => {
3039 let bearer = format!("Bearer {token}");
3041 if let Ok(val) = reqwest::header::HeaderValue::from_str(&bearer) {
3042 collected_headers.push((reqwest::header::AUTHORIZATION, val));
3043 }
3044 }
3045 }
3046
3047 if config.connection_close
3048 && let Ok(val) = reqwest::header::HeaderValue::from_str("close")
3049 {
3050 collected_headers.push((reqwest::header::CONNECTION, val));
3051 }
3052 }
3053
3054 let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
3056 let materialized_body: Option<Vec<u8>> = if is_stream_body {
3057 if suppress_body {
3058 std::mem::take(&mut exchange.input.body);
3066 tracing::warn!(
3068 correlation_id = %exchange.correlation_id(),
3069 method = %method_str,
3070 "dropping request body for non-entity-enclosing HTTP method"
3071 );
3072 }
3073 None } else {
3075 let body = std::mem::take(&mut exchange.input.body);
3076 let bytes = body.into_bytes(config.max_body_size).await?;
3077 if bytes.is_empty() {
3078 None
3080 } else if suppress_body {
3081 tracing::warn!(
3083 correlation_id = %exchange.correlation_id(),
3084 method = %method_str,
3085 "dropping request body for non-entity-enclosing HTTP method"
3086 );
3087 None
3088 } else {
3089 Some(bytes.to_vec())
3090 }
3091 };
3092
3093 let response = if config.follow_redirects && !is_stream_body {
3094 ssrf::send_with_ssrf_safe_redirects(
3100 &client,
3101 &shared_client,
3102 &pinned_cache,
3103 &http_config,
3104 &config,
3105 method,
3106 &url,
3107 collected_headers,
3108 materialized_body,
3109 config.max_redirects,
3110 config.response_timeout,
3111 )
3112 .await?
3113 } else {
3114 let mut request = client.request(method, &url);
3116
3117 if let Some(timeout) = config.response_timeout {
3118 request = request.timeout(timeout);
3119 }
3120
3121 for (name, value) in &collected_headers {
3122 request = request.header(name, value);
3123 }
3124
3125 if is_stream_body {
3126 if let Body::Stream(ref s) = exchange.input.body {
3127 let mut stream_lock = s.stream.lock().await;
3128 if let Some(stream) = stream_lock.take() {
3129 request = request.body(reqwest::Body::wrap_stream(stream));
3130 } else {
3131 return Err(CamelError::AlreadyConsumed);
3132 }
3133 }
3134 } else if let Some(ref body_bytes) = materialized_body {
3135 request = request.body(body_bytes.clone());
3136 }
3137
3138 request.send().await.map_err(|e| {
3139 CamelError::ProcessorError(format!("HTTP request failed: {e}"))
3140 })?
3141 };
3142
3143 let status_code = response.status().as_u16();
3144 let status_text = response
3145 .status()
3146 .canonical_reason()
3147 .unwrap_or("Unknown")
3148 .to_string();
3149
3150 for (key, value) in response.headers() {
3151 if config
3152 .skip_response_headers
3153 .iter()
3154 .any(|h| h.eq_ignore_ascii_case(key.as_str()))
3155 {
3156 continue;
3157 }
3158 if let Ok(val_str) = value.to_str() {
3159 exchange.input.set_header(
3160 title_case_header(key.as_str()),
3161 serde_json::Value::String(val_str.to_string()),
3162 );
3163 }
3164 }
3165
3166 exchange.input.set_header(
3167 "CamelHttpResponseCode",
3168 serde_json::Value::Number(status_code.into()),
3169 );
3170 exchange.input.set_header(
3171 "CamelHttpResponseText",
3172 serde_json::Value::String(status_text.clone()),
3173 );
3174
3175 let read_timeout = Duration::from_millis(config.read_timeout_ms);
3177 let response_body = tokio::time::timeout(read_timeout, async {
3178 if let Some(content_len) = response.content_length()
3180 && content_len > config.max_response_bytes as u64
3181 {
3182 return Err(CamelError::ProcessorError(format!(
3183 "Response body too large: {} bytes exceeds limit of {} bytes",
3184 content_len, config.max_response_bytes
3185 )));
3186 }
3187 use futures::TryStreamExt;
3189 let mut stream = response.bytes_stream();
3190 let mut total: usize = 0;
3191 let mut collected = Vec::new();
3192 while let Some(chunk) = stream.try_next().await.map_err(|e| {
3193 CamelError::ProcessorError(format!("Failed to read response body: {e}"))
3194 })? {
3195 total += chunk.len();
3196 if total > config.max_response_bytes {
3197 return Err(CamelError::ProcessorError(format!(
3198 "Response body too large: {} bytes exceeds limit of {} bytes",
3199 total, config.max_response_bytes
3200 )));
3201 }
3202 collected.push(chunk);
3203 }
3204 let mut result = bytes::BytesMut::with_capacity(total);
3205 for chunk in collected {
3206 result.extend_from_slice(&chunk);
3207 }
3208 Ok::<bytes::Bytes, CamelError>(result.freeze())
3209 })
3210 .await
3211 .map_err(|_| {
3212 CamelError::ProcessorError(format!(
3213 "Read timeout after {}ms",
3214 config.read_timeout_ms
3215 ))
3216 })??;
3217
3218 if config.throw_exception_on_failure
3219 && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
3220 {
3221 return Err(CamelError::HttpOperationFailed {
3222 method: method_str,
3223 url: redact_url_for_diagnostics(&url),
3226 status_code,
3227 status_text,
3228 response_body: Some(truncate_error_body(&response_body)),
3229 });
3230 }
3231
3232 if !response_body.is_empty() {
3233 exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
3234 }
3235
3236 debug!(
3237 correlation_id = %exchange.correlation_id(),
3238 status = status_code,
3239 url = %redact_url_for_diagnostics(&url),
3240 "HTTP response"
3241 );
3242 Ok(exchange)
3243 }
3244 .await;
3245 component_metrics.observe("http", "request", outcome.is_err());
3252 outcome
3253 })
3254 }
3255}
3256
3257#[cfg(test)]
3267pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
3268
3269fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
3277 match e {
3278 CamelError::Unauthenticated(msg) => {
3279 tracing::warn!(error = %msg, path = %path, "Authentication failed");
3280 HttpReply {
3281 status: 401,
3282 headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
3283 body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
3284 }
3285 }
3286 CamelError::Unauthorized(msg) => {
3287 tracing::warn!(error = %msg, path = %path, "Authorization failed");
3288 HttpReply {
3289 status: 403,
3290 headers: vec![],
3291 body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
3292 }
3293 }
3294 CamelError::TypeConversionFailed(msg) => {
3295 tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
3296 let body = serde_json::to_string(&serde_json::json!({
3297 "error": "bad_request",
3298 "message": msg,
3299 }))
3300 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
3302 status: 400,
3303 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
3304 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
3305 }
3306 }
3307 CamelError::ValidationError(msg) => {
3308 tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
3309 let body = serde_json::to_string(&serde_json::json!({
3310 "error": "validation_error",
3311 "message": msg,
3312 }))
3313 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
3315 status: 400,
3316 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
3317 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
3318 }
3319 }
3320 CamelError::ConsumerStopping => {
3321 tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
3322 HttpReply {
3323 status: 503,
3324 headers: vec![],
3325 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
3326 }
3327 }
3328 e => {
3329 tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
3331 HttpReply {
3332 status: 500,
3333 headers: vec![],
3334 body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
3335 }
3336 }
3337 }
3338}
3339
3340const fn json_value_kind(v: &serde_json::Value) -> &'static str {
3344 match v {
3345 serde_json::Value::Null => "null",
3346 serde_json::Value::Bool(_) => "bool",
3347 serde_json::Value::Number(_) => "number",
3348 serde_json::Value::String(_) => "string",
3349 serde_json::Value::Array(_) => "array",
3350 serde_json::Value::Object(_) => "object",
3351 }
3352}
3353
3354fn select_response_headers(
3369 headers: &HashMap<String, serde_json::Value>,
3370 user_content_type: Option<String>,
3371 inferred_content_type: Option<String>,
3372) -> Vec<(String, String)> {
3373 let conn_tokens = header_policy::connection_tokens(
3374 headers
3375 .iter()
3376 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3377 .filter_map(|(_, v)| v.as_str()),
3378 );
3379 let mut selected: Vec<(String, String)> = Vec::new();
3380 for (k, v) in headers {
3381 if k.starts_with("Camel") {
3382 debug!(header = %k, "reply header dropped: Camel namespace");
3383 continue;
3384 }
3385 if header_policy::excluded_response(k, &conn_tokens) {
3386 debug!(header = %k, "reply header dropped: emission policy");
3387 continue;
3388 }
3389 let scalar = match v {
3390 serde_json::Value::String(s) => Some(s.clone()),
3391 serde_json::Value::Number(n) => Some(n.to_string()),
3392 serde_json::Value::Bool(b) => Some(b.to_string()),
3393 _ => None,
3394 };
3395 match scalar {
3396 Some(s) => selected.push((k.clone(), s)),
3397 None => debug!(
3398 header = %k,
3399 value_kind = json_value_kind(v),
3400 "reply header dropped: no scalar string form"
3401 ),
3402 }
3403 }
3404 if let Some(ct) = user_content_type.or(inferred_content_type) {
3405 selected.push(("Content-Type".to_string(), ct));
3406 }
3407 selected
3408}
3409
3410#[cfg(test)]
3411mod tests {
3412 use camel_component_api::test_support::NoopRuntimeObservability;
3413
3414 fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3418 std::sync::Arc::new(NoopRuntimeObservability)
3419 }
3420 fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3421 std::sync::Arc::new(NoopRuntimeObservability)
3422 }
3423 fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3424 std::sync::Arc::new(NoopRuntimeObservability)
3425 }
3426
3427 use super::*;
3428 use crate::rest_match::PathSegment;
3429 use camel_component_api::{Message, NoOpComponentContext};
3430 use std::sync::Arc;
3431 use std::time::Duration;
3432
3433 fn test_producer_ctx() -> ProducerContext {
3434 ProducerContext::new()
3435 }
3436
3437 #[test]
3442 fn redact_url_masks_userinfo_and_query() {
3443 let redacted =
3444 redact_url_for_diagnostics("http://user:secretpass@internal.example/api?token=abc123");
3445 assert!(
3446 !redacted.contains("secretpass"),
3447 "password must be masked: {redacted}"
3448 );
3449 assert!(
3450 !redacted.contains("token=abc123"),
3451 "query must be masked: {redacted}"
3452 );
3453 assert!(
3454 !redacted.contains("user@"),
3455 "username must be masked: {redacted}"
3456 );
3457 assert!(
3458 redacted.contains("internal.example"),
3459 "host stays visible: {redacted}"
3460 );
3461 assert!(redacted.contains("[redacted]"), "query marked: {redacted}");
3462 }
3463
3464 #[test]
3465 fn redact_url_keeps_clean_urls_visible() {
3466 let redacted = redact_url_for_diagnostics("https://api.example.com/v1/items");
3467 assert_eq!(redacted, "https://api.example.com/v1/items");
3468 }
3469
3470 #[test]
3471 fn redact_url_truncates_unparseable() {
3472 let long = "x".repeat(1000);
3473 let redacted = redact_url_for_diagnostics(&long);
3474 assert_eq!(redacted.len(), 256, "unparseable URL must be truncated");
3475 }
3476
3477 #[test]
3478 fn truncate_error_body_caps_attacker_body() {
3479 let big = vec![b'A'; 10 * 1024 * 1024];
3480 let truncated = truncate_error_body(&big);
3481 assert!(
3482 truncated.len() <= MAX_ERROR_RESPONSE_BODY_BYTES + 20,
3483 "body must be capped near {} bytes, got {}",
3484 MAX_ERROR_RESPONSE_BODY_BYTES,
3485 truncated.len()
3486 );
3487 assert!(truncated.ends_with("...[truncated]"));
3488 }
3489
3490 #[test]
3491 fn truncate_error_body_keeps_small_body() {
3492 assert_eq!(truncate_error_body(b"boom"), "boom");
3493 }
3494
3495 #[test]
3496 fn test_http_config_defaults() {
3497 let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
3498 assert_eq!(config.base_url, "http://localhost:8080/api");
3499 assert!(config.http_method.is_none());
3500 assert!(config.throw_exception_on_failure);
3501 assert_eq!(config.ok_status_code_range, (200, 299));
3502 assert!(config.response_timeout.is_none());
3503 assert!(matches!(config.auth, HttpAuth::None));
3504 assert!(!config.bridge_endpoint);
3505 assert!(!config.connection_close);
3506 }
3507
3508 #[test]
3509 fn test_http_config_scheme() {
3510 assert_eq!(HttpEndpointConfig::scheme(), "http");
3512 }
3513
3514 #[test]
3515 fn test_http_config_from_components() {
3516 let components = camel_component_api::UriComponents {
3518 scheme: "https".to_string(),
3519 path: "//api.example.com/v1".to_string(),
3520 params: std::collections::HashMap::from([(
3521 "httpMethod".to_string(),
3522 "POST".to_string(),
3523 )]),
3524 raw_query: None,
3525 };
3526 let config = HttpEndpointConfig::from_components(components).unwrap();
3527 assert_eq!(config.base_url, "https://api.example.com/v1");
3528 assert_eq!(config.http_method, Some("POST".to_string()));
3529 }
3530
3531 #[test]
3532 fn test_http_config_with_options() {
3533 let config = HttpEndpointConfig::from_uri(
3534 "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
3535 ).unwrap();
3536 assert_eq!(config.base_url, "https://api.example.com/v1");
3537 assert_eq!(config.http_method, Some("PUT".to_string()));
3538 assert!(!config.throw_exception_on_failure);
3539 assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
3540 }
3541
3542 #[test]
3543 fn test_http_endpoint_config_auth_and_headers_options() {
3544 let config = HttpEndpointConfig::from_uri(
3545 "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
3546 )
3547 .unwrap();
3548
3549 assert!(matches!(
3550 config.auth,
3551 HttpAuth::Basic { username, password } if username == "u" && password == "p"
3552 ));
3553 assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
3554 assert!(config.bridge_endpoint);
3555 assert!(config.connection_close);
3556 assert_eq!(
3557 config.skip_request_headers,
3558 vec!["authorization".to_string(), "x-secret".to_string()]
3559 );
3560 assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
3561 }
3562
3563 #[test]
3564 fn test_http_endpoint_config_bearer_auth() {
3565 let config = HttpEndpointConfig::from_uri(
3566 "http://localhost/api?authMethod=Bearer&authBearerToken=t",
3567 )
3568 .unwrap();
3569 assert!(matches!(
3570 config.auth,
3571 HttpAuth::Bearer { token } if token == "t"
3572 ));
3573 }
3574
3575 #[test]
3576 fn rejects_cookie_handling_inmemory() {
3577 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
3578 match result {
3579 Err(CamelError::InvalidUri(msg)) => {
3580 assert!(
3581 msg.contains("cookieHandling is not supported"),
3582 "expected rejection message, got: {msg}"
3583 );
3584 }
3585 other => panic!("expected InvalidUri error, got: {other:?}"),
3586 }
3587 }
3588
3589 #[test]
3590 fn rejects_cookie_handling_disabled() {
3591 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
3592 match result {
3593 Err(CamelError::InvalidUri(msg)) => {
3594 assert!(
3595 msg.contains("cookieHandling is not supported"),
3596 "expected rejection message, got: {msg}"
3597 );
3598 }
3599 other => panic!("expected InvalidUri error, got: {other:?}"),
3600 }
3601 }
3602
3603 #[test]
3604 fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
3605 let config = HttpConfig::default()
3606 .with_response_timeout_ms(999)
3607 .with_allow_internal(true)
3608 .with_blocked_hosts(vec!["evil.com".to_string()])
3609 .with_max_body_size(12345);
3610 let endpoint =
3611 HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
3612 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
3613 assert!(endpoint.allow_internal);
3614 assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
3615 assert_eq!(endpoint.max_body_size, 12345);
3616 }
3617
3618 #[test]
3619 fn test_from_uri_with_defaults_uri_overrides_config() {
3620 let config = HttpConfig::default()
3621 .with_response_timeout_ms(999)
3622 .with_allow_internal(true)
3623 .with_blocked_hosts(vec!["evil.com".to_string()])
3624 .with_max_body_size(12345);
3625 let endpoint = HttpEndpointConfig::from_uri_with_defaults(
3626 "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
3627 &config,
3628 )
3629 .unwrap();
3630 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
3631 assert!(!endpoint.allow_internal);
3632 assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
3633 assert_eq!(endpoint.max_body_size, 99);
3634 }
3635
3636 #[test]
3637 fn test_http_config_ok_status_range() {
3638 let config =
3639 HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
3640 assert_eq!(config.ok_status_code_range, (200, 204));
3641 }
3642
3643 #[test]
3644 fn test_http_config_wrong_scheme() {
3645 let result = HttpEndpointConfig::from_uri("file:/tmp");
3646 assert!(result.is_err());
3647 }
3648
3649 #[test]
3650 fn test_http_component_scheme() {
3651 let component = HttpComponent::new();
3652 assert_eq!(component.scheme(), "http");
3653 }
3654
3655 #[test]
3656 fn test_https_component_scheme() {
3657 let component = HttpsComponent::new();
3658 assert_eq!(component.scheme(), "https");
3659 }
3660
3661 #[test]
3662 fn test_http_endpoint_creates_consumer() {
3663 let component = HttpComponent::new();
3664 let ctx = NoOpComponentContext;
3665 let endpoint = component
3666 .create_endpoint("http://0.0.0.0:19100/test", &ctx)
3667 .unwrap();
3668 assert!(endpoint.create_consumer(rt()).is_ok());
3669 }
3670
3671 #[test]
3672 fn test_https_endpoint_creates_consumer_errors_without_tls() {
3673 let component = HttpsComponent::new();
3674 let ctx = NoOpComponentContext;
3675 let endpoint = component
3676 .create_endpoint("https://0.0.0.0:8443/test", &ctx)
3677 .unwrap();
3678 assert!(endpoint.create_consumer(rt()).is_err());
3680 }
3681
3682 #[test]
3683 fn test_http_endpoint_creates_producer() {
3684 let ctx = test_producer_ctx();
3685 let component = HttpComponent::new();
3686 let endpoint_ctx = NoOpComponentContext;
3687 let endpoint = component
3688 .create_endpoint("http://localhost/api", &endpoint_ctx)
3689 .unwrap();
3690 assert!(endpoint.create_producer(rt(), &ctx).is_ok());
3691 }
3692
3693 #[tokio::test]
3698 async fn test_producer_with_token_provider() {
3699 use camel_auth::oauth2::TokenProvider;
3700 use tower::ServiceExt;
3701
3702 let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
3703 Arc::new(std::sync::Mutex::new(None));
3704 let captured_clone = Arc::clone(&captured_auth);
3705
3706 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3707 let port = listener.local_addr().unwrap().port();
3708
3709 let _handle = tokio::spawn(async move {
3710 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3711 if let Ok((mut stream, _)) = listener.accept().await {
3712 let mut buf = vec![0u8; 8192];
3713 let n = stream.read(&mut buf).await.unwrap_or(0);
3714 let request = String::from_utf8_lossy(&buf[..n]).to_string();
3715 let auth = request
3716 .lines()
3717 .find(|l| l.to_lowercase().starts_with("authorization:"))
3718 .map(|l| {
3719 l.split(':')
3720 .nth(1)
3721 .map(|s| s.trim().to_string())
3722 .unwrap_or_default()
3723 });
3724 *captured_clone.lock().unwrap() = auth;
3725 let body = r#"{"echo":"ok"}"#;
3726 let resp = format!(
3727 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
3728 body.len(),
3729 body
3730 );
3731 let _ = stream.write_all(resp.as_bytes()).await;
3732 }
3733 });
3734
3735 #[derive(Debug)]
3736 struct StaticProvider;
3737 #[async_trait::async_trait]
3738 impl TokenProvider for StaticProvider {
3739 async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
3740 Ok("injected-token".into())
3741 }
3742 }
3743
3744 let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
3745 let ctx = test_producer_ctx();
3746 let component = HttpComponent::new();
3747 let endpoint_ctx = NoOpComponentContext;
3748 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
3749 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3750
3751 let exchange = Exchange::new(Message::new("hello"));
3752
3753 let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
3754 let mut layered = layer.layer(producer);
3755 let result = layered.ready().await.unwrap().call(exchange).await;
3756 assert!(result.is_ok(), "producer call failed: {:?}", result);
3757
3758 tokio::time::sleep(Duration::from_millis(100)).await;
3759 let auth = captured_auth.lock().unwrap().take();
3760 assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
3761 }
3762
3763 async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
3764 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3765 let addr = listener.local_addr().unwrap();
3766 let url = format!("http://127.0.0.1:{}", addr.port());
3767
3768 let handle = tokio::spawn(async move {
3769 loop {
3770 if let Ok((mut stream, _)) = listener.accept().await {
3771 tokio::spawn(async move {
3772 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3773 let mut buf = vec![0u8; 4096];
3774 let n = stream.read(&mut buf).await.unwrap_or(0);
3775 let request = String::from_utf8_lossy(&buf[..n]).to_string();
3776
3777 let method = request.split_whitespace().next().unwrap_or("GET");
3778
3779 let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
3780 let response = format!(
3781 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
3782 body.len(),
3783 body
3784 );
3785 let _ = stream.write_all(response.as_bytes()).await;
3786 });
3787 }
3788 }
3789 });
3790
3791 (url, handle)
3792 }
3793
3794 async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
3795 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3796 let addr = listener.local_addr().unwrap();
3797 let url = format!("http://127.0.0.1:{}", addr.port());
3798
3799 let handle = tokio::spawn(async move {
3800 loop {
3801 if let Ok((mut stream, _)) = listener.accept().await {
3802 let status = status;
3803 tokio::spawn(async move {
3804 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3805 let mut buf = vec![0u8; 4096];
3806 let _ = stream.read(&mut buf).await;
3807
3808 let status_text = match status {
3809 404 => "Not Found",
3810 500 => "Internal Server Error",
3811 _ => "Error",
3812 };
3813 let body = "error body";
3814 let response = format!(
3815 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
3816 status,
3817 status_text,
3818 body.len(),
3819 body
3820 );
3821 let _ = stream.write_all(response.as_bytes()).await;
3822 });
3823 }
3824 }
3825 });
3826
3827 (url, handle)
3828 }
3829
3830 async fn start_request_capturing_server() -> (
3831 String,
3832 Arc<std::sync::Mutex<Option<String>>>,
3833 tokio::task::JoinHandle<()>,
3834 ) {
3835 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3836 let port = listener.local_addr().unwrap().port();
3837 let url = format!("http://127.0.0.1:{port}");
3838 let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
3839 let captured_clone = Arc::clone(&captured);
3840 let handle = tokio::spawn(async move {
3841 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3842 if let Ok((mut stream, _)) = listener.accept().await {
3843 let mut buf = vec![0u8; 16384];
3844 let n = stream.read(&mut buf).await.unwrap_or(0);
3845 let request = String::from_utf8_lossy(&buf[..n]).to_string();
3846 if request.contains("\r\n\r\n") {
3847 *captured_clone.lock().unwrap() = Some(request);
3848 }
3849 let body = r#"{"echo":"ok"}"#;
3850 let resp = format!(
3851 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
3852 body.len(),
3853 body
3854 );
3855 let _ = stream.write_all(resp.as_bytes()).await;
3856 }
3857 });
3858 (url, captured, handle)
3859 }
3860
3861 #[tokio::test]
3862 async fn test_http_producer_get_request() {
3863 use tower::ServiceExt;
3864
3865 let (url, _handle) = start_test_server().await;
3866 let ctx = test_producer_ctx();
3867
3868 let component = HttpComponent::new();
3869 let endpoint_ctx = NoOpComponentContext;
3870 let endpoint = component
3871 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
3872 .unwrap();
3873 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3874
3875 let exchange = Exchange::new(Message::default());
3876 let result = producer.oneshot(exchange).await.unwrap();
3877
3878 let status = result
3879 .input
3880 .header("CamelHttpResponseCode")
3881 .and_then(|v| v.as_u64())
3882 .unwrap();
3883 assert_eq!(status, 200);
3884
3885 assert!(!result.input.body.is_empty());
3886 }
3887
3888 #[tokio::test]
3889 async fn producer_excludes_host_and_framing() {
3890 use tower::ServiceExt;
3891
3892 let (url, captured, _handle) = start_request_capturing_server().await;
3893 let ctx = test_producer_ctx();
3894 let component = HttpComponent::new();
3895 let endpoint_ctx = NoOpComponentContext;
3896 let endpoint = component
3897 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
3898 .unwrap();
3899 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3900
3901 let mut exchange = Exchange::new(Message::default());
3902 exchange.input.set_header("Host", "localhost");
3903 exchange.input.set_header("Content-Length", "42");
3904 exchange.input.set_header("Connection", "keep-alive");
3905 exchange.input.set_header("Upgrade", "h2c");
3906
3907 let result = producer.oneshot(exchange).await;
3908 assert!(result.is_ok(), "producer call failed: {:?}", result);
3909
3910 tokio::time::sleep(Duration::from_millis(100)).await;
3911 let request = captured
3912 .lock()
3913 .unwrap()
3914 .take()
3915 .expect("no outbound request captured");
3916 let lower = request.to_ascii_lowercase();
3917 assert!(
3918 !lower.contains("\r\nhost: localhost"),
3919 "forwarded Host: localhost must be stripped\n{request}"
3920 );
3921 assert!(
3922 !lower.contains("content-length: 42"),
3923 "exchange Content-Length must not be copied\n{request}"
3924 );
3925 assert!(
3926 !lower.lines().any(|l| l.starts_with("connection:")),
3927 "Connection header must not be forwarded\n{request}"
3928 );
3929 assert!(
3930 !lower.lines().any(|l| l.starts_with("upgrade:")),
3931 "Upgrade header must not be forwarded\n{request}"
3932 );
3933 let host_header = lower
3934 .lines()
3935 .find(|l| l.starts_with("host:"))
3936 .map(|l| l.split_once(':').map(|(_, v)| v).unwrap_or("").trim())
3937 .expect("outbound Host header must be set by reqwest");
3938 assert!(
3939 host_header.starts_with("127.0.0.1:"),
3940 "outbound Host '{host_header}' must match the capture-server address"
3941 );
3942 }
3943
3944 #[tokio::test]
3945 async fn producer_forwards_request_only_headers() {
3946 use tower::ServiceExt;
3947
3948 let (url, captured, _handle) = start_request_capturing_server().await;
3949 let ctx = test_producer_ctx();
3950 let component = HttpComponent::new();
3951 let endpoint_ctx = NoOpComponentContext;
3952 let endpoint = component
3953 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
3954 .unwrap();
3955 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3956
3957 let mut exchange = Exchange::new(Message::default());
3958 exchange.input.set_header("Accept", "application/json");
3959 exchange.input.set_header("User-Agent", "myclient/1.0");
3960
3961 let result = producer.oneshot(exchange).await;
3962 assert!(result.is_ok(), "producer call failed: {:?}", result);
3963
3964 tokio::time::sleep(Duration::from_millis(100)).await;
3965 let request = captured
3966 .lock()
3967 .unwrap()
3968 .take()
3969 .expect("no outbound request captured");
3970 let lower = request.to_ascii_lowercase();
3971 assert!(
3972 lower.contains("accept: application/json"),
3973 "request-only Accept header must be forwarded\n{request}"
3974 );
3975 assert!(
3976 lower.contains("user-agent: myclient/1.0"),
3977 "request-only User-Agent header must be forwarded\n{request}"
3978 );
3979 }
3980
3981 #[tokio::test]
3982 async fn producer_honours_skip_request_headers() {
3983 use tower::ServiceExt;
3984
3985 let (url, captured, _handle) = start_request_capturing_server().await;
3986 let ctx = test_producer_ctx();
3987 let component = HttpComponent::new();
3988 let endpoint_ctx = NoOpComponentContext;
3989 let endpoint = component
3990 .create_endpoint(
3991 &format!("{url}/api/test?allowInternal=true&skipRequestHeaders=Authorization"),
3992 &endpoint_ctx,
3993 )
3994 .unwrap();
3995 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3996
3997 let mut exchange = Exchange::new(Message::default());
3998 exchange.input.set_header("Authorization", "Bearer x");
3999
4000 let result = producer.oneshot(exchange).await;
4001 assert!(result.is_ok(), "producer call failed: {:?}", result);
4002
4003 tokio::time::sleep(Duration::from_millis(100)).await;
4004 let request = captured
4005 .lock()
4006 .unwrap()
4007 .take()
4008 .expect("no outbound request captured");
4009 assert!(
4010 !request.to_ascii_lowercase().contains("authorization"),
4011 "Authorization must be stripped by skipRequestHeaders\n{request}"
4012 );
4013 }
4014
4015 #[tokio::test]
4016 async fn test_http_producer_post_with_body() {
4017 use tower::ServiceExt;
4018
4019 let (url, _handle) = start_test_server().await;
4020 let ctx = test_producer_ctx();
4021
4022 let component = HttpComponent::new();
4023 let endpoint_ctx = NoOpComponentContext;
4024 let endpoint = component
4025 .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
4026 .unwrap();
4027 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4028
4029 let exchange = Exchange::new(Message::new("request body"));
4030 let result = producer.oneshot(exchange).await.unwrap();
4031
4032 let status = result
4033 .input
4034 .header("CamelHttpResponseCode")
4035 .and_then(|v| v.as_u64())
4036 .unwrap();
4037 assert_eq!(status, 200);
4038 }
4039
4040 #[tokio::test]
4041 async fn test_http_producer_method_from_header() {
4042 use tower::ServiceExt;
4043
4044 let (url, _handle) = start_test_server().await;
4045 let ctx = test_producer_ctx();
4046
4047 let component = HttpComponent::new();
4048 let endpoint_ctx = NoOpComponentContext;
4049 let endpoint = component
4050 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4051 .unwrap();
4052 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4053
4054 let mut exchange = Exchange::new(Message::default());
4055 exchange.input.set_header(
4056 "CamelHttpMethod",
4057 serde_json::Value::String("DELETE".to_string()),
4058 );
4059
4060 let result = producer.oneshot(exchange).await.unwrap();
4061 let status = result
4062 .input
4063 .header("CamelHttpResponseCode")
4064 .and_then(|v| v.as_u64())
4065 .unwrap();
4066 assert_eq!(status, 200);
4067 }
4068
4069 #[tokio::test]
4070 async fn test_http_producer_forced_method() {
4071 use tower::ServiceExt;
4072
4073 let (url, _handle) = start_test_server().await;
4074 let ctx = test_producer_ctx();
4075
4076 let component = HttpComponent::new();
4077 let endpoint_ctx = NoOpComponentContext;
4078 let endpoint = component
4079 .create_endpoint(
4080 &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
4081 &endpoint_ctx,
4082 )
4083 .unwrap();
4084 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4085
4086 let exchange = Exchange::new(Message::default());
4087 let result = producer.oneshot(exchange).await.unwrap();
4088
4089 let status = result
4090 .input
4091 .header("CamelHttpResponseCode")
4092 .and_then(|v| v.as_u64())
4093 .unwrap();
4094 assert_eq!(status, 200);
4095 }
4096
4097 #[tokio::test]
4098 async fn test_http_producer_throw_exception_on_failure() {
4099 use tower::ServiceExt;
4100
4101 let (url, _handle) = start_status_server(404).await;
4102 let ctx = test_producer_ctx();
4103
4104 let component = HttpComponent::new();
4105 let endpoint_ctx = NoOpComponentContext;
4106 let endpoint = component
4107 .create_endpoint(
4108 &format!("{url}/not-found?allowInternal=true"),
4109 &endpoint_ctx,
4110 )
4111 .unwrap();
4112 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4113
4114 let exchange = Exchange::new(Message::default());
4115 let result = producer.oneshot(exchange).await;
4116 assert!(result.is_err());
4117
4118 match result.unwrap_err() {
4119 CamelError::HttpOperationFailed { status_code, .. } => {
4120 assert_eq!(status_code, 404);
4121 }
4122 e => panic!("Expected HttpOperationFailed, got: {e}"),
4123 }
4124 }
4125
4126 #[tokio::test]
4127 async fn test_http_producer_no_throw_on_failure() {
4128 use tower::ServiceExt;
4129
4130 let (url, _handle) = start_status_server(500).await;
4131 let ctx = test_producer_ctx();
4132
4133 let component = HttpComponent::new();
4134 let endpoint_ctx = NoOpComponentContext;
4135 let endpoint = component
4136 .create_endpoint(
4137 &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
4138 &endpoint_ctx,
4139 )
4140 .unwrap();
4141 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4142
4143 let exchange = Exchange::new(Message::default());
4144 let result = producer.oneshot(exchange).await.unwrap();
4145
4146 let status = result
4147 .input
4148 .header("CamelHttpResponseCode")
4149 .and_then(|v| v.as_u64())
4150 .unwrap();
4151 assert_eq!(status, 500);
4152 }
4153
4154 #[tokio::test]
4155 async fn test_http_producer_uri_override() {
4156 use tower::ServiceExt;
4157
4158 let (url, _handle) = start_test_server().await;
4159 let ctx = test_producer_ctx();
4160
4161 let component = HttpComponent::new();
4162 let endpoint_ctx = NoOpComponentContext;
4163 let endpoint = component
4164 .create_endpoint(
4165 "http://localhost:1/does-not-exist?allowInternal=true",
4166 &endpoint_ctx,
4167 )
4168 .unwrap();
4169 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4170
4171 let mut exchange = Exchange::new(Message::default());
4172 exchange.input.set_header(
4173 "CamelHttpUri",
4174 serde_json::Value::String(format!("{url}/api")),
4175 );
4176
4177 let result = producer.oneshot(exchange).await.unwrap();
4178 let status = result
4179 .input
4180 .header("CamelHttpResponseCode")
4181 .and_then(|v| v.as_u64())
4182 .unwrap();
4183 assert_eq!(status, 200);
4184 }
4185
4186 #[tokio::test]
4187 async fn test_http_producer_response_headers_mapped() {
4188 use tower::ServiceExt;
4189
4190 let (url, _handle) = start_test_server().await;
4191 let ctx = test_producer_ctx();
4192
4193 let component = HttpComponent::new();
4194 let endpoint_ctx = NoOpComponentContext;
4195 let endpoint = component
4196 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4197 .unwrap();
4198 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4199
4200 let exchange = Exchange::new(Message::default());
4201 let result = producer.oneshot(exchange).await.unwrap();
4202
4203 assert!(
4204 result.input.header("Content-Type").is_some(),
4205 "Response should have Content-Type header"
4206 );
4207 assert!(result.input.header("CamelHttpResponseText").is_some());
4208 }
4209
4210 async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
4215 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4216 let addr = listener.local_addr().unwrap();
4217 let url = format!("http://127.0.0.1:{}", addr.port());
4218
4219 let handle = tokio::spawn(async move {
4220 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4221 loop {
4222 if let Ok((mut stream, _)) = listener.accept().await {
4223 tokio::spawn(async move {
4224 let mut buf = vec![0u8; 4096];
4225 let n = stream.read(&mut buf).await.unwrap_or(0);
4226 let request = String::from_utf8_lossy(&buf[..n]).to_string();
4227
4228 if request.contains("GET /final") {
4230 let body = r#"{"status":"final"}"#;
4231 let response = format!(
4232 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
4233 body.len(),
4234 body
4235 );
4236 let _ = stream.write_all(response.as_bytes()).await;
4237 } else {
4238 let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
4243 let _ = stream.write_all(response.as_bytes()).await;
4244 }
4245 });
4246 }
4247 }
4248 });
4249
4250 (url, handle)
4251 }
4252
4253 struct CapturedRequest {
4254 method: String,
4255 path: String,
4256 body: Vec<u8>,
4257 content_length: Option<String>,
4258 transfer_encoding: Option<String>,
4259 }
4260
4261 async fn capture_request(stream: &mut tokio::net::TcpStream) -> Option<CapturedRequest> {
4267 use tokio::io::AsyncReadExt;
4268
4269 let mut buf: Vec<u8> = Vec::new();
4271 let mut chunk = [0u8; 4096];
4272 let head_end: usize;
4273 loop {
4274 let n = stream.read(&mut chunk).await.unwrap_or(0);
4275 if n == 0 {
4276 return None;
4277 }
4278 buf.extend_from_slice(&chunk[..n]);
4279 if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
4280 head_end = pos + 4;
4281 break;
4282 }
4283 }
4284
4285 let head = String::from_utf8_lossy(&buf[..head_end]);
4287 let mut lines = head.split("\r\n");
4288 let request_line = lines.next().unwrap_or("");
4289 let mut parts = request_line.split_whitespace();
4290 let method = parts.next().unwrap_or("").to_string();
4291 let path = parts.next().unwrap_or("").to_string();
4292
4293 let mut content_length: Option<String> = None;
4294 let mut transfer_encoding: Option<String> = None;
4295 for line in lines {
4296 if let Some((name, value)) = line.split_once(':') {
4297 let name = name.trim().to_ascii_lowercase();
4298 let value = value.trim().to_string();
4299 if name == "content-length" {
4300 content_length = Some(value);
4301 } else if name == "transfer-encoding" {
4302 transfer_encoding = Some(value);
4303 }
4304 }
4305 }
4306
4307 let body_len: usize = content_length
4309 .as_deref()
4310 .and_then(|v| v.parse::<usize>().ok())
4311 .unwrap_or(0);
4312
4313 let mut body: Vec<u8> = buf[head_end..].to_vec();
4314 while body.len() < body_len {
4315 let n = stream.read(&mut chunk).await.unwrap_or(0);
4316 if n == 0 {
4317 break;
4318 }
4319 body.extend_from_slice(&chunk[..n]);
4320 }
4321 body.truncate(body_len);
4322
4323 Some(CapturedRequest {
4324 method,
4325 path,
4326 body,
4327 content_length,
4328 transfer_encoding,
4329 })
4330 }
4331
4332 async fn start_capture_server() -> (
4337 String,
4338 tokio::task::JoinHandle<()>,
4339 Arc<Mutex<Vec<CapturedRequest>>>,
4340 ) {
4341 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4342 let addr = listener.local_addr().unwrap();
4343 let url = format!("http://127.0.0.1:{}", addr.port());
4344
4345 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
4346 let captured_for_return = Arc::clone(&captured);
4347
4348 let handle = tokio::spawn(async move {
4349 use tokio::io::AsyncWriteExt;
4350 loop {
4351 if let Ok((mut stream, _)) = listener.accept().await {
4352 let captured = Arc::clone(&captured);
4353 tokio::spawn(async move {
4354 let Some(req) = capture_request(&mut stream).await else {
4355 return;
4356 };
4357 captured.lock().unwrap().push(req);
4358
4359 let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
4362 let _ = stream.write_all(response.as_bytes()).await;
4363 });
4364 }
4365 }
4366 });
4367
4368 (url, handle, captured_for_return)
4369 }
4370
4371 async fn start_redirect_capture_server() -> (
4377 String,
4378 tokio::task::JoinHandle<()>,
4379 Arc<Mutex<Vec<CapturedRequest>>>,
4380 ) {
4381 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4382 let addr = listener.local_addr().unwrap();
4383 let url = format!("http://127.0.0.1:{}", addr.port());
4384
4385 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
4386 let captured_for_return = Arc::clone(&captured);
4387
4388 let handle = tokio::spawn(async move {
4389 use tokio::io::AsyncWriteExt;
4390 loop {
4391 if let Ok((mut stream, _)) = listener.accept().await {
4392 let captured = Arc::clone(&captured);
4393 tokio::spawn(async move {
4394 let Some(req) = capture_request(&mut stream).await else {
4395 return;
4396 };
4397 let path = req.path.clone();
4398 captured.lock().unwrap().push(req);
4399
4400 let (status_line, location) = match path.as_str() {
4401 "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
4402 "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
4403 "/final" => ("HTTP/1.1 200 OK", None),
4404 _ => ("HTTP/1.1 404 Not Found", None),
4405 };
4406
4407 let response = match location {
4408 Some(loc) => format!(
4412 "{status_line}\r\nLocation: {loc}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
4413 ),
4414 None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
4415 };
4416 let _ = stream.write_all(response.as_bytes()).await;
4417 });
4418 }
4419 }
4420 });
4421
4422 (url, handle, captured_for_return)
4423 }
4424
4425 #[tokio::test]
4426 async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
4427 use tower::ServiceExt;
4428
4429 let (url, _handle, captured) = start_capture_server().await;
4430 let ctx = test_producer_ctx();
4431
4432 let component = HttpComponent::with_config(HttpConfig::default());
4433 let endpoint_ctx = NoOpComponentContext;
4434 let endpoint = component
4435 .create_endpoint(
4436 &format!("{url}?httpMethod=GET&allowInternal=true"),
4437 &endpoint_ctx,
4438 )
4439 .unwrap();
4440 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4441
4442 let mut exchange = Exchange::new(Message::default());
4443 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
4444
4445 let result = producer.oneshot(exchange).await.unwrap();
4446
4447 let status = result
4448 .input
4449 .header("CamelHttpResponseCode")
4450 .and_then(|v| v.as_u64())
4451 .unwrap();
4452 assert_eq!(status, 200);
4453
4454 let captured = captured.lock().unwrap();
4455 assert_eq!(captured.len(), 1, "expected exactly one captured request");
4456 let req = &captured[0];
4457 assert_eq!(req.method, "GET");
4458 assert_eq!(req.path, "/");
4461 assert!(req.body.is_empty(), "GET must not carry a body");
4462 assert!(
4463 req.content_length.is_none(),
4464 "suppressed request must not carry Content-Length"
4465 );
4466 assert!(
4467 req.transfer_encoding.is_none(),
4468 "suppressed request must not carry Transfer-Encoding"
4469 );
4470
4471 assert!(
4473 result.input.body.is_empty(),
4474 "exchange body must be consumed"
4475 );
4476 }
4477
4478 #[tokio::test]
4479 async fn test_head_with_body_suppressed_via_header() {
4480 use tower::ServiceExt;
4481
4482 let (url, _handle, captured) = start_capture_server().await;
4483 let ctx = test_producer_ctx();
4484
4485 let component = HttpComponent::with_config(HttpConfig::default());
4486 let endpoint_ctx = NoOpComponentContext;
4487 let endpoint = component
4488 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
4489 .unwrap();
4490 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4491
4492 let mut exchange = Exchange::new(Message::default());
4493 exchange.input.set_header(
4494 "CamelHttpMethod",
4495 serde_json::Value::String("HEAD".to_string()),
4496 );
4497 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
4498
4499 let result = producer.oneshot(exchange).await.unwrap();
4500 let status = result
4501 .input
4502 .header("CamelHttpResponseCode")
4503 .and_then(|v| v.as_u64())
4504 .unwrap();
4505 assert_eq!(status, 200);
4506
4507 let captured = captured.lock().unwrap();
4508 assert_eq!(captured.len(), 1);
4509 let req = &captured[0];
4510 assert_eq!(req.method, "HEAD");
4511 assert!(req.body.is_empty(), "HEAD must not carry a body");
4512 }
4513
4514 #[tokio::test]
4515 async fn test_delete_options_trace_with_body_suppressed() {
4516 use tower::ServiceExt;
4517
4518 let (url, _handle, captured) = start_capture_server().await;
4519 let ctx = test_producer_ctx();
4520 let component = HttpComponent::with_config(HttpConfig::default());
4521 let endpoint_ctx = NoOpComponentContext;
4522
4523 for method in ["DELETE", "OPTIONS", "TRACE"] {
4524 let endpoint = component
4525 .create_endpoint(
4526 &format!("{url}?httpMethod={method}&allowInternal=true"),
4527 &endpoint_ctx,
4528 )
4529 .unwrap();
4530 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4531
4532 let mut exchange = Exchange::new(Message::default());
4533 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
4534
4535 let result = producer.oneshot(exchange).await.unwrap();
4536 let status = result
4537 .input
4538 .header("CamelHttpResponseCode")
4539 .and_then(|v| v.as_u64())
4540 .unwrap();
4541 assert_eq!(status, 200, "method {method} should succeed");
4542 }
4543
4544 let captured = captured.lock().unwrap();
4545 assert_eq!(captured.len(), 3, "expected three captured requests");
4546 for method in ["DELETE", "OPTIONS", "TRACE"] {
4547 let req = captured
4548 .iter()
4549 .find(|r| r.method == method)
4550 .unwrap_or_else(|| panic!("missing captured request for {method}"));
4551 assert!(req.body.is_empty(), "{} must not carry a body", req.method);
4552 }
4553 }
4554
4555 #[tokio::test]
4556 async fn test_post_put_patch_with_body_still_sent() {
4557 use tower::ServiceExt;
4558
4559 let (url, _handle, captured) = start_capture_server().await;
4560 let ctx = test_producer_ctx();
4561 let component = HttpComponent::with_config(HttpConfig::default());
4562 let endpoint_ctx = NoOpComponentContext;
4563
4564 for method in ["POST", "PUT", "PATCH"] {
4565 let endpoint = component
4566 .create_endpoint(
4567 &format!("{url}?httpMethod={method}&allowInternal=true"),
4568 &endpoint_ctx,
4569 )
4570 .unwrap();
4571 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4572
4573 let payload = format!("body-for-{method}");
4574 let mut exchange = Exchange::new(Message::default());
4575 exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
4576
4577 let result = producer.oneshot(exchange).await.unwrap();
4578 let status = result
4579 .input
4580 .header("CamelHttpResponseCode")
4581 .and_then(|v| v.as_u64())
4582 .unwrap();
4583 assert_eq!(status, 200, "method {method} should succeed");
4584 }
4585
4586 let captured = captured.lock().unwrap();
4587 assert_eq!(captured.len(), 3, "expected three captured requests");
4588 for method in ["POST", "PUT", "PATCH"] {
4589 let req = captured
4590 .iter()
4591 .find(|r| r.method == method)
4592 .unwrap_or_else(|| panic!("missing captured request for {method}"));
4593 let expected = format!("body-for-{method}");
4594 assert!(!req.body.is_empty(), "{method} must still carry its body");
4595 assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
4596 }
4597 }
4598
4599 #[tokio::test]
4603 async fn test_stream_body_under_get_not_attached() {
4604 use tower::ServiceExt;
4605
4606 let (url, _handle, captured) = start_capture_server().await;
4607 let ctx = test_producer_ctx();
4608
4609 let component = HttpComponent::with_config(HttpConfig::default());
4610 let endpoint_ctx = NoOpComponentContext;
4611 let endpoint = component
4612 .create_endpoint(
4613 &format!("{url}?httpMethod=GET&allowInternal=true"),
4614 &endpoint_ctx,
4615 )
4616 .unwrap();
4617 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4618
4619 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
4620 vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
4621 let stream = Box::pin(futures::stream::iter(chunks));
4622 let mut exchange = Exchange::new(Message::default());
4623 exchange.input.body = Body::Stream(StreamBody {
4624 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
4625 metadata: StreamMetadata::default(),
4626 });
4627
4628 let result = producer.oneshot(exchange).await.unwrap();
4629
4630 let status = result
4631 .input
4632 .header("CamelHttpResponseCode")
4633 .and_then(|v| v.as_u64())
4634 .unwrap();
4635 assert_eq!(status, 200);
4636
4637 let captured = captured.lock().unwrap();
4638 assert_eq!(captured.len(), 1, "expected exactly one captured request");
4639 assert!(
4640 captured[0].body.is_empty(),
4641 "GET must not carry a stream body"
4642 );
4643 assert!(
4644 captured[0].transfer_encoding.is_none(),
4645 "suppressed request must not carry Transfer-Encoding"
4646 );
4647 assert!(
4648 captured[0].content_length.is_none(),
4649 "suppressed request must not carry Content-Length"
4650 );
4651 assert!(
4652 result.input.body.is_empty(),
4653 "exchange body must be consumed to Empty, not left as a stream"
4654 );
4655 }
4656
4657 #[tokio::test]
4661 async fn test_redirect_hops_never_replay_suppressed_body() {
4662 use tower::ServiceExt;
4663
4664 let (url, _handle, captured) = start_redirect_capture_server().await;
4665 let ctx = test_producer_ctx();
4666
4667 let component =
4668 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4669 let endpoint_ctx = NoOpComponentContext;
4670
4671 for path in ["/hop307", "/hop308"] {
4672 let endpoint = component
4673 .create_endpoint(
4674 &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
4675 &endpoint_ctx,
4676 )
4677 .unwrap();
4678 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4679
4680 let mut exchange = Exchange::new(Message::default());
4681 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
4682
4683 let result = producer.oneshot(exchange).await.unwrap();
4684 let status = result
4685 .input
4686 .header("CamelHttpResponseCode")
4687 .and_then(|v| v.as_u64())
4688 .unwrap();
4689 assert_eq!(
4690 status, 200,
4691 "redirect chain for {path} should end at /final"
4692 );
4693 }
4694
4695 let captured = captured.lock().unwrap();
4697 assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
4698 for req in captured.iter() {
4699 assert!(
4700 req.body.is_empty(),
4701 "hop {} {} must not carry a body",
4702 req.method,
4703 req.path
4704 );
4705 }
4706 }
4707
4708 #[tracing_test::traced_test]
4717 #[tokio::test]
4718 async fn test_suppressed_body_logs_exactly_one_warn() {
4719 use tower::ServiceExt;
4720
4721 let (url, _handle, _captured) = start_capture_server().await;
4722 let ctx = test_producer_ctx();
4723
4724 let component = HttpComponent::with_config(HttpConfig::default());
4725 let endpoint_ctx = NoOpComponentContext;
4726 let endpoint = component
4727 .create_endpoint(
4728 &format!("{url}?httpMethod=GET&allowInternal=true"),
4729 &endpoint_ctx,
4730 )
4731 .unwrap();
4732 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4733
4734 let mut exchange = Exchange::new(Message::default());
4735 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
4736 let correlation_id = exchange.correlation_id().to_string();
4737
4738 let result = producer.oneshot(exchange).await.unwrap();
4739 let status = result
4740 .input
4741 .header("CamelHttpResponseCode")
4742 .and_then(|v| v.as_u64())
4743 .unwrap();
4744 assert_eq!(status, 200);
4745
4746 logs_assert(|lines: &[&str]| {
4747 let hits = lines
4748 .iter()
4749 .filter(|l| {
4750 l.contains("dropping request body")
4751 && l.contains("method=GET")
4752 && l.contains(&format!("correlation_id={correlation_id}"))
4753 })
4754 .count();
4755 match hits {
4756 1 => Ok(()),
4757 n => Err(format!("expected exactly one body-drop warn, found {n}")),
4758 }
4759 });
4760 }
4761
4762 #[tracing_test::traced_test]
4763 #[tokio::test]
4764 async fn test_empty_body_get_emits_no_warn() {
4765 use tower::ServiceExt;
4766
4767 let (url, _handle, _captured) = start_capture_server().await;
4768 let ctx = test_producer_ctx();
4769
4770 let component = HttpComponent::with_config(HttpConfig::default());
4771 let endpoint_ctx = NoOpComponentContext;
4772 let endpoint = component
4773 .create_endpoint(
4774 &format!("{url}?httpMethod=GET&allowInternal=true"),
4775 &endpoint_ctx,
4776 )
4777 .unwrap();
4778 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4779
4780 let exchange = Exchange::new(Message::default());
4781 let result = producer.oneshot(exchange).await.unwrap();
4782 let status = result
4783 .input
4784 .header("CamelHttpResponseCode")
4785 .and_then(|v| v.as_u64())
4786 .unwrap();
4787 assert_eq!(status, 200);
4788
4789 logs_assert(|lines: &[&str]| {
4790 let hits = lines
4791 .iter()
4792 .filter(|l| l.contains("dropping request body"))
4793 .count();
4794 match hits {
4795 0 => Ok(()),
4796 n => Err(format!("expected no body-drop warn, found {n}")),
4797 }
4798 });
4799 }
4800
4801 #[tokio::test]
4802 async fn test_follow_redirects_false_does_not_follow() {
4803 use tower::ServiceExt;
4804
4805 let (url, _handle) = start_redirect_server().await;
4806 let ctx = test_producer_ctx();
4807
4808 let component =
4809 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
4810 let endpoint_ctx = NoOpComponentContext;
4811 let endpoint = component
4812 .create_endpoint(
4813 &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
4814 &endpoint_ctx,
4815 )
4816 .unwrap();
4817 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4818
4819 let exchange = Exchange::new(Message::default());
4820 let result = producer.oneshot(exchange).await.unwrap();
4821
4822 let status = result
4824 .input
4825 .header("CamelHttpResponseCode")
4826 .and_then(|v| v.as_u64())
4827 .unwrap();
4828 assert_eq!(
4829 status, 302,
4830 "Should NOT follow redirect when followRedirects=false"
4831 );
4832 }
4833
4834 #[tokio::test]
4835 async fn test_follow_redirects_true_follows_redirect() {
4836 use tower::ServiceExt;
4837
4838 let (url, _handle) = start_redirect_server().await;
4839 let ctx = test_producer_ctx();
4840
4841 let component =
4842 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4843 let endpoint_ctx = NoOpComponentContext;
4844 let endpoint = component
4845 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
4846 .unwrap();
4847 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4848
4849 let exchange = Exchange::new(Message::default());
4850 let result = producer.oneshot(exchange).await.unwrap();
4851
4852 let status = result
4854 .input
4855 .header("CamelHttpResponseCode")
4856 .and_then(|v| v.as_u64())
4857 .unwrap();
4858 assert_eq!(
4859 status, 200,
4860 "Should follow redirect when followRedirects=true"
4861 );
4862 }
4863
4864 #[tokio::test]
4867 async fn test_redirect_to_private_ip_is_ssrf_blocked() {
4868 use tower::ServiceExt;
4869
4870 let (url, _handle) = start_redirect_server().await;
4872 let ctx = test_producer_ctx();
4873
4874 let component =
4875 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4876 let endpoint_ctx = NoOpComponentContext;
4877 let endpoint = component
4878 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
4879 .unwrap();
4880 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4881
4882 let exchange = Exchange::new(Message::default());
4883 let result = producer.oneshot(exchange).await;
4884
4885 assert!(
4887 result.is_ok(),
4888 "Redirect should succeed with allowInternal=true, got: {:?}",
4889 result
4890 );
4891 let exchange = result.unwrap();
4892 let status = exchange
4893 .input
4894 .header("CamelHttpResponseCode")
4895 .and_then(|v| v.as_u64())
4896 .unwrap();
4897 assert_eq!(status, 200, "Should follow redirect to /final");
4898 }
4899
4900 #[tokio::test]
4902 async fn test_redirect_to_private_ip_allowed_when_configured() {
4903 use tower::ServiceExt;
4904
4905 let (url, _handle) = start_redirect_server().await;
4907 let ctx = test_producer_ctx();
4908
4909 let component =
4910 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4911 let endpoint_ctx = NoOpComponentContext;
4912 let endpoint = component
4913 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
4914 .unwrap();
4915 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4916
4917 let exchange = Exchange::new(Message::default());
4918 let result = producer.oneshot(exchange).await.unwrap();
4919
4920 let status = result
4921 .input
4922 .header("CamelHttpResponseCode")
4923 .and_then(|v| v.as_u64())
4924 .unwrap();
4925 assert_eq!(
4926 status, 200,
4927 "Should follow redirect to private IP when allowInternal=true"
4928 );
4929 }
4930
4931 #[tokio::test]
4934 async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
4935 use tower::ServiceExt;
4936
4937 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4939 let addr = listener.local_addr().unwrap();
4940 let url = format!("http://127.0.0.1:{}", addr.port());
4941
4942 let handle = tokio::spawn(async move {
4943 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4944 loop {
4945 if let Ok((mut stream, _)) = listener.accept().await {
4946 tokio::spawn(async move {
4947 let mut buf = vec![0u8; 4096];
4948 let _ = stream.read(&mut buf).await;
4949 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";
4951 let _ = stream.write_all(response.as_bytes()).await;
4952 });
4953 }
4954 }
4955 });
4956
4957 let ctx = test_producer_ctx();
4958 let component =
4959 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4960 let endpoint_ctx = NoOpComponentContext;
4961 let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
4963 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4964
4965 let exchange = Exchange::new(Message::default());
4966 let result = producer.oneshot(exchange).await;
4967
4968 assert!(
4970 result.is_err(),
4971 "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
4972 );
4973 let err = result.unwrap_err().to_string();
4974 assert!(
4975 err.contains("blocked IP")
4976 || err.contains("private IP")
4977 || err.contains("SSRF")
4978 || err.contains("not allowed"),
4979 "Error should mention SSRF/IP blocking, got: {err}"
4980 );
4981
4982 handle.abort();
4983 }
4984
4985 #[tokio::test]
4987 async fn test_too_many_redirects_returns_error() {
4988 use tower::ServiceExt;
4989
4990 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4992 let addr = listener.local_addr().unwrap();
4993 let url = format!("http://127.0.0.1:{}", addr.port());
4994
4995 let handle = tokio::spawn(async move {
4996 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4997 loop {
4998 if let Ok((mut stream, _)) = listener.accept().await {
4999 tokio::spawn(async move {
5000 let mut buf = vec![0u8; 4096];
5001 let _ = stream.read(&mut buf).await;
5002 let response = "HTTP/1.1 302 Found\r\nLocation: /loop\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
5007 let _ = stream.write_all(response.as_bytes()).await;
5008 });
5009 }
5010 }
5011 });
5012
5013 let ctx = test_producer_ctx();
5014 let component =
5015 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
5016 let endpoint_ctx = NoOpComponentContext;
5017 let endpoint = component
5018 .create_endpoint(
5019 &format!("{url}?allowInternal=true&maxRedirects=2"),
5020 &endpoint_ctx,
5021 )
5022 .unwrap();
5023 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5024
5025 let exchange = Exchange::new(Message::default());
5026 let result = producer.oneshot(exchange).await;
5027
5028 match result {
5036 Err(e) => {
5037 let msg = e.to_string();
5039 assert!(
5040 msg.contains("HTTP operation failed") || msg.contains("302"),
5041 "expected redirect-after-exhaustion error, got: {msg}"
5042 );
5043 }
5044 Ok(ex) => {
5045 let response_code = ex
5046 .input
5047 .header("CamelHttpResponseCode")
5048 .and_then(|v| v.as_u64());
5049 assert_eq!(
5050 response_code,
5051 Some(302),
5052 "expected 302 after exhausting redirects"
5053 );
5054 }
5055 }
5056
5057 handle.abort();
5058 }
5059
5060 #[tokio::test]
5061 async fn test_query_params_forwarded_to_http_request() {
5062 use tower::ServiceExt;
5063
5064 let (url, _handle) = start_test_server().await;
5065 let ctx = test_producer_ctx();
5066
5067 let component = HttpComponent::new();
5068 let endpoint_ctx = NoOpComponentContext;
5069 let endpoint = component
5071 .create_endpoint(
5072 &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
5073 &endpoint_ctx,
5074 )
5075 .unwrap();
5076 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5077
5078 let exchange = Exchange::new(Message::default());
5079 let result = producer.oneshot(exchange).await.unwrap();
5080
5081 let status = result
5084 .input
5085 .header("CamelHttpResponseCode")
5086 .and_then(|v| v.as_u64())
5087 .unwrap();
5088 assert_eq!(status, 200);
5089 }
5090
5091 #[test]
5092 fn test_non_camel_query_params_are_forwarded() {
5093 let config = HttpEndpointConfig::from_uri(
5096 "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
5097 )
5098 .unwrap();
5099
5100 assert_eq!(
5103 config.raw_query.as_deref(),
5104 Some("apiKey=secret123&httpMethod=GET&token=abc456")
5105 );
5106 assert!(config.query_params.is_empty());
5107 }
5108
5109 #[test]
5110 fn test_authored_query_bytes_survive_resolve_url() {
5111 let config =
5112 HttpEndpointConfig::from_uri("http://example.com/api?q=hello%20world&tag=a+b").unwrap();
5113 let exchange = Exchange::new(Message::default());
5114
5115 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
5116
5117 assert!(url.contains("q=hello%20world"), "url was: {url}");
5120 assert!(url.contains("tag=a+b"), "url was: {url}");
5121 }
5122
5123 async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
5128 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5129 let addr = listener.local_addr().unwrap();
5130 let url = format!("http://127.0.0.1:{}", addr.port());
5131
5132 let handle = tokio::spawn(async move {
5133 loop {
5134 if let Ok((mut stream, _)) = listener.accept().await {
5135 let delay = delay_ms;
5136 tokio::spawn(async move {
5137 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5138 let mut buf = vec![0u8; 4096];
5139 let _ = stream.read(&mut buf).await;
5140 let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
5142 let _ = stream.write_all(headers.as_bytes()).await;
5143 tokio::time::sleep(Duration::from_millis(delay)).await;
5145 let body = r#"{"status":"slow"}"#;
5146 let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
5147 let _ = stream.write_all(chunk.as_bytes()).await;
5148 });
5149 }
5150 }
5151 });
5152
5153 (url, handle)
5154 }
5155
5156 #[tokio::test]
5157 async fn test_http_producer_timeout() {
5158 use tower::ServiceExt;
5159
5160 let (url, _handle) = start_slow_server(500).await;
5162 let ctx = test_producer_ctx();
5163
5164 let component = HttpComponent::with_config(
5165 HttpConfig::default()
5166 .with_read_timeout_ms(100)
5167 .with_response_timeout_ms(30_000), );
5169 let endpoint_ctx = NoOpComponentContext;
5170 let endpoint = component
5171 .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
5172 .unwrap();
5173 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5174
5175 let exchange = Exchange::new(Message::default());
5176 let result = producer.oneshot(exchange).await;
5177
5178 assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
5179 let err = result.unwrap_err().to_string();
5180 assert!(
5181 err.contains("Read timeout") || err.contains("timeout"),
5182 "Error should mention timeout, got: {}",
5183 err
5184 );
5185 }
5186
5187 #[tokio::test]
5188 async fn test_http_producer_no_timeout_when_fast() {
5189 use tower::ServiceExt;
5190
5191 let (url, _handle) = start_test_server().await;
5192 let ctx = test_producer_ctx();
5193
5194 let component =
5195 HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
5196 let endpoint_ctx = NoOpComponentContext;
5197 let endpoint = component
5198 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5199 .unwrap();
5200 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5201
5202 let exchange = Exchange::new(Message::default());
5203 let result = producer.oneshot(exchange).await.unwrap();
5204
5205 let status = result
5206 .input
5207 .header("CamelHttpResponseCode")
5208 .and_then(|v| v.as_u64())
5209 .unwrap();
5210 assert_eq!(status, 200);
5211 }
5212
5213 #[tokio::test]
5218 async fn test_http_producer_blocks_metadata_endpoint() {
5219 use tower::ServiceExt;
5220
5221 let ctx = test_producer_ctx();
5222 let component = HttpComponent::new();
5223 let endpoint_ctx = NoOpComponentContext;
5224 let endpoint = component
5225 .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
5226 .unwrap();
5227 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5228
5229 let mut exchange = Exchange::new(Message::default());
5230 exchange.input.set_header(
5231 "CamelHttpUri",
5232 serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
5233 );
5234
5235 let result = producer.oneshot(exchange).await;
5236 assert!(result.is_err(), "Should block AWS metadata endpoint");
5237
5238 let err = result.unwrap_err();
5239 assert!(
5240 err.to_string().contains("Private IP"),
5241 "Error should mention private IP blocking, got: {}",
5242 err
5243 );
5244 }
5245
5246 #[test]
5247 fn test_ssrf_config_defaults() {
5248 let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
5249 assert!(
5250 !config.allow_internal,
5251 "Private IPs should be blocked by default"
5252 );
5253 assert!(
5254 config.blocked_hosts.is_empty(),
5255 "Blocked hosts should be empty by default"
5256 );
5257 }
5258
5259 #[test]
5260 fn test_ssrf_config_allow_internal() {
5261 let config =
5262 HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
5263 assert!(
5264 config.allow_internal,
5265 "Private IPs should be allowed when explicitly set"
5266 );
5267 }
5268
5269 #[test]
5270 fn test_ssrf_config_blocked_hosts() {
5271 let config = HttpEndpointConfig::from_uri(
5272 "http://example.com/api?blockedHosts=evil.com,malware.net",
5273 )
5274 .unwrap();
5275 assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
5276 }
5277
5278 #[tokio::test]
5279 async fn test_http_producer_blocks_localhost() {
5280 use tower::ServiceExt;
5281
5282 let ctx = test_producer_ctx();
5283 let component = HttpComponent::new();
5284 let endpoint_ctx = NoOpComponentContext;
5285 let endpoint = component
5286 .create_endpoint("http://example.com/api", &endpoint_ctx)
5287 .unwrap();
5288 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5289
5290 let mut exchange = Exchange::new(Message::default());
5291 exchange.input.set_header(
5292 "CamelHttpUri",
5293 serde_json::Value::String("http://localhost:8080/internal".to_string()),
5294 );
5295
5296 let result = producer.oneshot(exchange).await;
5297 assert!(result.is_err(), "Should block localhost");
5298 }
5299
5300 #[tokio::test]
5301 async fn test_http_producer_blocks_loopback_ip() {
5302 use tower::ServiceExt;
5303
5304 let ctx = test_producer_ctx();
5305 let component = HttpComponent::new();
5306 let endpoint_ctx = NoOpComponentContext;
5307 let endpoint = component
5308 .create_endpoint("http://example.com/api", &endpoint_ctx)
5309 .unwrap();
5310 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5311
5312 let mut exchange = Exchange::new(Message::default());
5313 exchange.input.set_header(
5314 "CamelHttpUri",
5315 serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
5316 );
5317
5318 let result = producer.oneshot(exchange).await;
5319 assert!(result.is_err(), "Should block loopback IP");
5320 }
5321
5322 #[tokio::test]
5323 async fn test_http_producer_allows_private_ip_when_enabled() {
5324 use tower::ServiceExt;
5325
5326 let ctx = test_producer_ctx();
5327 let component = HttpComponent::new();
5328 let endpoint_ctx = NoOpComponentContext;
5329 let endpoint = component
5332 .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
5333 .unwrap();
5334 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5335
5336 let exchange = Exchange::new(Message::default());
5337
5338 let result = producer.oneshot(exchange).await;
5341 if let Err(ref e) = result {
5343 let err_str = e.to_string();
5344 assert!(
5345 !err_str.contains("Private IP") && !err_str.contains("not allowed"),
5346 "Should not be SSRF error, got: {}",
5347 err_str
5348 );
5349 }
5350 }
5351
5352 #[test]
5357 fn test_http_server_config_parse() {
5358 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
5359 assert_eq!(cfg.host, "0.0.0.0");
5360 assert_eq!(cfg.port, 8080);
5361 assert_eq!(cfg.path, "/orders");
5362 assert_eq!(cfg.max_inflight_requests, 1024);
5363 }
5364
5365 #[test]
5366 fn test_http_server_config_scheme() {
5367 assert_eq!(HttpServerConfig::scheme(), "http");
5369 }
5370
5371 #[test]
5372 fn test_http_server_config_from_components() {
5373 let components = camel_component_api::UriComponents {
5375 scheme: "https".to_string(),
5376 path: "//0.0.0.0:8443/api".to_string(),
5377 params: std::collections::HashMap::from([
5378 ("maxRequestBody".to_string(), "5242880".to_string()),
5379 ("maxInflightRequests".to_string(), "7".to_string()),
5380 ]),
5381 raw_query: None,
5382 };
5383 let cfg = HttpServerConfig::from_components(components).unwrap();
5384 assert_eq!(cfg.host, "0.0.0.0");
5385 assert_eq!(cfg.port, 8443);
5386 assert_eq!(cfg.path, "/api");
5387 assert_eq!(cfg.max_request_body, 5242880);
5388 assert_eq!(cfg.max_inflight_requests, 7);
5389 }
5390
5391 #[test]
5392 fn test_http_server_config_default_path() {
5393 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
5394 assert_eq!(cfg.path, "/");
5395 }
5396
5397 #[test]
5398 fn test_http_server_config_wrong_scheme() {
5399 assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
5400 }
5401
5402 #[test]
5403 fn test_http_server_config_invalid_port() {
5404 assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
5405 }
5406
5407 #[test]
5408 fn test_http_server_config_default_port_by_scheme() {
5409 let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
5411 assert_eq!(cfg_http.port, 80);
5412
5413 let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
5415 assert_eq!(cfg_https.port, 443);
5416 }
5417
5418 #[test]
5419 fn test_request_envelope_and_reply_are_send() {
5420 fn assert_send<T: Send>() {}
5421 assert_send::<RequestEnvelope>();
5422 assert_send::<HttpReply>();
5423 }
5424
5425 #[test]
5430 fn test_server_registry_global_is_singleton() {
5431 let r1 = ServerRegistry::global();
5432 let r2 = ServerRegistry::global();
5433 assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
5434 }
5435
5436 #[allow(clippy::await_holding_lock)]
5437 #[tokio::test]
5438 async fn test_concurrent_get_or_spawn_returns_same_registry() {
5439 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5440 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5441 let port = listener.local_addr().unwrap().port();
5442 drop(listener);
5443
5444 let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
5445 Arc::new(std::sync::Mutex::new(Vec::new()));
5446
5447 let mut handles = Vec::new();
5448 for _ in 0..4 {
5449 let results = results.clone();
5450 handles.push(tokio::spawn(async move {
5451 let registry = ServerRegistry::global()
5452 .get_or_spawn(
5453 "127.0.0.1",
5454 port,
5455 2 * 1024 * 1024,
5456 10 * 1024 * 1024,
5457 1024,
5458 test_rt(),
5459 "test-route".into(),
5460 None,
5461 )
5462 .await
5463 .unwrap();
5464 results.lock().unwrap().push(registry);
5465 }));
5466 }
5467
5468 for h in handles {
5469 h.await.unwrap();
5470 }
5471
5472 let registries = results.lock().unwrap();
5473 assert_eq!(registries.len(), 4);
5474 for i in 1..registries.len() {
5475 assert!(
5476 Arc::ptr_eq(®istries[0].inner, ®istries[i].inner),
5477 "all concurrent callers should get same route registry"
5478 );
5479 }
5480 }
5481
5482 #[test]
5483 fn test_server_registry_distinguishes_host_and_port() {
5484 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5485 let rt = tokio::runtime::Runtime::new().expect("runtime");
5486 rt.block_on(async {
5487 let registry = ServerRegistry::global();
5488 let d1 = registry
5492 .get_or_spawn(
5493 "127.0.0.1",
5494 0,
5495 1024 * 1024,
5496 10 * 1024 * 1024,
5497 1024,
5498 test_rt(),
5499 "test-route-1".into(),
5500 None,
5501 )
5502 .await;
5503 let d2 = registry
5504 .get_or_spawn(
5505 "0.0.0.0",
5506 0,
5507 1024 * 1024,
5508 10 * 1024 * 1024,
5509 1024,
5510 test_rt(),
5511 "test-route-2".into(),
5512 None,
5513 )
5514 .await;
5515 assert!(d1.is_ok());
5516 assert!(d2.is_ok());
5517 assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
5518 });
5519 }
5520
5521 #[allow(clippy::await_holding_lock)]
5522 #[tokio::test]
5523 async fn test_shared_server_max_request_body_policy_is_deterministic() {
5524 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5525 let registry = ServerRegistry::global();
5526 let d1 = registry
5528 .get_or_spawn(
5529 "127.0.0.1",
5530 9991,
5531 1024 * 1024,
5532 10 * 1024 * 1024,
5533 1024,
5534 test_rt(),
5535 "test-route".into(),
5536 None,
5537 )
5538 .await;
5539 assert!(d1.is_ok());
5540
5541 let d2 = registry
5544 .get_or_spawn(
5545 "127.0.0.1",
5546 9991,
5547 2 * 1024 * 1024,
5548 10 * 1024 * 1024,
5549 1024,
5550 test_rt(),
5551 "test-route-2".into(),
5552 None,
5553 )
5554 .await;
5555 assert!(d2.is_err());
5556 let err = d2.unwrap_err();
5557 assert!(
5558 err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
5559 "Expected incompatible maxRequestBody error, got: {}",
5560 err
5561 );
5562 }
5563
5564 #[test]
5565 fn test_server_registry_reset_clears_entries() {
5566 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5567 let rt = tokio::runtime::Runtime::new().expect("runtime");
5568 rt.block_on(async {
5569 let d1 = ServerRegistry::global()
5571 .get_or_spawn(
5572 "127.0.0.1",
5573 9992,
5574 1024 * 1024,
5575 10 * 1024 * 1024,
5576 1024,
5577 test_rt(),
5578 "test-route".into(),
5579 None,
5580 )
5581 .await;
5582 assert!(d1.is_ok());
5583
5584 let guard = ServerRegistry::global().inner.lock().expect("lock");
5586 assert!(guard.entries.contains_key(&("127.0.0.1".to_string(), 9992)));
5587 drop(guard);
5588
5589 ServerRegistry::reset();
5591
5592 let guard = ServerRegistry::global().inner.lock().expect("lock");
5594 assert!(
5595 guard.entries.is_empty(),
5596 "registry should be empty after reset, has {} entries",
5597 guard.entries.len()
5598 );
5599 });
5600 }
5601
5602 #[tokio::test]
5603 async fn registry_rejects_tls_on_plain_port() {
5604 ServerRegistry::reset();
5605 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
5606
5607 let _r1 = ServerRegistry::global()
5609 .get_or_spawn(
5610 "127.0.0.1",
5611 0,
5612 1024,
5613 1024,
5614 16,
5615 Arc::clone(&rt),
5616 "route-1".into(),
5617 None, )
5619 .await;
5620
5621 let result = ServerRegistry::global()
5623 .get_or_spawn(
5624 "127.0.0.1",
5625 0,
5626 1024,
5627 1024,
5628 16,
5629 Arc::clone(&rt),
5630 "route-2".into(),
5631 Some(crate::config::ServerTlsConfig {
5632 cert_path: "/x.pem".into(),
5633 key_path: "/y.pem".into(),
5634 }),
5635 )
5636 .await;
5637 assert!(result.is_err(), "must reject TLS on plain port");
5638 }
5639
5640 #[allow(clippy::await_holding_lock)]
5645 #[tokio::test]
5646 async fn test_unregister_last_http_route_keeps_server_alive() {
5647 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5648 ServerRegistry::reset();
5649 let registry = ServerRegistry::global();
5650
5651 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5652 let port = listener.local_addr().unwrap().port();
5653 drop(listener); let rt = test_rt();
5655
5656 let _r1 = registry
5659 .get_or_spawn(
5660 "127.0.0.1",
5661 port,
5662 1024 * 1024,
5663 10 * 1024 * 1024,
5664 16,
5665 rt.clone(),
5666 "test-route-1".into(),
5667 None,
5668 )
5669 .await
5670 .unwrap();
5671 let _r2 = registry
5672 .get_or_spawn(
5673 "127.0.0.1",
5674 port,
5675 1024 * 1024,
5676 10 * 1024 * 1024,
5677 16,
5678 rt,
5679 "test-route-2".into(),
5680 None,
5681 )
5682 .await
5683 .unwrap();
5684
5685 let key = ("127.0.0.1".to_string(), port);
5686 let cell = {
5687 let guard = registry.inner.lock().expect("lock");
5688 guard.entries.get(&key).expect("entry should exist").clone()
5689 };
5690
5691 registry.unregister("127.0.0.1", port).await;
5693 {
5694 let handle = cell
5695 .get()
5696 .expect("handle should still exist after first unregister");
5697 assert!(
5698 !handle.monitor_task.is_finished(),
5699 "monitor task should still be alive after first unregister"
5700 );
5701 }
5702
5703 registry.unregister("127.0.0.1", port).await;
5705 tokio::time::sleep(Duration::from_millis(20)).await;
5706 {
5707 let handle = cell
5708 .get()
5709 .expect("handle should still exist after last unregister");
5710 assert!(
5711 !handle.monitor_task.is_finished(),
5712 "monitor task should still be alive — server is process-lifetime"
5713 );
5714 }
5715
5716 {
5718 let guard = registry.inner.lock().expect("lock");
5719 assert!(
5720 guard.entries.contains_key(&key),
5721 "entry should remain in registry — server kept alive for restart"
5722 );
5723 }
5724 }
5725
5726 async fn clone_fixture_listener() -> (
5735 tokio::net::TcpListener,
5736 std::net::TcpListener,
5737 std::net::SocketAddr,
5738 ) {
5739 let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind std listener");
5740 let probe = l.try_clone().expect("clone probe");
5741 l.set_nonblocking(true).expect("set_nonblocking");
5742 let listener = tokio::net::TcpListener::from_std(l).expect("from_std");
5743 let addr = listener.local_addr().expect("local_addr");
5744 (listener, probe, addr)
5745 }
5746
5747 fn staged_limits() -> (usize, usize, usize) {
5749 (1024 * 1024, 10 * 1024 * 1024, 1024)
5750 }
5751
5752 #[allow(clippy::await_holding_lock)]
5753 #[tokio::test]
5754 async fn staged_listener_first_spawn_serves_without_second_bind() {
5755 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5756 ServerRegistry::reset();
5757 let registry = ServerRegistry::global();
5758 let (listener, _probe, addr) = clone_fixture_listener().await;
5759 let port = addr.port();
5760 registry
5761 .stage_listener(listener)
5762 .await
5763 .expect("stage listener");
5764
5765 let (max_req, max_res, max_inflight) = staged_limits();
5766 let routes = registry
5767 .get_or_spawn(
5768 "127.0.0.1",
5769 port,
5770 max_req,
5771 max_res,
5772 max_inflight,
5773 test_rt(),
5774 "staged-first-spawn".into(),
5775 None,
5776 )
5777 .await
5778 .expect("spawn from staged listener must succeed");
5779
5780 assert_eq!(
5781 registry.bound_addr("127.0.0.1", port),
5782 Some(addr),
5783 "served socket must be the staged listener's addr"
5784 );
5785 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__staged_probe__"))
5788 .await
5789 .expect("http request against staged listener must connect");
5790 assert!(
5791 resp.status().as_u16() >= 200,
5792 "any status proves the staged socket serves"
5793 );
5794 drop(routes);
5795 }
5796
5797 #[allow(clippy::await_holding_lock)]
5798 #[tokio::test]
5799 async fn staged_entry_reused_by_second_caller() {
5800 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5801 ServerRegistry::reset();
5802 let registry = ServerRegistry::global();
5803 let (listener, _probe, addr) = clone_fixture_listener().await;
5804 let port = addr.port();
5805 registry
5806 .stage_listener(listener)
5807 .await
5808 .expect("stage listener");
5809
5810 let (max_req, max_res, max_inflight) = staged_limits();
5811 let first = registry
5812 .get_or_spawn(
5813 "127.0.0.1",
5814 port,
5815 max_req,
5816 max_res,
5817 max_inflight,
5818 test_rt(),
5819 "staged-reuse-1".into(),
5820 None,
5821 )
5822 .await
5823 .expect("first spawn from staged listener");
5824 let second = registry
5825 .get_or_spawn(
5826 "127.0.0.1",
5827 port,
5828 max_req,
5829 max_res,
5830 max_inflight,
5831 test_rt(),
5832 "staged-reuse-2".into(),
5833 None,
5834 )
5835 .await
5836 .expect("second caller must reuse the entry");
5837 assert_eq!(
5838 registry.bound_addr("127.0.0.1", port),
5839 Some(addr),
5840 "entry reused — bound addr unchanged, no second bind"
5841 );
5842 drop(first);
5843 drop(second);
5844 }
5845
5846 #[allow(clippy::await_holding_lock)]
5847 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5848 async fn staged_race_two_callers_single_resolver() {
5849 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5850 ServerRegistry::reset();
5851 let registry = ServerRegistry::global();
5852 let (listener, _probe, addr) = clone_fixture_listener().await;
5853 let port = addr.port();
5854 registry
5855 .stage_listener(listener)
5856 .await
5857 .expect("stage listener");
5858
5859 let (max_req, max_res, max_inflight) = staged_limits();
5864 let (first, second) = tokio::join!(
5865 registry.get_or_spawn(
5866 "127.0.0.1",
5867 port,
5868 max_req,
5869 max_res,
5870 max_inflight,
5871 test_rt(),
5872 "staged-race-1".into(),
5873 None,
5874 ),
5875 registry.get_or_spawn(
5876 "127.0.0.1",
5877 port,
5878 max_req,
5879 max_res,
5880 max_inflight,
5881 test_rt(),
5882 "staged-race-2".into(),
5883 None,
5884 ),
5885 );
5886 let first = first.expect("first racing caller must succeed");
5887 let second = second.expect("second racing caller must succeed");
5888 assert_eq!(
5889 registry.bound_addr("127.0.0.1", port),
5890 Some(addr),
5891 "single entry must be served from the staged socket — no EADDRINUSE path"
5892 );
5893 drop(first);
5894 drop(second);
5895 }
5896
5897 #[allow(clippy::await_holding_lock)]
5898 #[tokio::test]
5899 async fn unstaged_spawn_binds_legacy() {
5900 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5901 ServerRegistry::reset();
5902 let registry = ServerRegistry::global();
5903 let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe bind");
5905 let port = probe.local_addr().expect("local addr").port();
5906 drop(probe);
5907
5908 let (max_req, max_res, max_inflight) = staged_limits();
5909 registry
5910 .get_or_spawn(
5911 "127.0.0.1",
5912 port,
5913 max_req,
5914 max_res,
5915 max_inflight,
5916 test_rt(),
5917 "legacy-bind".into(),
5918 None,
5919 )
5920 .await
5921 .expect("legacy bind spawn");
5922 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__legacy_probe__"))
5923 .await
5924 .expect("connect to freshly bound port must succeed");
5925 assert!(resp.status().as_u16() >= 200);
5926 assert_eq!(
5927 registry.bound_addr("127.0.0.1", port),
5928 Some(std::net::SocketAddr::from(([127, 0, 0, 1], port))),
5929 "bound addr must be the legacy bound (host, port)"
5930 );
5931 }
5932
5933 #[allow(clippy::await_holding_lock)]
5934 #[tokio::test]
5935 async fn wrong_host_staged_port_fails_deterministically() {
5936 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5937 ServerRegistry::reset();
5938 let registry = ServerRegistry::global();
5939 let (listener, _probe, addr) = clone_fixture_listener().await;
5940 let port = addr.port();
5941 registry
5942 .stage_listener(listener)
5943 .await
5944 .expect("stage listener under 127.0.0.1");
5945
5946 let (max_req, max_res, max_inflight) = staged_limits();
5947 let err = registry
5948 .get_or_spawn(
5949 "localhost",
5950 port,
5951 max_req,
5952 max_res,
5953 max_inflight,
5954 test_rt(),
5955 "conflict-probe".into(),
5956 None,
5957 )
5958 .await
5959 .expect_err("wrong host on staged port must fail deterministically");
5960 assert!(
5961 err.to_string().contains("staged listener conflict on port"),
5962 "unexpected error: {err}"
5963 );
5964
5965 registry
5967 .get_or_spawn(
5968 "127.0.0.1",
5969 port,
5970 max_req,
5971 max_res,
5972 max_inflight,
5973 test_rt(),
5974 "conflict-after".into(),
5975 None,
5976 )
5977 .await
5978 .expect("correct host must serve the staged listener");
5979 assert_eq!(
5980 registry.bound_addr("127.0.0.1", port),
5981 Some(addr),
5982 "staged slot must be untouched by the conflicting call"
5983 );
5984 }
5985
5986 #[allow(clippy::await_holding_lock)]
5987 #[tokio::test]
5988 async fn duplicate_stage_same_key_rejected() {
5989 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5990 ServerRegistry::reset();
5991 let registry = ServerRegistry::global();
5992 let (listener, probe, addr) = clone_fixture_listener().await;
5993 registry
5994 .stage_listener(listener)
5995 .await
5996 .expect("stage listener A");
5997
5998 let dup = probe.try_clone().expect("clone2");
6000 dup.set_nonblocking(true).expect("set_nonblocking2");
6001 let b = tokio::net::TcpListener::from_std(dup).expect("from_std2");
6002
6003 let err = registry
6004 .stage_listener(b)
6005 .await
6006 .expect_err("duplicate stage must be rejected");
6007 assert!(
6008 err.to_string().contains("listener already staged"),
6009 "unexpected error: {err}"
6010 );
6011
6012 let (max_req, max_res, max_inflight) = staged_limits();
6013 registry
6014 .get_or_spawn(
6015 "127.0.0.1",
6016 addr.port(),
6017 max_req,
6018 max_res,
6019 max_inflight,
6020 test_rt(),
6021 "dup-stage-after".into(),
6022 None,
6023 )
6024 .await
6025 .expect("spawn from first staged listener");
6026 assert_eq!(
6027 registry.bound_addr("127.0.0.1", addr.port()),
6028 Some(addr),
6029 "first staged listener retained"
6030 );
6031 }
6032
6033 #[allow(clippy::await_holding_lock)]
6034 #[tokio::test]
6035 async fn distinct_keys_stage_independently() {
6036 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6037 ServerRegistry::reset();
6038 let registry = ServerRegistry::global();
6039 let (l1, _p1, addr1) = clone_fixture_listener().await;
6040 let (l2, _p2, addr2) = clone_fixture_listener().await;
6041 registry.stage_listener(l1).await.expect("stage P1");
6042 registry.stage_listener(l2).await.expect("stage P2");
6043
6044 let (max_req, max_res, max_inflight) = staged_limits();
6045 registry
6046 .get_or_spawn(
6047 "127.0.0.1",
6048 addr1.port(),
6049 max_req,
6050 max_res,
6051 max_inflight,
6052 test_rt(),
6053 "distinct-1".into(),
6054 None,
6055 )
6056 .await
6057 .expect("spawn P1");
6058 registry
6059 .get_or_spawn(
6060 "127.0.0.1",
6061 addr2.port(),
6062 max_req,
6063 max_res,
6064 max_inflight,
6065 test_rt(),
6066 "distinct-2".into(),
6067 None,
6068 )
6069 .await
6070 .expect("spawn P2");
6071 assert_eq!(
6072 registry.bound_addr("127.0.0.1", addr1.port()),
6073 Some(addr1),
6074 "P1 bound addr must be its own listener"
6075 );
6076 assert_eq!(
6077 registry.bound_addr("127.0.0.1", addr2.port()),
6078 Some(addr2),
6079 "P2 bound addr must be its own listener"
6080 );
6081 let r1 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr1.port()))
6082 .await
6083 .expect("connect P1");
6084 assert!(r1.status().as_u16() >= 200);
6085 let r2 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr2.port()))
6086 .await
6087 .expect("connect P2");
6088 assert!(r2.status().as_u16() >= 200);
6089 }
6090
6091 #[allow(clippy::await_holding_lock)]
6092 #[tokio::test]
6093 async fn tls_prebound_listener_served() {
6094 use camel_component_api::test_support::tls;
6095
6096 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
6099
6100 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6101 ServerRegistry::reset();
6102 let registry = ServerRegistry::global();
6103 let (listener, _probe, addr) = clone_fixture_listener().await;
6104 let port = addr.port();
6105
6106 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
6107 let cert_path = tls::write_pem_tmp("http-staged-tls-cert.pem", &cert_pem);
6108 let key_path = tls::write_pem_tmp("http-staged-tls-key.pem", &key_pem);
6109 let ca_path = tls::write_pem_tmp("http-staged-tls-ca.pem", &ca_pem);
6110
6111 let (max_req, max_res, max_inflight) = staged_limits();
6112 let routes = registry
6113 .get_or_spawn_with_listener(
6114 listener,
6115 max_req,
6116 max_res,
6117 max_inflight,
6118 test_rt(),
6119 "staged-tls".into(),
6120 Some(crate::config::ServerTlsConfig {
6121 cert_path: cert_path.to_string_lossy().into_owned(),
6122 key_path: key_path.to_string_lossy().into_owned(),
6123 }),
6124 )
6125 .await
6126 .expect("spawn TLS server from pre-bound listener");
6127
6128 let ca_bytes = std::fs::read(&ca_path).expect("read ca pem");
6131 let client = reqwest::Client::builder()
6132 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).expect("parse ca pem"))
6133 .build()
6134 .expect("build tls client");
6135
6136 let resp = client
6137 .get(format!("https://127.0.0.1:{port}/__staged_tls__"))
6138 .send()
6139 .await
6140 .expect("TLS handshake + request must succeed");
6141 assert_eq!(resp.status().as_u16(), 404, "unknown path 404s through TLS");
6142 assert_eq!(
6143 registry.bound_addr("127.0.0.1", port),
6144 Some(addr),
6145 "bound addr equals the pre-bound listener addr"
6146 );
6147 drop(routes);
6148 }
6149
6150 #[allow(clippy::await_holding_lock)]
6151 #[tokio::test]
6152 async fn with_listener_direct_spawn_keyed_by_actual_addr() {
6153 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6154 ServerRegistry::reset();
6155 let registry = ServerRegistry::global();
6156 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
6157 .await
6158 .expect("bind un-staged listener");
6159 let addr = listener.local_addr().expect("local addr");
6160 let port = addr.port();
6161
6162 let (max_req, max_res, max_inflight) = staged_limits();
6163 registry
6164 .get_or_spawn_with_listener(
6165 listener,
6166 max_req,
6167 max_res,
6168 max_inflight,
6169 test_rt(),
6170 "with-listener".into(),
6171 None,
6172 )
6173 .await
6174 .expect("direct spawn from un-staged listener");
6175 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__with_listener__"))
6176 .await
6177 .expect("connect on actual port");
6178 assert!(resp.status().as_u16() >= 200);
6179 assert_eq!(
6180 registry.bound_addr("127.0.0.1", port),
6181 Some(addr),
6182 "registry key is the listener's actual port"
6183 );
6184
6185 registry
6186 .get_or_spawn(
6187 "127.0.0.1",
6188 port,
6189 max_req,
6190 max_res,
6191 max_inflight,
6192 test_rt(),
6193 "with-listener-reuse".into(),
6194 None,
6195 )
6196 .await
6197 .expect("legacy caller must reuse the entry");
6198 assert_eq!(
6199 registry.bound_addr("127.0.0.1", port),
6200 Some(addr),
6201 "entry reused — no second bind"
6202 );
6203 }
6204
6205 #[tokio::test]
6210 async fn test_dispatch_handler_returns_404_for_unknown_path() {
6211 let registry = HttpRouteRegistry::new();
6212 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6214 let port = listener.local_addr().unwrap().port();
6215 tokio::spawn(run_axum_server(
6216 listener,
6217 registry,
6218 2 * 1024 * 1024,
6219 10 * 1024 * 1024,
6220 Arc::new(tokio::sync::Semaphore::new(1024)),
6221 test_rt(),
6222 "test-route".into(),
6223 ));
6224
6225 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
6227
6228 let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
6229 .await
6230 .unwrap();
6231 assert_eq!(resp.status().as_u16(), 404);
6232 }
6233
6234 #[tokio::test]
6239 async fn test_http_consumer_start_registers_path() {
6240 use camel_component_api::ConsumerContext;
6241
6242 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6244 let port = listener.local_addr().unwrap().port();
6245 drop(listener); let consumer_cfg = HttpServerConfig {
6248 scheme: "http".to_string(),
6249 host: "127.0.0.1".to_string(),
6250 port,
6251 path: "/ping".to_string(),
6252 max_request_body: 2 * 1024 * 1024,
6253 max_response_body: 10 * 1024 * 1024,
6254 max_inflight_requests: 1024,
6255 method: None,
6256 tls_config: None,
6257 };
6258 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6259
6260 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6261 let token = tokio_util::sync::CancellationToken::new();
6262 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6263
6264 tokio::spawn(async move {
6265 consumer.start(ctx).await.unwrap();
6266 });
6267
6268 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6269
6270 let client = reqwest::Client::new();
6271 let resp_future = client
6272 .post(format!("http://127.0.0.1:{port}/ping"))
6273 .body("hello world")
6274 .send();
6275
6276 let (http_result, _) = tokio::join!(resp_future, async {
6277 if let Some(mut envelope) = rx.recv().await {
6278 envelope.exchange.input.set_header(
6280 "CamelHttpResponseCode",
6281 serde_json::Value::Number(201.into()),
6282 );
6283 if let Some(reply_tx) = envelope.reply_tx {
6284 let _ = reply_tx.send(Ok(envelope.exchange));
6285 }
6286 }
6287 });
6288
6289 let resp = http_result.unwrap();
6290 assert_eq!(resp.status().as_u16(), 201);
6291
6292 token.cancel();
6293 }
6294
6295 #[test]
6299 fn test_envelope_channel_capacity_follows_max_inflight() {
6300 assert_eq!(envelope_channel_capacity(0), 1);
6301 assert_eq!(envelope_channel_capacity(1), 1);
6302 assert_eq!(envelope_channel_capacity(7), 7);
6303 assert_eq!(envelope_channel_capacity(64), 64);
6304 assert_eq!(envelope_channel_capacity(1024), 1024);
6305 }
6306
6307 #[tokio::test]
6311 async fn test_http_consumer_start_with_zero_max_inflight_rejects_503() {
6312 use camel_component_api::ConsumerContext;
6313
6314 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6315 let port = listener.local_addr().unwrap().port();
6316 drop(listener);
6317
6318 let consumer_cfg = HttpServerConfig {
6319 scheme: "http".to_string(),
6320 host: "127.0.0.1".to_string(),
6321 port,
6322 path: "/ping".to_string(),
6323 max_request_body: 2 * 1024 * 1024,
6324 max_response_body: 10 * 1024 * 1024,
6325 max_inflight_requests: 0,
6326 method: None,
6327 tls_config: None,
6328 };
6329 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6330
6331 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6332 let token = tokio_util::sync::CancellationToken::new();
6333 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6334
6335 let start_handle = tokio::spawn(async move {
6336 consumer.start(ctx).await.unwrap();
6337 });
6338
6339 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6340
6341 let client = reqwest::Client::new();
6342 let resp = client
6343 .post(format!("http://127.0.0.1:{port}/ping"))
6344 .body("hello world")
6345 .send()
6346 .await
6347 .unwrap();
6348 assert_eq!(resp.status().as_u16(), 503);
6349
6350 token.cancel();
6351 let _ = start_handle.await;
6352 }
6353
6354 #[test]
6357 fn test_http_consumer_startup_mode_is_explicit() {
6358 use camel_component_api::ConsumerStartupMode;
6359 let consumer_cfg = HttpServerConfig {
6360 scheme: "http".to_string(),
6361 host: "127.0.0.1".to_string(),
6362 port: 0,
6363 path: "/x".to_string(),
6364 max_request_body: 2 * 1024 * 1024,
6365 max_response_body: 10 * 1024 * 1024,
6366 max_inflight_requests: 1024,
6367 method: None,
6368 tls_config: None,
6369 };
6370 let consumer = HttpConsumer::new(consumer_cfg, test_rt());
6371 assert_eq!(
6372 consumer.startup_mode(),
6373 ConsumerStartupMode::Explicit,
6374 "HttpConsumer must opt into Explicit startup"
6375 );
6376 }
6377
6378 #[allow(clippy::await_holding_lock)]
6384 #[tokio::test]
6385 async fn test_http_consumer_emits_mark_ready_after_bind() {
6386 use camel_component_api::{ConsumerContext, StartupSignal};
6387
6388 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6389
6390 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6391 let port = listener.local_addr().unwrap().port();
6392 drop(listener);
6393
6394 let consumer_cfg = HttpServerConfig {
6395 scheme: "http".to_string(),
6396 host: "127.0.0.1".to_string(),
6397 port,
6398 path: "/ready-probe".to_string(),
6399 max_request_body: 2 * 1024 * 1024,
6400 max_response_body: 10 * 1024 * 1024,
6401 max_inflight_requests: 1024,
6402 method: None,
6403 tls_config: None,
6404 };
6405 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6406
6407 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6408 let token = tokio_util::sync::CancellationToken::new();
6409 let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
6410
6411 let (signal, startup_rx) = StartupSignal::pair();
6413 let ctx = ctx.with_startup(signal);
6414
6415 tokio::spawn(async move {
6418 let _ = consumer.start(ctx).await;
6419 });
6420
6421 let result =
6426 tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
6427 .await
6428 .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
6429 assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
6430
6431 token.cancel();
6433 }
6434
6435 #[tokio::test]
6436 async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
6437 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6438
6439 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6440 let port = listener.local_addr().unwrap().port();
6441 drop(listener);
6442
6443 let consumer_cfg = HttpServerConfig {
6444 scheme: "http".to_string(),
6445 host: "127.0.0.1".to_string(),
6446 port,
6447 path: "/saturation".to_string(),
6448 max_request_body: 2 * 1024 * 1024,
6449 max_response_body: 10 * 1024 * 1024,
6450 max_inflight_requests: 1,
6451 method: None,
6452 tls_config: None,
6453 };
6454 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6455
6456 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6457 let token = tokio_util::sync::CancellationToken::new();
6458 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6459 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6460 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6461
6462 let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
6463 let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
6464
6465 tokio::spawn(async move {
6466 let mut first_seen_tx = Some(first_seen_tx);
6467 let mut unblock_first_rx = Some(unblock_first_rx);
6468
6469 while let Some(envelope) = rx.recv().await {
6470 if let Some(tx) = first_seen_tx.take() {
6471 let _ = tx.send(());
6472 if let Some(rx_unblock) = unblock_first_rx.take() {
6473 let _ = rx_unblock.await;
6474 }
6475 }
6476
6477 if let Some(reply_tx) = envelope.reply_tx {
6478 let _ = reply_tx.send(Ok(envelope.exchange));
6479 }
6480 }
6481 });
6482
6483 let client = reqwest::Client::new();
6484 let first_req = {
6485 let client = client.clone();
6486 async move {
6487 client
6488 .get(format!("http://127.0.0.1:{port}/saturation"))
6489 .send()
6490 .await
6491 .unwrap()
6492 }
6493 };
6494
6495 let first_handle = tokio::spawn(first_req);
6496 first_seen_rx.await.unwrap();
6497
6498 let second_resp = client
6499 .get(format!("http://127.0.0.1:{port}/saturation"))
6500 .send()
6501 .await
6502 .unwrap();
6503
6504 assert_eq!(second_resp.status().as_u16(), 503);
6505
6506 let _ = unblock_first_tx.send(());
6507 let first_resp = first_handle.await.unwrap();
6508 assert_eq!(first_resp.status().as_u16(), 200);
6509
6510 token.cancel();
6511 }
6512
6513 #[tokio::test]
6517 async fn test_http_consumer_chunked_body_is_capped() {
6518 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6519
6520 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6521 let port = listener.local_addr().unwrap().port();
6522 drop(listener);
6523
6524 let consumer_cfg = HttpServerConfig {
6525 scheme: "http".to_string(),
6526 host: "127.0.0.1".to_string(),
6527 port,
6528 path: "/chunked-cap".to_string(),
6529 max_request_body: 1024, max_response_body: 10 * 1024 * 1024,
6531 max_inflight_requests: 16,
6532 method: None,
6533 tls_config: None,
6534 };
6535 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6536
6537 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6538 let token = tokio_util::sync::CancellationToken::new();
6539 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6540 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6541 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6542
6543 let chunks: Vec<Result<bytes::Bytes, std::io::Error>> = (0..8)
6545 .map(|_| Ok(bytes::Bytes::from(vec![b'A'; 512])))
6546 .collect();
6547 let stream_body = reqwest::Body::wrap_stream(futures::stream::iter(chunks));
6548
6549 let client = reqwest::Client::new();
6550 let send_fut = client
6551 .post(format!("http://127.0.0.1:{port}/chunked-cap"))
6552 .body(stream_body)
6553 .send();
6554
6555 let (http_result, _) = tokio::join!(send_fut, async {
6556 if let Some(mut envelope) = rx.recv().await {
6557 let materialized = envelope
6559 .exchange
6560 .input
6561 .body
6562 .clone()
6563 .into_bytes(64 * 1024)
6564 .await;
6565 assert!(
6566 materialized.is_err(),
6567 "materializing a 4 KiB chunked body under a 1 KiB cap must fail"
6568 );
6569 let err = materialized.unwrap_err().to_string();
6570 assert!(
6571 err.contains("limit") || err.contains("exceeds"),
6572 "error should mention the limit: {err}"
6573 );
6574 if let Some(reply_tx) = envelope.reply_tx {
6575 envelope.exchange.input.body =
6576 camel_component_api::Body::Text("handled".to_string());
6577 let _ = reply_tx.send(Ok(envelope.exchange));
6578 }
6579 }
6580 });
6581
6582 let resp = http_result.unwrap();
6583 assert_eq!(resp.status().as_u16(), 200);
6584
6585 token.cancel();
6586 }
6587
6588 #[tokio::test]
6589 #[allow(clippy::await_holding_lock)]
6590 async fn test_http_consumer_enforces_max_response_body_for_bytes() {
6591 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6592
6593 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6594
6595 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6596 let port = listener.local_addr().unwrap().port();
6597 drop(listener);
6598
6599 let consumer_cfg = HttpServerConfig {
6600 scheme: "http".to_string(),
6601 host: "127.0.0.1".to_string(),
6602 port,
6603 path: "/limit-bytes".to_string(),
6604 max_request_body: 2 * 1024 * 1024,
6605 max_response_body: 16,
6606 max_inflight_requests: 1024,
6607 method: None,
6608 tls_config: None,
6609 };
6610 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6611
6612 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6613 let token = tokio_util::sync::CancellationToken::new();
6614 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6615 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6616 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6617
6618 let client = reqwest::Client::new();
6619 let send_fut = client
6620 .get(format!("http://127.0.0.1:{port}/limit-bytes"))
6621 .send();
6622
6623 let (http_result, _) = tokio::join!(send_fut, async {
6624 if let Some(mut envelope) = rx.recv().await {
6625 envelope.exchange.input.body =
6626 camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
6627 if let Some(reply_tx) = envelope.reply_tx {
6628 let _ = reply_tx.send(Ok(envelope.exchange));
6629 }
6630 }
6631 });
6632
6633 let resp = http_result.unwrap();
6634 assert_eq!(resp.status().as_u16(), 500);
6635 let body = resp.text().await.unwrap();
6636 assert_eq!(body, "Response body exceeds configured limit");
6637 token.cancel();
6638 }
6639
6640 #[tokio::test]
6641 #[allow(clippy::await_holding_lock)]
6642 async fn test_http_consumer_enforces_max_response_body_for_json() {
6643 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6644
6645 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6646
6647 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6648 let port = listener.local_addr().unwrap().port();
6649 drop(listener);
6650
6651 let consumer_cfg = HttpServerConfig {
6652 scheme: "http".to_string(),
6653 host: "127.0.0.1".to_string(),
6654 port,
6655 path: "/limit-json".to_string(),
6656 max_request_body: 2 * 1024 * 1024,
6657 max_response_body: 16,
6658 max_inflight_requests: 1024,
6659 method: None,
6660 tls_config: None,
6661 };
6662 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6663
6664 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6665 let token = tokio_util::sync::CancellationToken::new();
6666 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6667 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6668 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6669
6670 let client = reqwest::Client::new();
6671 let send_fut = client
6672 .get(format!("http://127.0.0.1:{port}/limit-json"))
6673 .send();
6674
6675 let (http_result, _) = tokio::join!(send_fut, async {
6676 if let Some(mut envelope) = rx.recv().await {
6677 envelope.exchange.input.body = camel_component_api::Body::Json(
6678 serde_json::json!({"message":"this response is bigger than sixteen"}),
6679 );
6680 if let Some(reply_tx) = envelope.reply_tx {
6681 let _ = reply_tx.send(Ok(envelope.exchange));
6682 }
6683 }
6684 });
6685
6686 let resp = http_result.unwrap();
6687 assert_eq!(resp.status().as_u16(), 500);
6688 let body = resp.text().await.unwrap();
6689 assert_eq!(body, "Response body exceeds configured limit");
6690 token.cancel();
6691 }
6692
6693 #[tokio::test]
6694 #[allow(clippy::await_holding_lock)]
6695 async fn test_http_consumer_enforces_max_response_body_for_xml() {
6696 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6697
6698 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6699
6700 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6701 let port = listener.local_addr().unwrap().port();
6702 drop(listener);
6703
6704 let consumer_cfg = HttpServerConfig {
6705 scheme: "http".to_string(),
6706 host: "127.0.0.1".to_string(),
6707 port,
6708 path: "/limit-xml".to_string(),
6709 max_request_body: 2 * 1024 * 1024,
6710 max_response_body: 16,
6711 max_inflight_requests: 1024,
6712 method: None,
6713 tls_config: None,
6714 };
6715 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6716
6717 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6718 let token = tokio_util::sync::CancellationToken::new();
6719 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6720 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6721 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6722
6723 let client = reqwest::Client::new();
6724 let send_fut = client
6725 .get(format!("http://127.0.0.1:{port}/limit-xml"))
6726 .send();
6727
6728 let (http_result, _) = tokio::join!(send_fut, async {
6729 if let Some(mut envelope) = rx.recv().await {
6730 envelope.exchange.input.body = camel_component_api::Body::Xml(
6731 "<root><value>way-too-large</value></root>".into(),
6732 );
6733 if let Some(reply_tx) = envelope.reply_tx {
6734 let _ = reply_tx.send(Ok(envelope.exchange));
6735 }
6736 }
6737 });
6738
6739 let resp = http_result.unwrap();
6740 assert_eq!(resp.status().as_u16(), 500);
6741 let body = resp.text().await.unwrap();
6742 assert_eq!(body, "Response body exceeds configured limit");
6743 token.cancel();
6744 }
6745
6746 #[tokio::test]
6747 #[allow(clippy::await_holding_lock)]
6748 async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
6749 use camel_component_api::{
6750 CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
6751 };
6752 use futures::stream;
6753
6754 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6755
6756 let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
6757 let port = listener.local_addr().unwrap().port();
6758 drop(listener);
6759
6760 let consumer_cfg = HttpServerConfig {
6761 scheme: "http".to_string(),
6762 host: "0.0.0.0".to_string(),
6763 port,
6764 path: "/limit-stream".to_string(),
6765 max_request_body: 2 * 1024 * 1024,
6766 max_response_body: 16,
6767 max_inflight_requests: 1024,
6768 method: None,
6769 tls_config: None,
6770 };
6771 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6772
6773 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6774 let token = tokio_util::sync::CancellationToken::new();
6775 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6776 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6777 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6778
6779 let client = reqwest::Client::new();
6780 let send_fut = client
6781 .get(format!("http://127.0.0.1:{port}/limit-stream"))
6782 .send();
6783
6784 let (http_result, _) = tokio::join!(send_fut, async {
6785 if let Some(mut envelope) = rx.recv().await {
6786 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
6787 vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
6788 let stream = Box::pin(stream::iter(chunks));
6789 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
6790 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
6791 metadata: StreamMetadata {
6792 size_hint: Some(32),
6793 content_type: Some("application/octet-stream".into()),
6794 origin: None,
6795 },
6796 });
6797 if let Some(reply_tx) = envelope.reply_tx {
6798 let _ = reply_tx.send(Ok(envelope.exchange));
6799 }
6800 }
6801 });
6802
6803 let resp = http_result.unwrap();
6804 assert_eq!(resp.status().as_u16(), 200);
6805 let body = resp.bytes().await.unwrap();
6806 assert_eq!(body.len(), 32);
6807 token.cancel();
6808 }
6809
6810 #[tokio::test]
6815 #[allow(clippy::await_holding_lock)]
6816 async fn test_integration_single_consumer_round_trip() {
6817 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6818
6819 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6823
6824 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6826 let port = listener.local_addr().unwrap().port();
6827 drop(listener); let component = HttpComponent::new();
6830 let endpoint_ctx = NoOpComponentContext;
6831 let endpoint = component
6832 .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
6833 .unwrap();
6834 let mut consumer = endpoint.create_consumer(rt()).unwrap();
6835
6836 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6837 let token = tokio_util::sync::CancellationToken::new();
6838 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6839
6840 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6841 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6842
6843 let client = reqwest::Client::new();
6844 let send_fut = client
6845 .post(format!("http://127.0.0.1:{port}/echo"))
6846 .header("Content-Type", "text/plain")
6847 .body("ping")
6848 .send();
6849
6850 let (http_result, _) = tokio::join!(send_fut, async {
6851 if let Some(mut envelope) = rx.recv().await {
6852 assert_eq!(
6853 envelope.exchange.input.header("CamelHttpMethod"),
6854 Some(&serde_json::Value::String("POST".into()))
6855 );
6856 assert_eq!(
6857 envelope.exchange.input.header("CamelHttpPath"),
6858 Some(&serde_json::Value::String("/echo".into()))
6859 );
6860 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
6861 if let Some(reply_tx) = envelope.reply_tx {
6862 let _ = reply_tx.send(Ok(envelope.exchange));
6863 }
6864 }
6865 });
6866
6867 let resp = http_result.unwrap();
6868 assert_eq!(resp.status().as_u16(), 200);
6869 let body = resp.text().await.unwrap();
6870 assert_eq!(body, "pong");
6871
6872 token.cancel();
6873 }
6874
6875 #[tokio::test]
6876 #[allow(clippy::await_holding_lock)]
6877 async fn test_integration_two_consumers_shared_port() {
6878 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6879
6880 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6881
6882 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6884 let port = listener.local_addr().unwrap().port();
6885 drop(listener);
6886
6887 let component = HttpComponent::new();
6888 let endpoint_ctx = NoOpComponentContext;
6889
6890 let endpoint_a = component
6892 .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
6893 .unwrap();
6894 let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
6895
6896 let endpoint_b = component
6898 .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
6899 .unwrap();
6900 let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
6901
6902 let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6903 let token_a = tokio_util::sync::CancellationToken::new();
6904 let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
6905
6906 let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6907 let token_b = tokio_util::sync::CancellationToken::new();
6908 let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
6909
6910 tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
6911 tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
6912 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6913
6914 let client = reqwest::Client::new();
6915
6916 let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
6918 let (resp_hello, _) = tokio::join!(fut_hello, async {
6919 if let Some(mut envelope) = rx_a.recv().await {
6920 envelope.exchange.input.body =
6921 camel_component_api::Body::Text("hello-response".to_string());
6922 if let Some(reply_tx) = envelope.reply_tx {
6923 let _ = reply_tx.send(Ok(envelope.exchange));
6924 }
6925 }
6926 });
6927
6928 let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
6930 let (resp_world, _) = tokio::join!(fut_world, async {
6931 if let Some(mut envelope) = rx_b.recv().await {
6932 envelope.exchange.input.body =
6933 camel_component_api::Body::Text("world-response".to_string());
6934 if let Some(reply_tx) = envelope.reply_tx {
6935 let _ = reply_tx.send(Ok(envelope.exchange));
6936 }
6937 }
6938 });
6939
6940 let body_a = resp_hello.unwrap().text().await.unwrap();
6941 let body_b = resp_world.unwrap().text().await.unwrap();
6942
6943 assert_eq!(body_a, "hello-response");
6944 assert_eq!(body_b, "world-response");
6945
6946 token_a.cancel();
6947 token_b.cancel();
6948 }
6949
6950 #[tokio::test]
6951 #[allow(clippy::await_holding_lock)]
6952 async fn test_integration_unregistered_path_returns_404() {
6953 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6954
6955 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6956
6957 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6959 let port = listener.local_addr().unwrap().port();
6960 drop(listener);
6961
6962 let component = HttpComponent::new();
6963 let endpoint_ctx = NoOpComponentContext;
6964 let endpoint = component
6965 .create_endpoint(
6966 &format!("http://127.0.0.1:{port}/registered"),
6967 &endpoint_ctx,
6968 )
6969 .unwrap();
6970 let mut consumer = endpoint.create_consumer(rt()).unwrap();
6971
6972 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6973 let token = tokio_util::sync::CancellationToken::new();
6974 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6975
6976 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6977
6978 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
6980 loop {
6981 if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
6982 .await
6983 .is_ok()
6984 {
6985 break;
6986 }
6987 if std::time::Instant::now() >= deadline {
6988 panic!("HTTP server did not start within 5s on port {port}");
6989 }
6990 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
6991 }
6992
6993 let client = reqwest::Client::new();
6994 let resp = client
6995 .get(format!("http://127.0.0.1:{port}/not-there"))
6996 .send()
6997 .await
6998 .unwrap();
6999 assert_eq!(resp.status().as_u16(), 404);
7000
7001 token.cancel();
7002 }
7003
7004 #[test]
7005 fn test_http_consumer_declares_concurrent() {
7006 use camel_component_api::ConcurrencyModel;
7007
7008 let config = HttpServerConfig {
7009 scheme: "http".to_string(),
7010 host: "127.0.0.1".to_string(),
7011 port: 19999,
7012 path: "/test".to_string(),
7013 max_request_body: 2 * 1024 * 1024,
7014 max_response_body: 10 * 1024 * 1024,
7015 max_inflight_requests: 1024,
7016 method: None,
7017 tls_config: None,
7018 };
7019 let consumer = HttpConsumer::new(config, test_rt());
7020 assert_eq!(
7021 consumer.concurrency_model(),
7022 ConcurrencyModel::Concurrent { max: None }
7023 );
7024 }
7025
7026 #[test]
7027 fn server_config_parses_tls_cert_and_key() {
7028 let cfg = HttpServerConfig::from_uri(
7029 "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
7030 )
7031 .unwrap();
7032 assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
7033 assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
7034 }
7035
7036 #[test]
7037 fn server_config_no_tls_when_params_absent() {
7038 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
7039 assert!(cfg.tls_config.is_none());
7040 }
7041
7042 #[tokio::test]
7047 async fn test_http_reply_body_stream_variant_exists() {
7048 use bytes::Bytes;
7049 use camel_component_api::CamelError;
7050 use futures::stream;
7051
7052 let chunks: Vec<Result<Bytes, CamelError>> =
7053 vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
7054 let stream = Box::pin(stream::iter(chunks));
7055 let reply_body = HttpReplyBody::Stream(stream);
7056 match reply_body {
7058 HttpReplyBody::Stream(_) => {}
7059 HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
7060 }
7061 }
7062
7063 #[cfg(feature = "otel")]
7068 mod otel_tests {
7069 use super::*;
7070 use camel_component_api::Message;
7071 use tower::ServiceExt;
7072
7073 #[tokio::test]
7074 async fn test_producer_injects_traceparent_header() {
7075 let (url, _handle) = start_test_server_with_header_capture().await;
7076 let ctx = test_producer_ctx();
7077
7078 let component = HttpComponent::new();
7079 let endpoint_ctx = NoOpComponentContext;
7080 let endpoint = component
7081 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
7082 .unwrap();
7083 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7084
7085 let mut exchange = Exchange::new(Message::default());
7087 let mut headers = std::collections::HashMap::new();
7088 headers.insert(
7089 "traceparent".to_string(),
7090 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
7091 );
7092 camel_otel::extract_into_exchange(&mut exchange, &headers);
7093
7094 let result = producer.oneshot(exchange).await.unwrap();
7095
7096 let status = result
7098 .input
7099 .header("CamelHttpResponseCode")
7100 .and_then(|v| v.as_u64())
7101 .unwrap();
7102 assert_eq!(status, 200);
7103
7104 let traceparent = result.input.header("X-Received-Traceparent");
7106 assert!(
7107 traceparent.is_some(),
7108 "traceparent header should have been sent"
7109 );
7110
7111 let traceparent_str = traceparent.unwrap().as_str().unwrap();
7112 let parts: Vec<&str> = traceparent_str.split('-').collect();
7114 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
7115 assert_eq!(parts[0], "00", "version should be 00");
7116 assert_eq!(
7117 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
7118 "trace-id should match"
7119 );
7120 assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
7121 assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
7122 }
7123
7124 #[tokio::test]
7125 async fn test_consumer_extracts_traceparent_header() {
7126 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7127
7128 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7130 let port = listener.local_addr().unwrap().port();
7131 drop(listener);
7132
7133 let component = HttpComponent::new();
7134 let endpoint_ctx = NoOpComponentContext;
7135 let endpoint = component
7136 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
7137 .unwrap();
7138 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7139
7140 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7141 let token = tokio_util::sync::CancellationToken::new();
7142 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7143
7144 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7145 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7146
7147 let client = reqwest::Client::new();
7149 let send_fut = client
7150 .post(format!("http://127.0.0.1:{port}/trace"))
7151 .header(
7152 "traceparent",
7153 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
7154 )
7155 .body("test")
7156 .send();
7157
7158 let (http_result, _) = tokio::join!(send_fut, async {
7159 if let Some(envelope) = rx.recv().await {
7160 let mut injected_headers = std::collections::HashMap::new();
7163 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
7164
7165 assert!(
7166 injected_headers.contains_key("traceparent"),
7167 "Exchange should have traceparent after extraction"
7168 );
7169
7170 let traceparent = injected_headers.get("traceparent").unwrap();
7171 let parts: Vec<&str> = traceparent.split('-').collect();
7172 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
7173 assert_eq!(
7174 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
7175 "Trace ID should match the original traceparent header"
7176 );
7177
7178 if let Some(reply_tx) = envelope.reply_tx {
7179 let _ = reply_tx.send(Ok(envelope.exchange));
7180 }
7181 }
7182 });
7183
7184 let resp = http_result.unwrap();
7185 assert_eq!(resp.status().as_u16(), 200);
7186
7187 token.cancel();
7188 }
7189
7190 #[tokio::test]
7191 async fn test_consumer_extracts_mixed_case_traceparent_header() {
7192 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7193
7194 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7196 let port = listener.local_addr().unwrap().port();
7197 drop(listener);
7198
7199 let component = HttpComponent::new();
7200 let endpoint_ctx = NoOpComponentContext;
7201 let endpoint = component
7202 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
7203 .unwrap();
7204 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7205
7206 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7207 let token = tokio_util::sync::CancellationToken::new();
7208 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7209
7210 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7211 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7212
7213 let client = reqwest::Client::new();
7215 let send_fut = client
7216 .post(format!("http://127.0.0.1:{port}/trace"))
7217 .header(
7218 "TraceParent",
7219 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
7220 )
7221 .body("test")
7222 .send();
7223
7224 let (http_result, _) = tokio::join!(send_fut, async {
7225 if let Some(envelope) = rx.recv().await {
7226 let mut injected_headers = HashMap::new();
7229 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
7230
7231 assert!(
7232 injected_headers.contains_key("traceparent"),
7233 "Exchange should have traceparent after extraction from mixed-case header"
7234 );
7235
7236 let traceparent = injected_headers.get("traceparent").unwrap();
7237 let parts: Vec<&str> = traceparent.split('-').collect();
7238 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
7239 assert_eq!(
7240 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
7241 "Trace ID should match the original mixed-case TraceParent header"
7242 );
7243
7244 if let Some(reply_tx) = envelope.reply_tx {
7245 let _ = reply_tx.send(Ok(envelope.exchange));
7246 }
7247 }
7248 });
7249
7250 let resp = http_result.unwrap();
7251 assert_eq!(resp.status().as_u16(), 200);
7252
7253 token.cancel();
7254 }
7255
7256 #[tokio::test]
7257 async fn test_producer_no_trace_context_no_crash() {
7258 let (url, _handle) = start_test_server().await;
7259 let ctx = test_producer_ctx();
7260
7261 let component = HttpComponent::new();
7262 let endpoint_ctx = NoOpComponentContext;
7263 let endpoint = component
7264 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
7265 .unwrap();
7266 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7267
7268 let exchange = Exchange::new(Message::default());
7270
7271 let result = producer.oneshot(exchange).await.unwrap();
7273
7274 let status = result
7276 .input
7277 .header("CamelHttpResponseCode")
7278 .and_then(|v| v.as_u64())
7279 .unwrap();
7280 assert_eq!(status, 200);
7281 }
7282
7283 async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
7285 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7286 let addr = listener.local_addr().unwrap();
7287 let url = format!("http://127.0.0.1:{}", addr.port());
7288
7289 let handle = tokio::spawn(async move {
7290 loop {
7291 if let Ok((mut stream, _)) = listener.accept().await {
7292 tokio::spawn(async move {
7293 use tokio::io::{AsyncReadExt, AsyncWriteExt};
7294 let mut buf = vec![0u8; 8192];
7295 let n = stream.read(&mut buf).await.unwrap_or(0);
7296 let request = String::from_utf8_lossy(&buf[..n]).to_string();
7297
7298 let traceparent = request
7300 .lines()
7301 .find(|line| line.to_lowercase().starts_with("traceparent:"))
7302 .map(|line| {
7303 line.split(':')
7304 .nth(1)
7305 .map(|s| s.trim().to_string())
7306 .unwrap_or_default()
7307 })
7308 .unwrap_or_default();
7309
7310 let body =
7311 format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
7312 let response = format!(
7313 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
7314 body.len(),
7315 traceparent,
7316 body
7317 );
7318 let _ = stream.write_all(response.as_bytes()).await;
7319 });
7320 }
7321 }
7322 });
7323
7324 (url, handle)
7325 }
7326 }
7327
7328 #[tokio::test]
7337 async fn test_request_body_arrives_as_stream() {
7338 use camel_component_api::Body;
7339 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7340
7341 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7342 let port = listener.local_addr().unwrap().port();
7343 drop(listener);
7344
7345 let component = HttpComponent::new();
7346 let endpoint_ctx = NoOpComponentContext;
7347 let endpoint = component
7348 .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
7349 .unwrap();
7350 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7351
7352 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7353 let token = tokio_util::sync::CancellationToken::new();
7354 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7355
7356 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7357 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7358
7359 let client = reqwest::Client::new();
7360 let send_fut = client
7361 .post(format!("http://127.0.0.1:{port}/upload"))
7362 .body("hello streaming world")
7363 .send();
7364
7365 let (http_result, _) = tokio::join!(send_fut, async {
7366 if let Some(mut envelope) = rx.recv().await {
7367 assert!(
7369 matches!(envelope.exchange.input.body, Body::Stream(_)),
7370 "expected Body::Stream, got discriminant {:?}",
7371 std::mem::discriminant(&envelope.exchange.input.body)
7372 );
7373 let bytes = envelope
7375 .exchange
7376 .input
7377 .body
7378 .into_bytes(1024 * 1024)
7379 .await
7380 .unwrap();
7381 assert_eq!(&bytes[..], b"hello streaming world");
7382
7383 envelope.exchange.input.body = camel_component_api::Body::Empty;
7384 if let Some(reply_tx) = envelope.reply_tx {
7385 let _ = reply_tx.send(Ok(envelope.exchange));
7386 }
7387 }
7388 });
7389
7390 let resp = http_result.unwrap();
7391 assert_eq!(resp.status().as_u16(), 200);
7392
7393 token.cancel();
7394 }
7395
7396 #[tokio::test]
7401 async fn test_streaming_response_chunked() {
7402 use bytes::Bytes;
7403 use camel_component_api::Body;
7404 use camel_component_api::CamelError;
7405 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7406 use camel_component_api::{StreamBody, StreamMetadata};
7407 use futures::stream;
7408 use std::sync::Arc;
7409 use tokio::sync::Mutex;
7410
7411 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7412 let port = listener.local_addr().unwrap().port();
7413 drop(listener);
7414
7415 let component = HttpComponent::new();
7416 let endpoint_ctx = NoOpComponentContext;
7417 let endpoint = component
7418 .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
7419 .unwrap();
7420 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7421
7422 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7423 let token = tokio_util::sync::CancellationToken::new();
7424 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7425
7426 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7427 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7428
7429 let client = reqwest::Client::new();
7430 let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
7431
7432 let (http_result, _) = tokio::join!(send_fut, async {
7433 if let Some(mut envelope) = rx.recv().await {
7434 let chunks: Vec<Result<Bytes, CamelError>> =
7436 vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
7437 let stream = Box::pin(stream::iter(chunks));
7438 envelope.exchange.input.body = Body::Stream(StreamBody {
7439 stream: Arc::new(Mutex::new(Some(stream))),
7440 metadata: StreamMetadata::default(),
7441 });
7442 if let Some(reply_tx) = envelope.reply_tx {
7443 let _ = reply_tx.send(Ok(envelope.exchange));
7444 }
7445 }
7446 });
7447
7448 let resp = http_result.unwrap();
7449 assert_eq!(resp.status().as_u16(), 200);
7450 let body = resp.text().await.unwrap();
7451 assert_eq!(body, "chunk1chunk2");
7452
7453 token.cancel();
7454 }
7455
7456 #[tokio::test]
7461 async fn test_413_when_content_length_exceeds_limit() {
7462 use camel_component_api::ConsumerContext;
7463
7464 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7465 let port = listener.local_addr().unwrap().port();
7466 drop(listener);
7467
7468 let component = HttpComponent::new();
7470 let endpoint_ctx = NoOpComponentContext;
7471 let endpoint = component
7472 .create_endpoint(
7473 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
7474 &endpoint_ctx,
7475 )
7476 .unwrap();
7477 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7478
7479 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7480 let token = tokio_util::sync::CancellationToken::new();
7481 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7482
7483 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7484 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7485
7486 let client = reqwest::Client::new();
7487 let resp = client
7488 .post(format!("http://127.0.0.1:{port}/upload"))
7489 .header("Content-Length", "1000") .body("x".repeat(1000))
7491 .send()
7492 .await
7493 .unwrap();
7494
7495 assert_eq!(resp.status().as_u16(), 413);
7496
7497 token.cancel();
7498 }
7499
7500 #[tokio::test]
7504 async fn test_chunked_upload_without_content_length_bypasses_limit() {
7505 use bytes::Bytes;
7506 use camel_component_api::Body;
7507 use camel_component_api::ConsumerContext;
7508 use futures::stream;
7509
7510 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7511 let port = listener.local_addr().unwrap().port();
7512 drop(listener);
7513
7514 let component = HttpComponent::new();
7516 let endpoint_ctx = NoOpComponentContext;
7517 let endpoint = component
7518 .create_endpoint(
7519 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
7520 &endpoint_ctx,
7521 )
7522 .unwrap();
7523 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7524
7525 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7526 let token = tokio_util::sync::CancellationToken::new();
7527 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7528
7529 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7530 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7531
7532 let client = reqwest::Client::new();
7533
7534 let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
7538 Ok(Bytes::from("y".repeat(50))),
7539 Ok(Bytes::from("y".repeat(50))),
7540 ];
7541 let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
7542 let send_fut = client
7543 .post(format!("http://127.0.0.1:{port}/upload"))
7544 .body(stream_body)
7545 .send();
7546
7547 let consumer_fut = async {
7548 match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
7550 Ok(Some(mut envelope)) => {
7551 assert!(
7552 matches!(envelope.exchange.input.body, Body::Stream(_)),
7553 "expected Body::Stream"
7554 );
7555 envelope.exchange.input.body = camel_component_api::Body::Empty;
7556 if let Some(reply_tx) = envelope.reply_tx {
7557 let _ = reply_tx.send(Ok(envelope.exchange));
7558 }
7559 }
7560 Ok(None) => panic!("consumer channel closed unexpectedly"),
7561 Err(_) => {
7562 }
7565 }
7566 };
7567
7568 let (http_result, _) = tokio::join!(send_fut, consumer_fut);
7569
7570 let resp = http_result.unwrap();
7571 assert_ne!(
7578 resp.status().as_u16(),
7579 413,
7580 "chunked upload has no Content-Length to pre-check"
7581 );
7582 assert_eq!(resp.status().as_u16(), 200);
7583
7584 token.cancel();
7585 }
7586
7587 #[test]
7588 fn test_is_private_ip_ranges() {
7589 use camel_api::is_ssrf_blocked_ip;
7590 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(
7609 &"2001:4860:4860::8888".parse().unwrap()
7610 )); }
7612
7613 #[test]
7614 fn test_title_case_header() {
7615 assert_eq!(title_case_header("content-type"), "Content-Type");
7616 assert_eq!(title_case_header("authorization"), "Authorization");
7617 assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
7618 assert_eq!(title_case_header("host"), "Host");
7619 assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
7620 assert_eq!(title_case_header("single"), "Single");
7621 assert_eq!(title_case_header(""), "");
7622 }
7623
7624 #[test]
7625 fn test_resolve_url_combines_path_and_query_sources() {
7626 let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
7627 let mut exchange = Exchange::new(Message::default());
7628 exchange.input.set_header(
7629 "CamelHttpPath",
7630 serde_json::Value::String("next".to_string()),
7631 );
7632 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7633 assert!(url.starts_with("http://example.com/base/next?"));
7634 assert!(url.contains("foo=bar"));
7635
7636 exchange.input.set_header(
7637 "CamelHttpUri",
7638 serde_json::Value::String("http://other.test/root".to_string()),
7639 );
7640 exchange.input.set_header(
7641 "CamelHttpQuery",
7642 serde_json::Value::String("a=1&b=2".to_string()),
7643 );
7644
7645 let override_url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7646 assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
7647 }
7648
7649 fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
7650 let mut exchange = Exchange::new(Message::default());
7651 exchange
7652 .input
7653 .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
7654 exchange.input.set_header(
7655 "CamelHttpQuery",
7656 serde_json::Value::String(query.to_string()),
7657 );
7658 exchange
7659 }
7660
7661 #[test]
7662 fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
7663 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
7664 cfg.bridge_endpoint = true;
7665 cfg.query_params
7666 .push(("token".to_string(), "secret".to_string()));
7667 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
7668 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7669 assert_eq!(url, "http://x?token=secret");
7673 assert!(!url.contains("/foo"));
7674 assert!(!url.contains("dropme"));
7675 }
7676
7677 #[test]
7678 fn resolve_url_bridge_endpoint_false_merges_path() {
7679 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
7680 cfg.bridge_endpoint = false;
7681 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
7682 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7683 assert!(url.contains("/foo"), "url should contain /foo: {url}");
7684 assert!(
7685 url.contains("dropme=1"),
7686 "url should contain dropme=1: {url}"
7687 );
7688 }
7689
7690 #[test]
7691 fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
7692 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
7693 cfg.bridge_endpoint = true;
7694 let mut exchange = Exchange::new(Message::default());
7695 exchange.input.set_header(
7696 "CamelHttpPath",
7697 serde_json::Value::String("/foo".to_string()),
7698 );
7699 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7700 assert_eq!(url, "http://x");
7701 assert!(!url.contains("/foo"));
7702 }
7703
7704 #[test]
7705 fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
7706 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
7707 cfg.bridge_endpoint = true;
7708 let mut exchange = Exchange::new(Message::default());
7710 exchange.input.set_header(
7711 "CamelHttpUri",
7712 serde_json::Value::String("http://dest/explicit".to_string()),
7713 );
7714 exchange.input.set_header(
7715 "CamelHttpPath",
7716 serde_json::Value::String("/foo".to_string()),
7717 );
7718 exchange.input.set_header(
7719 "CamelHttpQuery",
7720 serde_json::Value::String("x=1".to_string()),
7721 );
7722 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7723 assert_eq!(url, "http://x");
7727 }
7728
7729 #[test]
7730 fn bridge_programmatic_params_use_percent20() {
7731 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
7732 cfg.bridge_endpoint = true;
7733 cfg.query_params = vec![("b".to_string(), "x y".to_string())];
7734 let exchange = Exchange::new(Message::default());
7735
7736 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7737
7738 assert_eq!(url, "http://x?b=x%20y");
7742 assert!(!url.contains('+'));
7743 }
7744
7745 #[test]
7746 fn bridge_arm_carries_authored_raw_query() {
7747 let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
7748 let exchange = exchange_with_path_and_query("/ignored", "dropme=1");
7751
7752 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7753
7754 assert_eq!(url, "http://h/p?a=1");
7757 assert!(!url.contains("dropme"), "exchange query leaked: {url}");
7758 assert!(!url.contains("/ignored"), "exchange path leaked: {url}");
7759 }
7760
7761 #[test]
7768 fn resolve_url_bridge_preserves_dot_segments() {
7769 let mut cfg = HttpEndpointConfig::from_uri("http://h/a/../b").unwrap();
7770 cfg.bridge_endpoint = true;
7771 cfg.query_params.push(("k".to_string(), "1".to_string()));
7772 let exchange = Exchange::new(Message::default());
7773
7774 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7775
7776 assert_eq!(url, "http://h/a/../b?k=1");
7779 }
7780
7781 #[test]
7782 fn resolve_url_bridge_preserves_default_port() {
7783 let mut cfg = HttpEndpointConfig::from_uri("http://h:80/p").unwrap();
7784 cfg.bridge_endpoint = true;
7785 cfg.query_params.push(("k".to_string(), "1".to_string()));
7786 let exchange = Exchange::new(Message::default());
7787
7788 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7789
7790 assert_eq!(url, "http://h:80/p?k=1");
7793 }
7794
7795 #[test]
7796 fn resolve_url_bridge_preserves_scheme_and_host_case() {
7797 let mut cfg = HttpEndpointConfig::from_uri("http://ExAMPLE.COM/p").unwrap();
7798 cfg.bridge_endpoint = true;
7799 cfg.query_params.push(("k".to_string(), "1".to_string()));
7800 cfg.base_url = "HTTP://ExAMPLE.COM/p".to_string();
7804 let exchange = Exchange::new(Message::default());
7805
7806 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7807
7808 assert_eq!(url, "HTTP://ExAMPLE.COM/p?k=1");
7811 }
7812
7813 #[test]
7814 fn resolve_url_bridge_no_query_emits_base_verbatim() {
7815 let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
7816 cfg.bridge_endpoint = true;
7817 let exchange = Exchange::new(Message::default());
7818
7819 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7820
7821 assert_eq!(url, "http://h/p");
7824 }
7825
7826 #[test]
7827 fn resolve_url_bridge_and_non_bridge_byte_identical() {
7828 let mut bridged = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
7831 bridged.bridge_endpoint = true;
7832 bridged
7833 .query_params
7834 .push(("k".to_string(), "1".to_string()));
7835 let bridge_url =
7836 HttpProducer::resolve_url(&Exchange::new(Message::default()), &bridged).unwrap();
7837
7838 let plain = HttpEndpointConfig::from_uri("http://H:80/a/../b").unwrap();
7841 let mut exchange = Exchange::new(Message::default());
7842 exchange.input.set_header(
7843 "CamelHttpQuery",
7844 serde_json::Value::String("k=1".to_string()),
7845 );
7846 let plain_url = HttpProducer::resolve_url(&exchange, &plain).unwrap();
7847
7848 assert_eq!(bridge_url, plain_url);
7849 assert_eq!(bridge_url, "http://H:80/a/../b?k=1");
7850 }
7851
7852 #[test]
7853 fn resolve_url_bridge_preserves_ipv6_authority_verbatim() {
7854 let mut cfg = HttpEndpointConfig::from_uri("http://[::1]:8080/p").unwrap();
7855 cfg.bridge_endpoint = true;
7856 cfg.query_params.push(("k".to_string(), "1".to_string()));
7857 let exchange = Exchange::new(Message::default());
7858
7859 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7860
7861 assert_eq!(url, "http://[::1]:8080/p?k=1");
7862 }
7863
7864 #[test]
7865 fn resolve_url_bridge_empty_base_path_keeps_no_synthetic_slash() {
7866 let cfg = HttpEndpointConfig::from_uri("http://h?x=1&bridgeEndpoint=true").unwrap();
7867 let exchange = Exchange::new(Message::default());
7868
7869 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
7870
7871 assert_eq!(url, "http://h?x=1");
7874 }
7875
7876 #[test]
7881 fn resolve_url_preserves_authored_query_order_and_bytes() {
7882 let config =
7883 HttpEndpointConfig::from_uri("http://h/p?a=1&b=x,y&c=t:1&connectTimeout=5000").unwrap();
7884 let exchange = Exchange::new(Message::default());
7885
7886 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
7887
7888 assert_eq!(url, "http://h/p?a=1&b=x,y&c=t:1");
7891 }
7892
7893 #[test]
7894 fn resolve_url_consumes_encoded_option_key() {
7895 let config = HttpEndpointConfig::from_uri("http://h/p?connect%54imeout=5000&a=1").unwrap();
7896 let exchange = Exchange::new(Message::default());
7897
7898 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
7899
7900 assert_eq!(url, "http://h/p?a=1");
7902 }
7903
7904 #[test]
7905 fn resolve_url_all_options_consumed_drops_query() {
7906 let config = HttpEndpointConfig::from_uri("http://h/p?connectTimeout=5000").unwrap();
7907 let exchange = Exchange::new(Message::default());
7908
7909 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
7910
7911 assert_eq!(url, "http://h/p");
7914 assert!(!url.contains('?'));
7915 }
7916
7917 #[test]
7918 fn resolve_url_preserves_empty_query_marker() {
7919 let config = HttpEndpointConfig::from_uri("http://h/p?").unwrap();
7920 let exchange = Exchange::new(Message::default());
7921
7922 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
7923
7924 assert_eq!(url, "http://h/p?");
7927 }
7928
7929 #[test]
7930 fn resolve_url_raw_wrapper_not_re_encoded() {
7931 let config = HttpEndpointConfig::from_uri("http://h/p?token=RAW(abc)").unwrap();
7932 let exchange = Exchange::new(Message::default());
7933
7934 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
7935
7936 assert_eq!(url, "http://h/p?token=RAW(abc)");
7938 assert!(!url.contains("%28"), "RAW( wrapper re-encoded: {url}");
7939 }
7940
7941 #[test]
7942 fn resolve_url_camel_http_query_composes_verbatim_span() {
7943 let config = HttpEndpointConfig::from_uri("http://h/p?x=1").unwrap();
7944 let mut exchange = Exchange::new(Message::default());
7945 exchange.input.set_header(
7946 "CamelHttpQuery",
7947 serde_json::Value::String("userFilter=a%2Cb".to_string()),
7948 );
7949
7950 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
7951
7952 assert_eq!(url, "http://h/p?x=1&userFilter=a%2Cb");
7957 assert!(!url.contains("%252C"), "header bytes re-encoded: {url}");
7958 }
7959
7960 #[test]
7965 fn header_composes_with_endpoint_query() {
7966 let config =
7967 HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret&lang=en").unwrap();
7968 let mut exchange = Exchange::new(Message::default());
7969 exchange.input.set_header(
7970 "CamelHttpQuery",
7971 serde_json::Value::String("lang=es&page=2".to_string()),
7972 );
7973
7974 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
7975
7976 assert_eq!(url, "http://upstream/api?apiKey=secret&lang=en&page=2");
7979 }
7980
7981 #[test]
7982 fn header_alone_still_rides() {
7983 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
7984 let mut exchange = Exchange::new(Message::default());
7985 exchange.input.set_header(
7986 "CamelHttpQuery",
7987 serde_json::Value::String("page=2".to_string()),
7988 );
7989
7990 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
7991
7992 assert_eq!(url, "http://upstream/api?page=2");
7994 }
7995
7996 #[test]
7997 fn empty_reflected_query_leaves_endpoint_query_intact() {
7998 let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
7999 let mut exchange = Exchange::new(Message::default());
8000 exchange
8003 .input
8004 .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
8005
8006 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8007
8008 assert_eq!(url, "http://upstream/api?apiKey=secret");
8010 assert!(!url.ends_with('?'), "dangling '?' marker: {url}");
8011 }
8012
8013 #[test]
8014 fn forbidden_byte_in_header_query_errors() {
8015 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
8016 let mut exchange = Exchange::new(Message::default());
8017 exchange.input.set_header(
8018 "CamelHttpQuery",
8019 serde_json::Value::String("q=ab<cd".to_string()),
8020 );
8021
8022 let err = HttpProducer::resolve_url(&exchange, &config)
8023 .unwrap_err()
8024 .to_string();
8025
8026 assert!(err.contains("0x3C"), "error must name the byte: {err}");
8029 }
8030
8031 #[test]
8032 fn override_uri_with_query_plus_header_query() {
8033 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
8034 let mut exchange = Exchange::new(Message::default());
8035 exchange.input.set_header(
8036 "CamelHttpUri",
8037 serde_json::Value::String("http://host/api?a=1".to_string()),
8038 );
8039 exchange.input.set_header(
8040 "CamelHttpQuery",
8041 serde_json::Value::String("a=2&b=3".to_string()),
8042 );
8043
8044 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8045
8046 assert_eq!(url, "http://host/api?a=1&b=3");
8049 }
8050
8051 #[test]
8052 fn path_applies_before_query_composition() {
8053 let config = HttpEndpointConfig::from_uri("http://upstream/api").unwrap();
8054 let mut exchange = Exchange::new(Message::default());
8055 exchange.input.set_header(
8056 "CamelHttpUri",
8057 serde_json::Value::String("http://host/api?a=1".to_string()),
8058 );
8059 exchange.input.set_header(
8060 "CamelHttpPath",
8061 serde_json::Value::String("/extra".to_string()),
8062 );
8063 exchange.input.set_header(
8064 "CamelHttpQuery",
8065 serde_json::Value::String("b=2".to_string()),
8066 );
8067
8068 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8069
8070 assert_eq!(url, "http://host/api/extra?a=1&b=2");
8073 }
8074
8075 #[test]
8076 fn plain_proxy_reflection_composes() {
8077 let config = HttpEndpointConfig::from_uri("http://upstream/api?apiKey=secret").unwrap();
8078 let exchange = exchange_with_path_and_query("/in/extra", "page=2");
8080
8081 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8082
8083 assert_eq!(url, "http://upstream/api/in/extra?apiKey=secret&page=2");
8086 }
8087
8088 #[test]
8089 fn bridge_endpoint_ignores_url_headers() {
8090 let cfg = HttpEndpointConfig::from_uri("http://h/p?a=1&bridgeEndpoint=true").unwrap();
8091 let mut exchange = Exchange::new(Message::default());
8092 exchange.input.set_header(
8093 "CamelHttpUri",
8094 serde_json::Value::String("http://evil.test/x".to_string()),
8095 );
8096 exchange.input.set_header(
8097 "CamelHttpPath",
8098 serde_json::Value::String("/foo".to_string()),
8099 );
8100 exchange.input.set_header(
8101 "CamelHttpQuery",
8102 serde_json::Value::String("z=9".to_string()),
8103 );
8104
8105 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8106
8107 assert_eq!(url, "http://h/p?a=1");
8110 assert!(!url.contains("evil"), "override leaked: {url}");
8111 assert!(!url.contains("z=9"), "header query leaked: {url}");
8112 assert!(!url.contains("/foo"), "header path leaked: {url}");
8113 }
8114
8115 #[test]
8116 fn resolve_url_programmatic_params_use_percent20_deterministic() {
8117 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8118 config.query_params = vec![
8119 ("b".to_string(), "x y".to_string()),
8120 ("a".to_string(), "1".to_string()),
8121 ];
8122 let exchange = Exchange::new(Message::default());
8123
8124 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8125
8126 assert_eq!(url, "http://h/p?b=x%20y&a=1");
8129 assert!(!url.contains('+'));
8130 }
8131
8132 #[test]
8133 fn resolve_url_authored_and_programmatic_merge() {
8134 let mut config = HttpEndpointConfig::from_uri("http://h/p?a=1&c=t:1").unwrap();
8135 config.query_params = vec![
8136 ("b".to_string(), "2".to_string()),
8137 ("a".to_string(), "9".to_string()),
8138 ];
8139 let exchange = Exchange::new(Message::default());
8140
8141 let url = HttpProducer::resolve_url(&exchange, &config).unwrap();
8142
8143 assert_eq!(url, "http://h/p?a=1&c=t:1&b=2");
8146 }
8147
8148 #[test]
8149 fn from_uri_no_longer_fills_query_params_from_uri() {
8150 let config = HttpEndpointConfig::from_uri("http://h/p?a=1&connectTimeout=5000").unwrap();
8151
8152 assert!(
8154 config.query_params.is_empty(),
8155 "query_params is programmatic-only: {:?}",
8156 config.query_params
8157 );
8158 assert_eq!(config.raw_query.as_deref(), Some("a=1&connectTimeout=5000"));
8159 }
8160
8161 #[test]
8162 fn resolve_url_forbidden_raw_byte_errors() {
8163 let mut config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8164 config.raw_query = Some("a=x y".to_string());
8165 let exchange = Exchange::new(Message::default());
8166
8167 let err = HttpProducer::resolve_url(&exchange, &config)
8168 .expect_err("literal space in raw query must error");
8169
8170 assert!(
8172 err.to_string().contains("0x20"),
8173 "error must name the forbidden byte: {err}"
8174 );
8175 }
8176
8177 #[test]
8178 fn armed_fence_rejects_unknown_host_redacted() {
8179 let cfg = HttpEndpointConfig::from_uri(
8180 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
8181 )
8182 .unwrap();
8183 let mut exchange = Exchange::new(Message::default());
8184 exchange.input.set_header(
8185 "CamelHttpUri",
8186 serde_json::Value::String(
8187 "http://user:pass@evil.example.com/x?token=s3cret".to_string(),
8188 ),
8189 );
8190
8191 let err = HttpProducer::resolve_url(&exchange, &cfg)
8192 .expect_err("override host outside the fence must fail resolution");
8193
8194 let message = err.to_string();
8195 assert!(!message.contains("pass"), "userinfo leaked: {message}");
8196 assert!(!message.contains("s3cret"), "query leaked: {message}");
8197 }
8198
8199 #[test]
8200 fn armed_fence_allows_listed_host() {
8201 let cfg = HttpEndpointConfig::from_uri(
8202 "http://x?allowedUriHosts=api.internal:8443,cdn.example.com",
8203 )
8204 .unwrap();
8205 let mut exchange = Exchange::new(Message::default());
8206 exchange.input.set_header(
8207 "CamelHttpUri",
8208 serde_json::Value::String("http://cdn.example.com/x".to_string()),
8209 );
8210
8211 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8212 assert_eq!(url, "http://cdn.example.com/x");
8213 }
8214
8215 #[test]
8216 fn host_only_entry_permits_any_port() {
8217 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=cdn.example.com").unwrap();
8218 let mut exchange = Exchange::new(Message::default());
8219 exchange.input.set_header(
8220 "CamelHttpUri",
8221 serde_json::Value::String("http://cdn.example.com:9443/x".to_string()),
8222 );
8223
8224 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8225 assert_eq!(url, "http://cdn.example.com:9443/x");
8226 }
8227
8228 #[test]
8229 fn unarmed_endpoint_unchanged() {
8230 let cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
8231 let mut exchange = Exchange::new(Message::default());
8232 exchange.input.set_header(
8233 "CamelHttpUri",
8234 serde_json::Value::String("http://any.example.com/path".to_string()),
8235 );
8236
8237 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8238 assert_eq!(url, "http://any.example.com/path");
8239 }
8240
8241 #[test]
8242 fn empty_allowlist_fails_endpoint_creation() {
8243 assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=,,").is_err());
8244 }
8245
8246 #[test]
8247 fn malformed_entry_fails_endpoint_creation() {
8248 assert!(HttpEndpointConfig::from_uri("http://x?allowedUriHosts=not a host!").is_err());
8249 }
8250
8251 #[test]
8252 fn fence_entry_with_path_fails_creation() {
8253 assert!(
8256 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=api.internal:8443/v2").is_err()
8257 );
8258 }
8259
8260 #[test]
8261 fn fence_entry_with_userinfo_fails_creation() {
8262 assert!(
8263 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=user@cdn.example.com").is_err()
8264 );
8265 }
8266
8267 #[test]
8268 fn ipv6_fence_entry_allows_bracketed_host() {
8269 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=[::1]:8443").unwrap();
8270 for uri in ["http://[::1]:8443/x", "http://[0:0:0:0:0:0:0:1]:8443/x"] {
8273 let mut exchange = Exchange::new(Message::default());
8274 exchange
8275 .input
8276 .set_header("CamelHttpUri", serde_json::Value::String(uri.to_string()));
8277 let url = HttpProducer::resolve_url(&exchange, &cfg)
8278 .unwrap_or_else(|e| panic!("override {uri} must be honored: {e}"));
8279 assert_eq!(url, uri, "bracketed IPv6 override not honored");
8280 }
8281 }
8282
8283 #[test]
8284 fn dns_case_insensitive_fence_match() {
8285 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=CDN.Example.COM").unwrap();
8288 let mut exchange = Exchange::new(Message::default());
8289 exchange.input.set_header(
8290 "CamelHttpUri",
8291 serde_json::Value::String("http://cdn.example.com/x".to_string()),
8292 );
8293 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8294 assert_eq!(url, "http://cdn.example.com/x");
8295 }
8296
8297 #[test]
8298 fn fence_allowed_override_query_merges_with_header() {
8299 let cfg =
8302 HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example&k=v").unwrap();
8303 let mut exchange = Exchange::new(Message::default());
8304 exchange.input.set_header(
8305 "CamelHttpUri",
8306 serde_json::Value::String("http://host.example/api?a=1".to_string()),
8307 );
8308 exchange.input.set_header(
8309 "CamelHttpQuery",
8310 serde_json::Value::String("b=2".to_string()),
8311 );
8312
8313 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8314 assert_eq!(url, "http://host.example/api?a=1&b=2");
8315 }
8316
8317 #[test]
8318 fn empty_header_with_armed_fence_leaves_no_query() {
8319 let cfg = HttpEndpointConfig::from_uri("http://x?allowedUriHosts=host.example").unwrap();
8320 let mut exchange = Exchange::new(Message::default());
8321 exchange.input.set_header(
8322 "CamelHttpUri",
8323 serde_json::Value::String("http://host.example/api".to_string()),
8324 );
8325 exchange
8326 .input
8327 .set_header("CamelHttpQuery", serde_json::Value::String(String::new()));
8328
8329 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8330 assert_eq!(url, "http://host.example/api");
8331 assert!(!url.contains('?'), "query marker leaked: {url}");
8332 }
8333
8334 #[test]
8335 fn fence_option_is_consumed() {
8336 let cfg =
8340 HttpEndpointConfig::from_uri("http://h/p?x=1&allowedUriHosts=cdn.example.com").unwrap();
8341 let exchange = Exchange::new(Message::default());
8342
8343 let url = HttpProducer::resolve_url(&exchange, &cfg).unwrap();
8344 assert!(!url.contains("allowedUriHosts"), "option leaked: {url}");
8345 assert!(url.contains("x=1"), "authored query lost: {url}");
8346 }
8347
8348 #[tokio::test]
8349 async fn resolve_url_malformed_base_url_errors_no_panic() {
8350 use tower::ServiceExt;
8351
8352 let (url, _handle) = start_test_server().await;
8353 let mut config = HttpEndpointConfig::from_uri("http://[::1:bad").unwrap();
8354 config.allow_internal = true; let producer = HttpProducer {
8356 config: Arc::new(config),
8357 client: build_client(&HttpConfig::default(), None),
8358 pinned_cache: Arc::new(PinnedClientCache::new(
8359 PINNED_CLIENT_TTL,
8360 PINNED_CLIENT_MAX_ENTRIES,
8361 )),
8362 http_config: Arc::new(HttpConfig::default()),
8363 runtime: rt(),
8364 };
8365
8366 let first = producer
8369 .clone()
8370 .oneshot(Exchange::new(Message::default()))
8371 .await;
8372 let err = first.expect_err("malformed base URL must error, not panic");
8373 assert!(
8374 err.to_string().to_lowercase().contains("url"),
8375 "error must name the malformed URL: {err}"
8376 );
8377
8378 let mut exchange = Exchange::new(Message::default());
8381 exchange.input.set_header(
8382 "CamelHttpUri",
8383 serde_json::Value::String(format!("{url}/api")),
8384 );
8385 let response = producer
8386 .oneshot(exchange)
8387 .await
8388 .expect("valid request through same producer must succeed");
8389 let status = response
8390 .input
8391 .header("CamelHttpResponseCode")
8392 .and_then(|v| v.as_u64())
8393 .unwrap();
8394 assert_eq!(status, 200);
8395 }
8396
8397 #[test]
8398 fn resolve_url_bridge_malformed_base_errors_no_panic() {
8399 let mut cfg = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8400 cfg.bridge_endpoint = true;
8401 cfg.query_params.push(("k".to_string(), "1".to_string()));
8402 cfg.base_url = "http://[::1:bad".to_string();
8407 let exchange = Exchange::new(Message::default());
8408
8409 let err = HttpProducer::resolve_url(&exchange, &cfg)
8410 .expect_err("malformed bridge base URL must error");
8411 assert!(
8412 err.to_string().contains("invalid base URL"),
8413 "error must name the invalid base URL: {err}"
8414 );
8415 }
8416
8417 #[test]
8418 fn test_http_producer_helpers_status_and_size_boundaries() {
8419 assert!(HttpProducer::is_ok_status(200, (200, 299)));
8420 assert!(HttpProducer::is_ok_status(299, (200, 299)));
8421 assert!(!HttpProducer::is_ok_status(199, (200, 299)));
8422 assert!(!HttpProducer::is_ok_status(300, (200, 299)));
8423
8424 assert!(!exceeds_max_response_body(10, 10));
8425 assert!(exceeds_max_response_body(11, 10));
8426 }
8427
8428 async fn setup_consumer_on_free_port(
8433 path: &str,
8434 ) -> (
8435 u16,
8436 tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
8437 tokio_util::sync::CancellationToken,
8438 ) {
8439 use camel_component_api::ConsumerContext;
8440
8441 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8442 let port = listener.local_addr().unwrap().port();
8443 drop(listener);
8444
8445 let consumer_cfg = HttpServerConfig {
8446 scheme: "http".to_string(),
8447 host: "127.0.0.1".to_string(),
8448 port,
8449 path: path.to_string(),
8450 max_request_body: 2 * 1024 * 1024,
8451 max_response_body: 10 * 1024 * 1024,
8452 max_inflight_requests: 1024,
8453 method: None,
8454 tls_config: None,
8455 };
8456 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
8457
8458 let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
8459 let token = tokio_util::sync::CancellationToken::new();
8460 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8461
8462 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8463 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8464
8465 (port, rx, token)
8466 }
8467
8468 #[tokio::test]
8469 async fn test_content_type_inferred_for_json_body() {
8470 let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
8471
8472 let client = reqwest::Client::new();
8473 let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
8474
8475 let (http_result, _) = tokio::join!(send_fut, async {
8476 if let Some(mut envelope) = rx.recv().await {
8477 envelope.exchange.input.body =
8478 camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
8479 if let Some(reply_tx) = envelope.reply_tx {
8480 let _ = reply_tx.send(Ok(envelope.exchange));
8481 }
8482 }
8483 });
8484
8485 let resp = http_result.unwrap();
8486 assert_eq!(resp.status().as_u16(), 200);
8487 let ct = resp
8488 .headers()
8489 .get("content-type")
8490 .expect("Content-Type header should be present");
8491 assert_eq!(ct, "application/json");
8492 let body = resp.text().await.unwrap();
8493 assert_eq!(body, r#"{"message":"hello"}"#);
8494
8495 token.cancel();
8496 }
8497
8498 #[tokio::test]
8499 async fn test_content_type_inferred_for_text_body() {
8500 let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
8501
8502 let client = reqwest::Client::new();
8503 let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
8504
8505 let (http_result, _) = tokio::join!(send_fut, async {
8506 if let Some(mut envelope) = rx.recv().await {
8507 envelope.exchange.input.body =
8508 camel_component_api::Body::Text("plain text response".to_string());
8509 if let Some(reply_tx) = envelope.reply_tx {
8510 let _ = reply_tx.send(Ok(envelope.exchange));
8511 }
8512 }
8513 });
8514
8515 let resp = http_result.unwrap();
8516 assert_eq!(resp.status().as_u16(), 200);
8517 let ct = resp
8518 .headers()
8519 .get("content-type")
8520 .expect("Content-Type header should be present");
8521 assert_eq!(ct, "text/plain; charset=utf-8");
8522 let body = resp.text().await.unwrap();
8523 assert_eq!(body, "plain text response");
8524
8525 token.cancel();
8526 }
8527
8528 #[tokio::test]
8529 async fn test_content_type_inferred_for_xml_body() {
8530 let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
8531
8532 let client = reqwest::Client::new();
8533 let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
8534
8535 let (http_result, _) = tokio::join!(send_fut, async {
8536 if let Some(mut envelope) = rx.recv().await {
8537 envelope.exchange.input.body =
8538 camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
8539 if let Some(reply_tx) = envelope.reply_tx {
8540 let _ = reply_tx.send(Ok(envelope.exchange));
8541 }
8542 }
8543 });
8544
8545 let resp = http_result.unwrap();
8546 assert_eq!(resp.status().as_u16(), 200);
8547 let ct = resp
8548 .headers()
8549 .get("content-type")
8550 .expect("Content-Type header should be present");
8551 assert_eq!(ct, "application/xml");
8552 let body = resp.text().await.unwrap();
8553 assert_eq!(body, "<root><item>value</item></root>");
8554
8555 token.cancel();
8556 }
8557
8558 #[tokio::test]
8559 async fn test_no_content_type_for_empty_body() {
8560 let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
8561
8562 let client = reqwest::Client::new();
8563 let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
8564
8565 let (http_result, _) = tokio::join!(send_fut, async {
8566 if let Some(mut envelope) = rx.recv().await {
8567 envelope.exchange.input.body = camel_component_api::Body::Empty;
8568 if let Some(reply_tx) = envelope.reply_tx {
8569 let _ = reply_tx.send(Ok(envelope.exchange));
8570 }
8571 }
8572 });
8573
8574 let resp = http_result.unwrap();
8575 assert_eq!(resp.status().as_u16(), 200);
8576 assert!(
8577 resp.headers().get("content-type").is_none(),
8578 "Empty body should not set Content-Type"
8579 );
8580
8581 token.cancel();
8582 }
8583
8584 #[tokio::test]
8585 async fn test_no_content_type_for_raw_bytes_body() {
8586 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
8587
8588 let client = reqwest::Client::new();
8589 let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
8590
8591 let (http_result, _) = tokio::join!(send_fut, async {
8592 if let Some(mut envelope) = rx.recv().await {
8593 envelope.exchange.input.body =
8594 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
8595 if let Some(reply_tx) = envelope.reply_tx {
8596 let _ = reply_tx.send(Ok(envelope.exchange));
8597 }
8598 }
8599 });
8600
8601 let resp = http_result.unwrap();
8602 assert_eq!(resp.status().as_u16(), 200);
8603 assert!(
8604 resp.headers().get("content-type").is_none(),
8605 "Raw Bytes body should not set Content-Type"
8606 );
8607
8608 token.cancel();
8609 }
8610
8611 #[tokio::test]
8612 async fn test_content_type_from_stream_metadata() {
8613 use camel_component_api::{StreamBody, StreamMetadata};
8614 use futures::stream;
8615
8616 let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
8617
8618 let client = reqwest::Client::new();
8619 let send_fut = client
8620 .get(format!("http://127.0.0.1:{port}/stream-ct"))
8621 .send();
8622
8623 let (http_result, _) = tokio::join!(send_fut, async {
8624 if let Some(mut envelope) = rx.recv().await {
8625 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
8626 vec![Ok(bytes::Bytes::from("audio data"))];
8627 let stream = Box::pin(stream::iter(chunks));
8628 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
8629 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
8630 metadata: StreamMetadata {
8631 size_hint: None,
8632 content_type: Some("audio/mpeg".to_string()),
8633 origin: None,
8634 },
8635 });
8636 if let Some(reply_tx) = envelope.reply_tx {
8637 let _ = reply_tx.send(Ok(envelope.exchange));
8638 }
8639 }
8640 });
8641
8642 let resp = http_result.unwrap();
8643 assert_eq!(resp.status().as_u16(), 200);
8644 let ct = resp
8645 .headers()
8646 .get("content-type")
8647 .expect("Content-Type header should be present");
8648 assert_eq!(ct, "audio/mpeg");
8649 let body = resp.text().await.unwrap();
8650 assert_eq!(body, "audio data");
8651
8652 token.cancel();
8653 }
8654
8655 #[tokio::test]
8656 async fn test_user_content_type_overrides_inferred() {
8657 let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
8658
8659 let client = reqwest::Client::new();
8660 let send_fut = client
8661 .get(format!("http://127.0.0.1:{port}/override-ct"))
8662 .send();
8663
8664 let (http_result, _) = tokio::join!(send_fut, async {
8665 if let Some(mut envelope) = rx.recv().await {
8666 envelope.exchange.input.body =
8667 camel_component_api::Body::Json(serde_json::json!({"ok": true}));
8668 envelope.exchange.input.set_header(
8669 "Content-Type",
8670 serde_json::Value::String("text/html".to_string()),
8671 );
8672 if let Some(reply_tx) = envelope.reply_tx {
8673 let _ = reply_tx.send(Ok(envelope.exchange));
8674 }
8675 }
8676 });
8677
8678 let resp = http_result.unwrap();
8679 assert_eq!(resp.status().as_u16(), 200);
8680 let ct = resp
8681 .headers()
8682 .get("content-type")
8683 .expect("Content-Type header should be present");
8684 assert_eq!(
8685 ct, "text/html",
8686 "User-set Content-Type should take precedence over inferred type"
8687 );
8688
8689 token.cancel();
8690 }
8691
8692 #[tokio::test]
8693 async fn test_user_content_type_with_bytes_body() {
8694 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
8695
8696 let client = reqwest::Client::new();
8697 let send_fut = client
8698 .get(format!("http://127.0.0.1:{port}/bytes-ct"))
8699 .send();
8700
8701 let (http_result, _) = tokio::join!(send_fut, async {
8702 if let Some(mut envelope) = rx.recv().await {
8703 envelope.exchange.input.body =
8704 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
8705 envelope.exchange.input.set_header(
8706 "Content-Type",
8707 serde_json::Value::String("application/json".to_string()),
8708 );
8709 if let Some(reply_tx) = envelope.reply_tx {
8710 let _ = reply_tx.send(Ok(envelope.exchange));
8711 }
8712 }
8713 });
8714
8715 let resp = http_result.unwrap();
8716 assert_eq!(resp.status().as_u16(), 200);
8717 let ct = resp
8718 .headers()
8719 .get("content-type")
8720 .expect("Content-Type header should be present for Bytes body with user header");
8721 assert_eq!(
8722 ct, "application/json",
8723 "User Content-Type should be sent for Bytes body"
8724 );
8725
8726 token.cancel();
8727 }
8728
8729 #[tokio::test]
8734 async fn monitor_task_silent_on_clean_exit() {
8735 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
8736 monitor_axum_task(
8738 handle,
8739 "127.0.0.1:0".to_string(),
8740 noop_rt(),
8741 "test-monitor".into(),
8742 )
8743 .await;
8744 }
8745
8746 #[tokio::test]
8747 async fn monitor_task_handles_panicked_task() {
8748 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
8749 panic!("simulated server crash");
8750 });
8751 monitor_axum_task(
8753 handle,
8754 "127.0.0.1:9999".to_string(),
8755 noop_rt(),
8756 "test-monitor".into(),
8757 )
8758 .await;
8759 }
8760
8761 #[test]
8766 fn http_auth_basic_debug_redacts_password() {
8767 let auth = HttpAuth::Basic {
8768 username: "admin".to_string(),
8769 password: "hunter2".to_string(),
8770 };
8771 let debug = format!("{:?}", auth);
8772 assert!(
8773 !debug.contains("hunter2"),
8774 "password must be redacted: {debug}"
8775 );
8776 assert!(debug.contains("admin"), "username should appear: {debug}");
8777 }
8778
8779 #[test]
8780 fn http_auth_bearer_debug_redacts_token() {
8781 let auth = HttpAuth::Bearer {
8782 token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
8783 };
8784 let debug = format!("{:?}", auth);
8785 assert!(
8786 !debug.contains("eyJhbGci"),
8787 "token must be redacted: {debug}"
8788 );
8789 }
8790
8791 #[test]
8792 fn http_auth_none_debug_shows_variant() {
8793 let debug = format!("{:?}", HttpAuth::None);
8794 assert!(
8795 debug.contains("None"),
8796 "None variant should appear: {debug}"
8797 );
8798 }
8799
8800 #[test]
8801 fn http_endpoint_config_debug_redacts_auth_credentials() {
8802 let config = HttpEndpointConfig::from_uri(
8803 "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
8804 )
8805 .unwrap();
8806 let debug = format!("{:?}", config);
8807 assert!(
8808 !debug.contains("secret123"),
8809 "password must be redacted in HttpEndpointConfig debug: {debug}"
8810 );
8811 }
8812
8813 #[test]
8814 fn debug_lists_all_public_fields() {
8815 let config = HttpEndpointConfig::from_uri("http://h/p").unwrap();
8816 let debug = format!("{:?}", config);
8817 for field in [
8818 "base_url",
8819 "http_method",
8820 "throw_exception_on_failure",
8821 "ok_status_code_range",
8822 "response_timeout",
8823 "query_params",
8824 "raw_query",
8825 "allow_internal",
8826 "blocked_hosts",
8827 "max_body_size",
8828 "read_timeout_ms",
8829 "max_response_bytes",
8830 "auth",
8831 "token_provider",
8832 "user_agent",
8833 "bridge_endpoint",
8834 "connection_close",
8835 "skip_request_headers",
8836 "skip_response_headers",
8837 "follow_redirects",
8838 "max_redirects",
8839 ] {
8840 assert!(
8841 debug.contains(field),
8842 "Debug output missing field '{field}': {debug}"
8843 );
8844 }
8845 }
8846
8847 use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
8852 use tower_http::services::ServeDir;
8853
8854 fn make_test_registry() -> HttpRouteRegistry {
8855 HttpRouteRegistry::new()
8856 }
8857
8858 fn make_test_state(registry: HttpRouteRegistry) -> AppState {
8859 AppState {
8860 registry,
8861 max_request_body: 2 * 1024 * 1024,
8862 max_response_body: 10 * 1024 * 1024,
8863 inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
8864 }
8865 }
8866
8867 #[allow(clippy::await_holding_lock)]
8868 #[tokio::test]
8869 async fn test_static_file_serving_serves_file_contents() {
8870 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
8871 ServerRegistry::reset();
8872
8873 let temp_dir =
8875 std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
8876 std::fs::create_dir_all(&temp_dir).unwrap();
8877 std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
8878 std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
8879
8880 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
8881
8882 let registry = make_test_registry();
8883 let serve_dir = ServeDir::new(&canonical_dir)
8884 .precompressed_gzip()
8885 .precompressed_br()
8886 .append_index_html_on_directories(true);
8887
8888 let mount = StaticMount {
8889 mount_path: "/".to_string(),
8890 mode: MountMode::Static,
8891 dir: canonical_dir.clone(),
8892 cache_control: "public, max-age=3600".to_string(),
8893 error_pages: std::collections::HashMap::new(),
8894 serve_dir,
8895 };
8896 registry.register_static_mount(mount).await.unwrap();
8897
8898 let state = make_test_state(registry);
8899
8900 let req = Request::builder()
8902 .uri("/hello.txt")
8903 .body(AxumBody::empty())
8904 .unwrap();
8905 let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
8906 assert_eq!(resp.status(), StatusCode::OK);
8907 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8908 .await
8909 .unwrap();
8910 assert_eq!(&body[..], b"Hello, static world!");
8911
8912 let req = Request::builder()
8914 .uri("/style.css")
8915 .body(AxumBody::empty())
8916 .unwrap();
8917 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
8918 assert_eq!(resp.status(), StatusCode::OK);
8919 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8920 .await
8921 .unwrap();
8922 assert_eq!(&body[..], b"body { color: red; }");
8923
8924 let req = Request::builder()
8926 .uri("/missing.txt")
8927 .body(AxumBody::empty())
8928 .unwrap();
8929 let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
8930 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
8931
8932 std::fs::remove_dir_all(&temp_dir).ok();
8934 }
8935
8936 #[allow(clippy::await_holding_lock)]
8937 #[tokio::test]
8938 async fn test_spa_fallback_serves_index_for_unknown_paths() {
8939 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
8940 ServerRegistry::reset();
8941
8942 let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
8943 std::fs::create_dir_all(&temp_dir).unwrap();
8944 std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
8945 std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
8946
8947 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
8948
8949 let registry = make_test_registry();
8950 let serve_dir = ServeDir::new(&canonical_dir)
8951 .precompressed_gzip()
8952 .precompressed_br()
8953 .append_index_html_on_directories(true);
8954
8955 let mount = StaticMount {
8956 mount_path: "/".to_string(),
8957 mode: MountMode::Spa,
8958 dir: canonical_dir.clone(),
8959 cache_control: "public, max-age=0".to_string(),
8960 error_pages: std::collections::HashMap::new(),
8961 serve_dir,
8962 };
8963 registry.register_static_mount(mount).await.unwrap();
8965
8966 let state = make_test_state(registry);
8967
8968 let req = Request::builder()
8970 .method("GET")
8971 .uri("/dashboard")
8972 .header("Accept", "text/html")
8973 .body(AxumBody::empty())
8974 .unwrap();
8975 let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
8976 assert_eq!(resp.status(), StatusCode::OK);
8977 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8978 .await
8979 .unwrap();
8980 assert_eq!(&body[..], b"<h1>SPA App</h1>");
8981
8982 let req = Request::builder()
8984 .method("GET")
8985 .uri("/app.js")
8986 .body(AxumBody::empty())
8987 .unwrap();
8988 let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
8989 assert_eq!(resp.status(), StatusCode::OK);
8990 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8991 .await
8992 .unwrap();
8993 assert_eq!(&body[..], b"console.log('app')");
8994
8995 let req = Request::builder()
8997 .method("GET")
8998 .uri("/api/data")
8999 .header("Accept", "application/json")
9000 .body(AxumBody::empty())
9001 .unwrap();
9002 let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
9003 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9004
9005 let req = Request::builder()
9007 .method("GET")
9008 .uri("/style.css")
9009 .header("Accept", "text/html")
9010 .body(AxumBody::empty())
9011 .unwrap();
9012 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
9013 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9014
9015 std::fs::remove_dir_all(&temp_dir).ok();
9017 }
9018
9019 #[allow(clippy::await_holding_lock)]
9026 async fn run_conditional_get_returns_304(mode: MountMode) {
9027 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
9028 ServerRegistry::reset();
9029
9030 let temp_dir = std::env::temp_dir().join(format!(
9031 "http_cond_get_{}_{}",
9032 if mode == MountMode::Spa {
9033 "spa"
9034 } else {
9035 "static"
9036 },
9037 std::process::id()
9038 ));
9039 std::fs::create_dir_all(&temp_dir).unwrap();
9040 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
9041
9042 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
9043
9044 let registry = make_test_registry();
9045 let serve_dir = ServeDir::new(&canonical_dir)
9046 .precompressed_gzip()
9047 .precompressed_br()
9048 .append_index_html_on_directories(true);
9049
9050 let mount = StaticMount {
9051 mount_path: "/".to_string(),
9052 mode,
9053 dir: canonical_dir.clone(),
9054 cache_control: "public, max-age=3600".to_string(),
9055 error_pages: std::collections::HashMap::new(),
9056 serve_dir,
9057 };
9058 registry.register_static_mount(mount).await.unwrap();
9059
9060 let state = make_test_state(registry);
9061
9062 let req = Request::builder()
9064 .method("GET")
9065 .uri("/index.html")
9066 .body(AxumBody::empty())
9067 .unwrap();
9068 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
9069 assert_eq!(
9070 resp.status(),
9071 StatusCode::OK,
9072 "first GET should return 200, got {}",
9073 resp.status()
9074 );
9075 assert!(
9077 resp.headers().contains_key(http::header::CACHE_CONTROL),
9078 "200 response missing Cache-Control"
9079 );
9080 let etag = resp
9081 .headers()
9082 .get(http::header::ETAG)
9083 .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
9084 .clone();
9085 let last_modified = resp
9086 .headers()
9087 .get(http::header::LAST_MODIFIED)
9088 .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
9089 .clone();
9090 let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
9092 .await
9093 .unwrap();
9094
9095 let req = Request::builder()
9099 .method("GET")
9100 .uri("/index.html")
9101 .header(http::header::IF_NONE_MATCH, etag.clone())
9102 .body(AxumBody::empty())
9103 .unwrap();
9104 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
9105 assert_eq!(
9106 resp.status(),
9107 StatusCode::NOT_MODIFIED,
9108 "If-None-Match with matching ETag should return 304, got {}",
9109 resp.status()
9110 );
9111 assert!(
9113 resp.headers().contains_key(http::header::CACHE_CONTROL),
9114 "304 (If-None-Match) missing Cache-Control"
9115 );
9116 assert_eq!(
9119 resp.headers().get(http::header::ETAG),
9120 Some(&etag),
9121 "304 (If-None-Match) must echo the ETag validator"
9122 );
9123 assert_eq!(
9124 resp.headers().get(http::header::LAST_MODIFIED),
9125 Some(&last_modified),
9126 "304 (If-None-Match) must carry Last-Modified"
9127 );
9128
9129 let req = Request::builder()
9131 .method("GET")
9132 .uri("/index.html")
9133 .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
9134 .body(AxumBody::empty())
9135 .unwrap();
9136 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
9137 assert_eq!(
9138 resp.status(),
9139 StatusCode::NOT_MODIFIED,
9140 "If-Modified-Since with matching timestamp should return 304, got {}",
9141 resp.status()
9142 );
9143 assert!(
9144 resp.headers().contains_key(http::header::CACHE_CONTROL),
9145 "304 (If-Modified-Since) missing Cache-Control"
9146 );
9147 assert_eq!(
9148 resp.headers().get(http::header::ETAG),
9149 Some(&etag),
9150 "304 (If-Modified-Since) must carry the ETag validator"
9151 );
9152 assert_eq!(
9153 resp.headers().get(http::header::LAST_MODIFIED),
9154 Some(&last_modified),
9155 "304 (If-Modified-Since) must echo Last-Modified"
9156 );
9157
9158 let req = Request::builder()
9164 .method("GET")
9165 .uri("/index.html")
9166 .header(
9167 http::header::IF_MODIFIED_SINCE,
9168 "Wed, 21 Oct 2000 07:28:00 GMT",
9169 )
9170 .body(AxumBody::empty())
9171 .unwrap();
9172 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
9173 assert_eq!(
9174 resp.status(),
9175 StatusCode::OK,
9176 "past If-Modified-Since should return 200 (file modified after it), got {}",
9177 resp.status()
9178 );
9179
9180 std::fs::remove_dir_all(&temp_dir).ok();
9182 }
9183
9184 #[tokio::test]
9185 async fn test_conditional_get_returns_304_static_mode() {
9186 run_conditional_get_returns_304(MountMode::Static).await;
9187 }
9188
9189 #[tokio::test]
9190 async fn test_conditional_get_returns_304_spa_mode() {
9191 run_conditional_get_returns_304(MountMode::Spa).await;
9192 }
9193
9194 #[allow(clippy::await_holding_lock)]
9195 #[tokio::test]
9196 async fn test_error_page_mapping_serves_custom_404() {
9197 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
9198 ServerRegistry::reset();
9199
9200 let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
9201 let errors_dir = temp_dir.join("errors");
9202 std::fs::create_dir_all(&errors_dir).unwrap();
9203 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
9204 std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
9205
9206 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
9207 let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
9208
9209 let registry = make_test_registry();
9210 let serve_dir = ServeDir::new(&canonical_dir)
9211 .precompressed_gzip()
9212 .precompressed_br()
9213 .append_index_html_on_directories(true);
9214
9215 let mut error_pages = std::collections::HashMap::new();
9216 error_pages.insert(404, canonical_404);
9217
9218 let mount = StaticMount {
9219 mount_path: "/".to_string(),
9220 mode: MountMode::Static,
9221 dir: canonical_dir.clone(),
9222 cache_control: "public, max-age=0".to_string(),
9223 error_pages,
9224 serve_dir,
9225 };
9226 registry.register_static_mount(mount).await.unwrap();
9227
9228 let state = make_test_state(registry);
9229
9230 let req = Request::builder()
9232 .method("GET")
9233 .uri("/missing.html")
9234 .body(AxumBody::empty())
9235 .unwrap();
9236 let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
9237 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
9238 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9239 .await
9240 .unwrap();
9241 assert_eq!(&body[..], b"<h1>Custom 404</h1>");
9242
9243 let req = Request::builder()
9245 .method("GET")
9246 .uri("/index.html")
9247 .body(AxumBody::empty())
9248 .unwrap();
9249 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
9250 assert_eq!(resp.status(), StatusCode::OK);
9251 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9252 .await
9253 .unwrap();
9254 assert_eq!(&body[..], b"<h1>Home</h1>");
9255
9256 std::fs::remove_dir_all(&temp_dir).ok();
9258 }
9259
9260 #[tokio::test]
9261 async fn http_consumer_returns_body_and_code_on_stop() {
9262 use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
9263 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
9264 use tower::ServiceExt;
9265
9266 let set_body_step = CompiledStep::Process {
9268 kind_hint: camel_api::SpanKindHint::Internal,
9269 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
9270 ex.input.body = Body::Text("nope".into());
9271 Box::pin(async move { Ok(ex) })
9272 }),
9273 body_contract: None,
9274 lifecycle: None,
9275 label: None,
9276 };
9277 let set_status_step = CompiledStep::Process {
9278 kind_hint: camel_api::SpanKindHint::Internal,
9279 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
9280 ex.input.set_header(
9281 "CamelHttpResponseCode",
9282 serde_json::Value::Number(409.into()),
9283 );
9284 Box::pin(async move { Ok(ex) })
9285 }),
9286 body_contract: None,
9287 lifecycle: None,
9288 label: None,
9289 };
9290 let pipeline = compose_pipeline_with_handler(
9291 vec![set_body_step, set_status_step, CompiledStep::Stop],
9292 None,
9293 PipelineRuntimeCtx::compile_time(),
9294 );
9295
9296 let ex = Exchange::new(Message::default());
9297 let result = pipeline.oneshot(ex).await;
9298 assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
9299 let returned = result.unwrap();
9300 assert_eq!(returned.input.body.as_text(), Some("nope"));
9301 assert_eq!(
9302 returned
9303 .input
9304 .header("CamelHttpResponseCode")
9305 .and_then(|v| v.as_u64()),
9306 Some(409)
9307 );
9308 }
9309
9310 #[tokio::test]
9311 async fn http_consumer_returns_200_when_body_empty_on_stop() {
9312 use camel_api::{Exchange, Message};
9320 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
9321 use tower::ServiceExt;
9322
9323 let pipeline = compose_pipeline_with_handler(
9324 vec![CompiledStep::Stop],
9325 None,
9326 PipelineRuntimeCtx::compile_time(),
9327 );
9328 let ex = Exchange::new(Message::default());
9329 let result = pipeline.oneshot(ex).await;
9330 assert!(result.is_ok(), "Stop with empty body arrives as Ok");
9331 }
9334
9335 async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
9343 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9344 let port = listener.local_addr().unwrap().port();
9345 let registry = HttpRouteRegistry::new();
9346 tokio::spawn(run_axum_server(
9347 listener,
9348 registry.clone(),
9349 2 * 1024 * 1024,
9350 10 * 1024 * 1024,
9351 Arc::new(tokio::sync::Semaphore::new(1024)),
9352 test_rt(),
9353 "test-route".into(),
9354 ));
9355 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9357 (port, registry)
9358 }
9359
9360 fn spawn_responder(
9365 mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
9366 status: u16,
9367 body: String,
9368 ) -> tokio::task::JoinHandle<()> {
9369 tokio::spawn(async move {
9370 if let Some(envelope) = rx.recv().await {
9371 let _ = envelope.reply_tx.send(HttpReply {
9372 status,
9373 headers: vec![],
9374 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
9375 });
9376 }
9377 })
9378 }
9379
9380 #[tokio::test]
9381 async fn method_aware_dispatch_same_path_different_verbs() {
9382 let (port, registry) = spawn_test_server().await;
9383
9384 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9389 registry
9390 .register_rest_endpoint(
9391 "GET".into(),
9392 vec![PathSegment::Literal("users".into())],
9393 get_tx,
9394 )
9395 .await;
9396
9397 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9398 registry
9399 .register_rest_endpoint(
9400 "POST".into(),
9401 vec![PathSegment::Literal("users".into())],
9402 post_tx,
9403 )
9404 .await;
9405
9406 let get_handle = spawn_responder(get_rx, 200, "list".into());
9407 let post_handle = spawn_responder(post_rx, 201, "create".into());
9408
9409 let client = reqwest::Client::new();
9410
9411 let resp = client
9413 .get(format!("http://127.0.0.1:{port}/users"))
9414 .send()
9415 .await
9416 .unwrap();
9417 assert_eq!(resp.status().as_u16(), 200);
9418 let body = resp.text().await.unwrap();
9419 assert_eq!(body, "list");
9420
9421 let resp = client
9423 .post(format!("http://127.0.0.1:{port}/users"))
9424 .send()
9425 .await
9426 .unwrap();
9427 assert_eq!(resp.status().as_u16(), 201);
9428 let body = resp.text().await.unwrap();
9429 assert_eq!(body, "create");
9430
9431 let _ = tokio::join!(get_handle, post_handle);
9432 }
9433
9434 #[tokio::test]
9435 async fn method_aware_dispatch_templated_path_extracts_params() {
9436 let (port, registry) = spawn_test_server().await;
9437
9438 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9442 registry
9443 .register_rest_endpoint(
9444 "GET".into(),
9445 vec![
9446 PathSegment::Literal("users".into()),
9447 PathSegment::Param("id".into()),
9448 ],
9449 tx,
9450 )
9451 .await;
9452
9453 let handle = tokio::spawn(async move {
9456 if let Some(envelope) = rx.recv().await {
9457 let id = envelope.path_params.get("id").cloned().unwrap_or_default();
9458 let _ = envelope.reply_tx.send(HttpReply {
9459 status: 200,
9460 headers: vec![],
9461 body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
9462 });
9463 }
9464 });
9465
9466 let client = reqwest::Client::new();
9467 let resp = client
9468 .get(format!("http://127.0.0.1:{port}/users/42"))
9469 .send()
9470 .await
9471 .unwrap();
9472 assert_eq!(resp.status().as_u16(), 200);
9473 let body = resp.text().await.unwrap();
9474 assert_eq!(body, "id=42");
9475
9476 let _ = handle.await;
9477 }
9478
9479 #[tokio::test]
9480 async fn method_aware_dispatch_unmatched_method_falls_through() {
9481 let (port, _registry) = spawn_test_server().await;
9486
9487 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9489 _registry
9490 .register_rest_endpoint(
9491 "GET".into(),
9492 vec![PathSegment::Literal("users".into())],
9493 get_tx,
9494 )
9495 .await;
9496
9497 let drain = tokio::spawn(async move {
9500 let mut get_rx = get_rx;
9501 while get_rx.recv().await.is_some() {}
9502 });
9503
9504 let client = reqwest::Client::new();
9505 let resp = client
9506 .delete(format!("http://127.0.0.1:{port}/users"))
9507 .send()
9508 .await
9509 .unwrap();
9510 assert_eq!(resp.status().as_u16(), 404);
9511
9512 drop(drain);
9513 }
9514
9515 #[tokio::test]
9516 async fn regression_legacy_exact_api_route_still_works() {
9517 let (port, registry) = spawn_test_server().await;
9522
9523 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9524 registry.register_api_route("/legacy/path".into(), tx).await;
9525
9526 let handle = tokio::spawn(async move {
9527 if let Some(envelope) = rx.recv().await {
9528 let _ = envelope.reply_tx.send(HttpReply {
9529 status: 200,
9530 headers: vec![],
9531 body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
9532 });
9533 }
9534 });
9535
9536 let client = reqwest::Client::new();
9537 let resp = client
9538 .get(format!("http://127.0.0.1:{port}/legacy/path"))
9539 .send()
9540 .await
9541 .unwrap();
9542 assert_eq!(resp.status().as_u16(), 200);
9543 let body = resp.text().await.unwrap();
9544 assert_eq!(body, "legacy ok");
9545
9546 let _ = handle.await;
9547 }
9548
9549 #[allow(clippy::await_holding_lock)]
9550 #[tokio::test]
9551 async fn regression_static_mount_still_works() {
9552 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
9556 ServerRegistry::reset();
9557
9558 let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
9559 std::fs::create_dir_all(&temp_dir).unwrap();
9560 std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
9561 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
9562
9563 let registry = make_test_registry();
9564 let serve_dir = ServeDir::new(&canonical_dir)
9565 .precompressed_gzip()
9566 .precompressed_br()
9567 .append_index_html_on_directories(true);
9568 let mount = StaticMount {
9569 mount_path: "/".to_string(),
9570 mode: MountMode::Static,
9571 dir: canonical_dir.clone(),
9572 cache_control: "public, max-age=3600".to_string(),
9573 error_pages: std::collections::HashMap::new(),
9574 serve_dir,
9575 };
9576 registry.register_static_mount(mount).await.unwrap();
9577
9578 let state = make_test_state(registry);
9579 let req = Request::builder()
9580 .uri("/regress.txt")
9581 .body(AxumBody::empty())
9582 .unwrap();
9583 let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
9584 assert_eq!(resp.status(), StatusCode::OK);
9585 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
9586 .await
9587 .unwrap();
9588 assert_eq!(&body[..], b"static works");
9589
9590 std::fs::remove_dir_all(&temp_dir).ok();
9591 }
9592
9593 #[tokio::test]
9602 async fn deregister_one_method_keeps_sibling_verbs() {
9603 let (port, registry) = spawn_test_server().await;
9607
9608 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9609 registry
9610 .register_rest_endpoint(
9611 "GET".into(),
9612 vec![PathSegment::Literal("users".into())],
9613 get_tx,
9614 )
9615 .await;
9616
9617 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9618 registry
9619 .register_rest_endpoint(
9620 "POST".into(),
9621 vec![PathSegment::Literal("users".into())],
9622 post_tx,
9623 )
9624 .await;
9625
9626 let drain = tokio::spawn(async move {
9628 let mut get_rx = get_rx;
9629 while get_rx.recv().await.is_some() {}
9630 });
9631
9632 registry.unregister_rest_endpoint("GET", "/users").await;
9634 drop(drain);
9635
9636 let post_handle = spawn_responder(post_rx, 201, "create".into());
9637
9638 let client = reqwest::Client::new();
9639 let resp = client
9641 .post(format!("http://127.0.0.1:{port}/users"))
9642 .send()
9643 .await
9644 .unwrap();
9645 assert_eq!(resp.status().as_u16(), 201);
9646 assert_eq!(resp.text().await.unwrap(), "create");
9647
9648 let _ = post_handle.await;
9649 }
9650
9651 #[tokio::test]
9652 async fn dispatch_exact_legacy_beats_rest_template() {
9653 let (port, registry) = spawn_test_server().await;
9658
9659 let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9661 registry
9662 .register_api_route("/api/users".into(), exact_tx)
9663 .await;
9664 let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
9665
9666 let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9668 registry
9669 .register_rest_endpoint(
9670 "GET".into(),
9671 vec![
9672 PathSegment::Literal("api".into()),
9673 PathSegment::Param("resource".into()),
9674 ],
9675 tpl_tx,
9676 )
9677 .await;
9678 let _tpl_drain = tokio::spawn(async move {
9684 let mut tpl_rx = tpl_rx;
9685 if let Some(env) = tpl_rx.recv().await {
9686 let _ = env.reply_tx.send(HttpReply {
9687 status: 200,
9688 headers: vec![],
9689 body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
9690 });
9691 }
9692 });
9693
9694 let client = reqwest::Client::new();
9695 let resp = client
9696 .get(format!("http://127.0.0.1:{port}/api/users"))
9697 .send()
9698 .await
9699 .unwrap();
9700 assert_eq!(resp.status().as_u16(), 200);
9701 assert_eq!(resp.text().await.unwrap(), "exact");
9703
9704 let _ = exact_handle.await;
9705 }
9706
9707 #[tokio::test]
9708 async fn ambiguous_rest_templates_return_500_not_silent_404() {
9709 let (port, registry) = spawn_test_server().await;
9714
9715 let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9716 registry
9717 .register_rest_endpoint(
9718 "GET".into(),
9719 vec![
9720 PathSegment::Literal("users".into()),
9721 PathSegment::Param("id".into()),
9722 ],
9723 a_tx,
9724 )
9725 .await;
9726
9727 let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9728 registry
9729 .register_rest_endpoint(
9730 "GET".into(),
9731 vec![
9732 PathSegment::Literal("users".into()),
9733 PathSegment::Param("name".into()),
9734 ],
9735 b_tx,
9736 )
9737 .await;
9738
9739 let client = reqwest::Client::new();
9740 let resp = client
9741 .get(format!("http://127.0.0.1:{port}/users/42"))
9742 .send()
9743 .await
9744 .unwrap();
9745 assert_eq!(resp.status().as_u16(), 500);
9747 }
9748
9749 #[test]
9750 fn from_uri_round_trips_templated_path_with_http_method() {
9751 use crate::UriConfig;
9757 let cfg =
9758 HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
9759 assert_eq!(cfg.host, "0.0.0.0");
9760 assert_eq!(cfg.port, 8080);
9761 assert_eq!(cfg.path, "/users/{id}");
9762 assert_eq!(cfg.method.as_deref(), Some("GET"));
9763
9764 let cfg_lc =
9766 HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
9767 assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
9768 assert_eq!(cfg_lc.path, "/orders");
9769 }
9770
9771 #[test]
9776 fn type_conversion_failed_maps_to_400() {
9777 let reply = pipeline_error_to_reply(
9778 CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
9779 "/api/users",
9780 );
9781 assert_eq!(reply.status, 400);
9782 let ct = reply
9784 .headers
9785 .iter()
9786 .find(|(k, _)| k == "Content-Type")
9787 .map(|(_, v)| v.as_str());
9788 assert_eq!(ct, Some("application/json"));
9789 let body = match &reply.body {
9791 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
9792 _ => panic!("expected bytes body"),
9793 };
9794 assert!(body.contains("\"error\""));
9795 assert!(body.contains("bad_request"));
9796 assert!(body.contains("invalid JSON at line 1"));
9797 }
9798
9799 #[test]
9800 fn other_error_still_maps_to_500() {
9801 let reply =
9802 pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
9803 assert_eq!(reply.status, 500);
9804 }
9805
9806 #[test]
9807 fn unauthenticated_maps_to_401() {
9808 let reply = pipeline_error_to_reply(
9809 CamelError::Unauthenticated("no token".to_string()),
9810 "/api/users",
9811 );
9812 assert_eq!(reply.status, 401);
9813 }
9814
9815 #[test]
9816 fn unauthorized_maps_to_403() {
9817 let reply = pipeline_error_to_reply(
9818 CamelError::Unauthorized("forbidden".to_string()),
9819 "/api/users",
9820 );
9821 assert_eq!(reply.status, 403);
9822 }
9823
9824 #[test]
9825 fn validation_error_maps_to_400() {
9826 let reply = pipeline_error_to_reply(
9827 CamelError::ValidationError("body does not match schema".to_string()),
9828 "/api/users",
9829 );
9830 assert_eq!(reply.status, 400);
9831 let ct = reply
9832 .headers
9833 .iter()
9834 .find(|(k, _)| k == "Content-Type")
9835 .map(|(_, v)| v.as_str());
9836 assert_eq!(ct, Some("application/json"));
9837 let body = match &reply.body {
9838 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
9839 _ => panic!("expected bytes body"),
9840 };
9841 assert!(body.contains("\"error\""));
9842 assert!(body.contains("validation_error"));
9843 assert!(body.contains("body does not match schema"));
9844 }
9845
9846 #[test]
9847 fn https_consumer_without_tls_cert_errors() {
9848 let endpoint = HttpEndpoint {
9849 uri: "https://0.0.0.0:8443/api".to_string(),
9850 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
9851 server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
9852 client: reqwest::Client::new(),
9853 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
9854 PINNED_CLIENT_TTL,
9855 PINNED_CLIENT_MAX_ENTRIES,
9856 )),
9857 http_config: HttpConfig::default(),
9858 };
9859 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
9860 let result = endpoint.create_consumer(rt);
9861 assert!(result.is_err(), "expected error for https without tls cert");
9862 if let Err(e) = result {
9863 let msg = e.to_string();
9864 assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
9865 }
9866 }
9867
9868 #[test]
9869 fn http_consumer_with_tls_config_errors() {
9870 let endpoint = HttpEndpoint {
9871 uri: "http://0.0.0.0:8080/api".to_string(),
9872 config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
9873 server_config: HttpServerConfig::from_uri(
9874 "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
9875 )
9876 .unwrap(),
9877 client: reqwest::Client::new(),
9878 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
9879 PINNED_CLIENT_TTL,
9880 PINNED_CLIENT_MAX_ENTRIES,
9881 )),
9882 http_config: HttpConfig::default(),
9883 };
9884 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
9885 let result = endpoint.create_consumer(rt);
9886 assert!(result.is_err(), "expected error for http with tls config");
9887 if let Err(e) = result {
9888 let msg = e.to_string();
9889 assert!(msg.contains("https"), "error must mention https: {msg}");
9890 }
9891 }
9892
9893 #[test]
9894 fn https_consumer_with_partial_tls_cert_only_errors() {
9895 let server_config =
9898 HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
9899 assert!(
9900 server_config.tls_config.is_none(),
9901 "partial tlsCert must not create ServerTlsConfig"
9902 );
9903 let endpoint = HttpEndpoint {
9904 uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
9905 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
9906 .unwrap(),
9907 server_config,
9908 client: reqwest::Client::new(),
9909 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
9910 PINNED_CLIENT_TTL,
9911 PINNED_CLIENT_MAX_ENTRIES,
9912 )),
9913 http_config: HttpConfig::default(),
9914 };
9915 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
9916 let result = endpoint.create_consumer(rt);
9917 assert!(
9918 result.is_err(),
9919 "must error: https:// requires both tlsCert and tlsKey"
9920 );
9921 }
9922
9923 #[test]
9924 fn load_tls_config_parses_valid_pem() {
9925 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
9927 use camel_component_api::test_support::tls;
9928 let (_, cert_pem, key_pem) = tls::gen_server_cert();
9929 let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
9930 let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
9931
9932 let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
9933 assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
9934 }
9935
9936 #[tokio::test(flavor = "multi_thread")]
9937 #[allow(clippy::await_holding_lock)]
9938 async fn consumer_tls_handshake_roundtrip() {
9939 use camel_component_api::test_support::tls;
9940 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9941
9942 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
9944
9945 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
9947
9948 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
9950 let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
9951 let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
9952 let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
9953
9954 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9956 let port = probe.local_addr().unwrap().port();
9957 drop(probe);
9958
9959 ServerRegistry::reset();
9960
9961 let component = HttpComponent::new();
9963 let endpoint_ctx = NoOpComponentContext;
9964 let uri = format!(
9965 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
9966 cert_path.to_string_lossy(),
9967 key_path.to_string_lossy(),
9968 );
9969 let endpoint = component
9970 .create_endpoint(&uri, &endpoint_ctx)
9971 .expect("create TLS endpoint");
9972 let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
9973
9974 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9976 let token = tokio_util::sync::CancellationToken::new();
9977 let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
9978 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9979
9980 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
9982
9983 let ca_bytes = std::fs::read(&ca_path).unwrap();
9985 let client = reqwest::Client::builder()
9986 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
9987 .build()
9988 .unwrap();
9989
9990 let send_fut = client
9991 .post(format!("https://localhost:{port}/test"))
9992 .body("ping")
9993 .send();
9994
9995 let (http_result, _) = tokio::join!(send_fut, async {
9997 if let Some(mut envelope) = rx.recv().await {
9998 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
9999 if let Some(reply_tx) = envelope.reply_tx {
10000 let _ = reply_tx.send(Ok(envelope.exchange));
10001 }
10002 }
10003 });
10004
10005 let resp = http_result.expect("TLS handshake + request must succeed");
10006
10007 assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
10008 let body = resp.text().await.unwrap();
10009 assert_eq!(body, "pong");
10010
10011 token.cancel();
10012 }
10013
10014 #[tokio::test(flavor = "multi_thread")]
10015 #[allow(clippy::await_holding_lock)]
10016 async fn consumer_tls_rejects_client_without_ca() {
10017 use camel_component_api::test_support::tls;
10018 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
10019
10020 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
10021
10022 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
10024
10025 let (_, cert_pem, key_pem) = tls::gen_server_cert();
10026 let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
10027 let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
10028
10029 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10030 let port = probe.local_addr().unwrap().port();
10031 drop(probe);
10032
10033 ServerRegistry::reset();
10034
10035 let component = HttpComponent::new();
10037 let endpoint_ctx = NoOpComponentContext;
10038 let uri = format!(
10039 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
10040 cert_path.to_string_lossy(),
10041 key_path.to_string_lossy(),
10042 );
10043 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
10044 let mut consumer = endpoint.create_consumer(rt()).unwrap();
10045 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
10046 let token = tokio_util::sync::CancellationToken::new();
10047 let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
10048 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10049
10050 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
10051
10052 let client = reqwest::Client::builder().build().unwrap();
10054
10055 let result = client
10056 .get(format!("https://localhost:{port}/test"))
10057 .send()
10058 .await;
10059
10060 assert!(
10061 result.is_err(),
10062 "must reject without CA — proves real verification"
10063 );
10064
10065 token.cancel();
10066 }
10067
10068 #[test]
10069 fn server_config_partial_tls_cert_without_key() {
10070 let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
10072 assert!(cfg.tls_config.is_none());
10074 }
10075
10076 #[test]
10077 fn endpoint_uri_options_count_parity() {
10078 assert_eq!(
10080 HttpEndpointConfig::uri_options().len(),
10081 22,
10082 "HttpEndpointUriConfig #[uri_param] count drifted from parser"
10083 );
10084 }
10085
10086 fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
10087 pairs
10088 .iter()
10089 .map(|(k, v)| {
10090 (
10091 (*k).to_string(),
10092 serde_json::Value::String((*v).to_string()),
10093 )
10094 })
10095 .collect()
10096 }
10097
10098 #[test]
10099 fn response_emits_cache_control_via_pragma_warning() {
10100 let headers = make_headers(&[
10101 ("Cache-Control", "public, max-age=3600"),
10102 ("Via", "1.1 myproxy"),
10103 ("Pragma", "no-cache"),
10104 ("Warning", "199 misc"),
10105 ]);
10106 let selected = select_response_headers(&headers, None, None);
10107 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
10108 for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
10109 assert!(
10110 names.contains(&expected),
10111 "{expected} should pass through to the response"
10112 );
10113 }
10114 }
10115
10116 #[test]
10117 fn response_excludes_request_only_and_server_owned() {
10118 let headers = make_headers(&[
10119 ("User-Agent", "x"),
10120 ("Accept", "*/*"),
10121 ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
10122 ]);
10123 let selected = select_response_headers(&headers, None, None);
10124 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
10125 for excluded in ["User-Agent", "Accept", "Date"] {
10126 assert!(
10127 !names.contains(&excluded),
10128 "{excluded} should NOT appear in the response"
10129 );
10130 }
10131 }
10132
10133 #[test]
10134 fn response_re_derives_content_type() {
10135 let headers = make_headers(&[("Content-Type", "text/plain")]);
10136 let selected = select_response_headers(&headers, Some("application/json".into()), None);
10137 let ct_entries: Vec<&str> = selected
10138 .iter()
10139 .filter(|(k, _)| k == "Content-Type")
10140 .map(|(_, v)| v.as_str())
10141 .collect();
10142 assert_eq!(
10143 ct_entries,
10144 ["application/json"],
10145 "exactly one Content-Type entry, re-derived from user_content_type"
10146 );
10147 }
10148
10149 #[test]
10150 fn response_excludes_camel_headers() {
10151 let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
10152 let selected = select_response_headers(&headers, None, None);
10153 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
10154 assert!(
10155 !names.contains(&"CamelHttpPath"),
10156 "Camel-namespace headers must be excluded"
10157 );
10158 assert!(
10159 names.contains(&"Cache-Control"),
10160 "Cache-Control must pass through"
10161 );
10162 }
10163
10164 #[test]
10165 fn response_stringifies_scalar_header_values() {
10166 let mut headers = make_headers(&[("X-Label", "keep")]);
10167 headers.insert("X-Retries".to_string(), serde_json::json!(3));
10168 headers.insert("X-Ratio".to_string(), serde_json::json!(3.5));
10169 headers.insert("X-Enabled".to_string(), serde_json::json!(true));
10170 let selected = select_response_headers(&headers, None, None);
10171 let get = |name: &str| -> Option<&str> {
10172 selected
10173 .iter()
10174 .find(|(k, _)| k == name)
10175 .map(|(_, v)| v.as_str())
10176 };
10177 assert_eq!(
10178 get("X-Retries"),
10179 Some("3"),
10180 "integer header must be stringified"
10181 );
10182 assert_eq!(
10183 get("X-Ratio"),
10184 Some("3.5"),
10185 "float header must be stringified"
10186 );
10187 assert_eq!(
10188 get("X-Enabled"),
10189 Some("true"),
10190 "bool header must be stringified"
10191 );
10192 assert_eq!(
10193 get("X-Label"),
10194 Some("keep"),
10195 "string header must pass through"
10196 );
10197 }
10198
10199 #[test]
10200 fn response_drops_null_and_structured_header_values() {
10201 let mut headers = make_headers(&[("X-Keep", "yes")]);
10202 headers.insert("X-Null".to_string(), serde_json::Value::Null);
10203 headers.insert("X-Obj".to_string(), serde_json::json!({"a": 1}));
10204 headers.insert("X-Arr".to_string(), serde_json::json!([1, 2]));
10205 let selected = select_response_headers(&headers, None, None);
10206 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
10207 for dropped in ["X-Null", "X-Obj", "X-Arr"] {
10208 assert!(
10209 !names.contains(&dropped),
10210 "{dropped} must not be emitted: no single-value form"
10211 );
10212 }
10213 assert!(names.contains(&"X-Keep"), "scalar headers must survive");
10214 }
10215
10216 #[test]
10217 fn response_stringifies_scalars_despite_excluded_names() {
10218 let mut headers = HashMap::new();
10222 headers.insert("Content-Length".to_string(), serde_json::json!(999));
10223 headers.insert("Date".to_string(), serde_json::json!(12345));
10224 let selected = select_response_headers(&headers, None, None);
10225 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
10226 assert!(
10227 !names.contains(&"Content-Length"),
10228 "content-length is re-derived by the server"
10229 );
10230 assert!(!names.contains(&"Date"), "date is server-owned");
10231 }
10232
10233 async fn start_host_capturing_destination() -> (
10245 String,
10246 Arc<std::sync::Mutex<Option<(String, String)>>>,
10247 tokio::task::JoinHandle<()>,
10248 ) {
10249 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10250 let port = listener.local_addr().unwrap().port();
10251 let url = format!("http://127.0.0.1:{port}");
10252 let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
10253 Arc::new(std::sync::Mutex::new(None));
10254 let captured_clone = Arc::clone(&captured);
10255 let handle = tokio::spawn(async move {
10256 use tokio::io::{AsyncReadExt, AsyncWriteExt};
10257 if let Ok((mut stream, _)) = listener.accept().await {
10258 let mut buf = vec![0u8; 16384];
10259 let n = stream.read(&mut buf).await.unwrap_or(0);
10260 let request = String::from_utf8_lossy(&buf[..n]).to_string();
10261 if request.contains("\r\n\r\n") {
10262 let request_line = request.lines().next().unwrap_or("").to_string();
10263 let host_value = request
10264 .lines()
10265 .find(|l| l.to_lowercase().starts_with("host:"))
10266 .and_then(|l| l.split_once(':'))
10267 .map(|(_, v)| v.trim().to_string())
10268 .unwrap_or_default();
10269 *captured_clone.lock().unwrap() = Some((host_value, request_line));
10270 }
10271 let body = r#"{"echo":"ok"}"#;
10272 let resp = format!(
10273 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
10274 body.len(),
10275 body
10276 );
10277 let _ = stream.write_all(resp.as_bytes()).await;
10278 }
10279 });
10280 (url, captured, handle)
10281 }
10282
10283 #[tokio::test]
10288 async fn bridge_proxy_outbound_host_matches_destination() {
10289 use tower::ServiceExt;
10290
10291 let (url, captured, _handle) = start_host_capturing_destination().await;
10292 let expected_host = url.strip_prefix("http://").unwrap();
10295
10296 let ctx = test_producer_ctx();
10297 let component = HttpComponent::new();
10298 let endpoint_ctx = NoOpComponentContext;
10299 let endpoint = component
10300 .create_endpoint(
10301 &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
10302 &endpoint_ctx,
10303 )
10304 .unwrap();
10305 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
10306
10307 let mut exchange = Exchange::new(Message::default());
10310 exchange.input.set_header("Host", "localhost");
10311 exchange.input.set_header("CamelHttpPath", "/foo");
10312
10313 let result = producer.oneshot(exchange).await;
10314 assert!(result.is_ok(), "producer call failed: {:?}", result);
10315
10316 tokio::time::sleep(Duration::from_millis(100)).await;
10317 let (host_value, request_line) = captured
10318 .lock()
10319 .unwrap()
10320 .take()
10321 .expect("destination capture mutex empty — producer did not reach the destination");
10322
10323 assert_ne!(
10324 host_value, "localhost",
10325 "bridge producer must not forward the exchange Host: localhost"
10326 );
10327 assert_eq!(
10328 host_value, expected_host,
10329 "Host must be derived from the destination authority (no scheme)"
10330 );
10331 assert!(
10332 !request_line.contains("/foo"),
10333 "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
10334 );
10335 }
10336
10337 #[tokio::test]
10342 async fn bridge_proxy_route_set_response_header_survives() {
10343 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
10344
10345 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
10346 let port = listener.local_addr().unwrap().port();
10347 drop(listener);
10348
10349 let component = HttpComponent::new();
10350 let endpoint_ctx = NoOpComponentContext;
10351 let endpoint = component
10352 .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
10353 .unwrap();
10354 let mut consumer = endpoint.create_consumer(rt()).unwrap();
10355
10356 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
10357 let token = tokio_util::sync::CancellationToken::new();
10358 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
10359
10360 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
10361 tokio::time::sleep(Duration::from_millis(50)).await;
10362
10363 let client = reqwest::Client::new();
10364 let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
10365
10366 let (http_result, _) = tokio::join!(send_fut, async {
10370 if let Some(mut envelope) = rx.recv().await {
10371 envelope
10372 .exchange
10373 .input
10374 .set_header("Cache-Control", "public, max-age=3600");
10375 if let Some(reply_tx) = envelope.reply_tx {
10376 let _ = reply_tx.send(Ok(envelope.exchange));
10377 }
10378 }
10379 });
10380
10381 let resp = http_result.unwrap();
10382 assert_eq!(resp.status().as_u16(), 200);
10383
10384 let cache_control = resp.headers().get("cache-control");
10385 assert!(
10386 cache_control.is_some(),
10387 "Cache-Control header must survive to the wire response"
10388 );
10389 assert_eq!(
10390 cache_control.unwrap().to_str().unwrap(),
10391 "public, max-age=3600"
10392 );
10393
10394 token.cancel();
10395 }
10396
10397 use camel_api::security_policy::CredentialSource;
10416 use camel_auth::credential_source::extract_token_from_exchange;
10417 use camel_auth::native_auth::NativeCredentialStore;
10418 use camel_auth::{StaticTokenAuthenticator, TokenAuthenticator};
10419
10420 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 {
10428 let mut msg = Message::default();
10429 msg.set_header(
10430 "CamelHttpMethod",
10431 serde_json::Value::String(envelope.method.clone()),
10432 );
10433 msg.set_header(
10434 "CamelHttpPath",
10435 serde_json::Value::String(envelope.path.clone()),
10436 );
10437 msg.set_header(
10438 "CamelHttpQuery",
10439 serde_json::Value::String(envelope.query.clone()),
10440 );
10441 for (k, v) in &envelope.headers {
10442 if let Ok(val_str) = v.to_str() {
10443 msg.set_header(
10444 title_case_header(k.as_str()),
10445 serde_json::Value::String(val_str.to_string()),
10446 );
10447 }
10448 }
10449 Exchange::new(msg)
10450 }
10451
10452 async fn spawn_failing_auth_route(
10459 registry: &HttpRouteRegistry,
10460 path: &str,
10461 sources: Vec<CredentialSource>,
10462 ) {
10463 let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
10464 NativeCredentialStore::try_new(vec![]).unwrap(),
10465 ));
10466 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
10467 registry.register_api_route(path.to_string(), tx).await;
10468 let path_owned = path.to_string();
10469 tokio::spawn(async move {
10470 while let Some(envelope) = rx.recv().await {
10471 let exchange = envelope_to_exchange(&envelope);
10472 let reply_tx = envelope.reply_tx;
10473 let result: Result<(), CamelError> = async {
10474 let token = extract_token_from_exchange(&exchange, &sources)
10475 .map(|extracted| extracted.token)
10476 .ok_or_else(|| {
10477 CamelError::Unauthenticated("no credential in any source".into())
10478 })?;
10479 authenticator.authenticate_bearer(&token).await?;
10480 Ok(())
10481 }
10482 .await;
10483 let reply = match result {
10484 Ok(()) => HttpReply {
10485 status: 200,
10486 headers: vec![],
10487 body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
10488 },
10489 Err(e) => pipeline_error_to_reply(e, &path_owned),
10490 };
10491 let _ = reply_tx.send(reply);
10492 }
10493 });
10494 }
10495
10496 fn captured_logs_contain(needle: &str) -> bool {
10500 let buf = tracing_test::internal::global_buf().lock().unwrap();
10501 String::from_utf8_lossy(&buf).contains(needle)
10502 }
10503
10504 #[tracing_test::traced_test]
10505 #[tokio::test]
10506 async fn error_context_redacts_query_sentinel() {
10507 let (port, registry) = spawn_test_server().await;
10508 spawn_failing_auth_route(
10509 ®istry,
10510 "/secure-query",
10511 vec![CredentialSource::QueryParam {
10512 param: "token".to_string(),
10513 }],
10514 )
10515 .await;
10516
10517 let client = reqwest::Client::new();
10518 let resp = client
10519 .get(format!(
10521 "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
10522 ))
10523 .send()
10524 .await
10525 .unwrap();
10526
10527 assert_eq!(resp.status().as_u16(), 401);
10528 let body = resp.text().await.unwrap();
10529 assert_eq!(body, "Unauthorized");
10530 assert!(
10531 !body.contains(SENTINEL_QRY_42),
10532 "reply body must not contain the query credential"
10533 );
10534 assert!(
10535 !captured_logs_contain(SENTINEL_QRY_42),
10536 "no tracing record during request handling may render the query credential"
10537 );
10538 assert!(
10542 captured_logs_contain("Authentication failed"),
10543 "positive control: the failed-auth warn! must be captured by the test subscriber"
10544 );
10545 }
10546
10547 #[tracing_test::traced_test]
10548 #[tokio::test]
10549 async fn error_context_redacts_cookie_sentinel() {
10550 let (port, registry) = spawn_test_server().await;
10551 spawn_failing_auth_route(
10552 ®istry,
10553 "/secure-cookie",
10554 vec![CredentialSource::Cookie {
10555 name: "session".to_string(),
10556 }],
10557 )
10558 .await;
10559
10560 let client = reqwest::Client::new();
10561 let resp = client
10562 .get(format!("http://127.0.0.1:{port}/secure-cookie"))
10563 .header("Cookie", format!("session={SENTINEL_CKY_7}"))
10564 .send()
10565 .await
10566 .unwrap();
10567
10568 assert_eq!(resp.status().as_u16(), 401);
10569 let body = resp.text().await.unwrap();
10570 assert_eq!(body, "Unauthorized");
10571 assert!(
10572 !body.contains(SENTINEL_CKY_7),
10573 "reply body must not contain the cookie credential"
10574 );
10575 assert!(
10576 !captured_logs_contain(SENTINEL_CKY_7),
10577 "no tracing record during request handling may render the cookie credential"
10578 );
10579 }
10580
10581 #[tracing_test::traced_test]
10582 #[tokio::test]
10583 async fn error_reply_no_credential_value() {
10584 let (port, registry) = spawn_test_server().await;
10585 spawn_failing_auth_route(
10586 ®istry,
10587 "/secure-bad",
10588 vec![CredentialSource::Cookie {
10589 name: "session".to_string(),
10590 }],
10591 )
10592 .await;
10593
10594 let client = reqwest::Client::new();
10595 let resp = client
10596 .get(format!("http://127.0.0.1:{port}/secure-bad"))
10597 .header("Cookie", format!("session={SENTINEL_BAD_1}"))
10598 .send()
10599 .await
10600 .unwrap();
10601
10602 assert_eq!(resp.status().as_u16(), 401);
10603 let body = resp.text().await.unwrap();
10604 assert_eq!(body, "Unauthorized");
10605 assert!(
10606 !body.contains(SENTINEL_BAD_1),
10607 "reply body must not contain the credential value"
10608 );
10609 assert!(
10610 !captured_logs_contain(SENTINEL_BAD_1),
10611 "error logs must not render the credential value"
10612 );
10613 }
10614
10615 async fn spawn_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
10629 use tokio::io::AsyncWriteExt;
10630
10631 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
10632 .await
10633 .expect("bind ephemeral 127.0.0.1 listener");
10634 let port = listener.local_addr().expect("local addr").port();
10635 let base_url = format!("http://localhost:{port}");
10636 let handle = tokio::spawn(async move {
10637 while let Ok((mut conn, _)) = listener.accept().await {
10638 let _ = conn
10639 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
10640 .await;
10641 let _ = conn.shutdown().await;
10642 }
10643 });
10644 (base_url, handle)
10645 }
10646
10647 fn responder_port(base_url: &str) -> u16 {
10651 url::Url::parse(base_url)
10652 .expect("responder base URL parses")
10653 .port()
10654 .expect("responder base URL carries an explicit port")
10655 }
10656
10657 fn endpoint_with_shared_cache(
10662 base_url: &str,
10663 pinned_cache: &Arc<PinnedClientCache>,
10664 ) -> HttpEndpoint {
10665 let uri = format!("{base_url}?allowInternal=true");
10666 HttpEndpoint {
10667 uri: uri.clone(),
10668 config: HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses"),
10669 server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
10670 client: reqwest::Client::new(),
10671 pinned_cache: Arc::clone(pinned_cache),
10672 http_config: HttpConfig::default(),
10673 }
10674 }
10675
10676 #[tokio::test]
10677 async fn producers_share_endpoint_cache() {
10678 use tower::ServiceExt;
10679
10680 let (base_url, _handle) = spawn_multi_accept_200().await;
10681 let pinned_cache = Arc::new(PinnedClientCache::new(
10682 PINNED_CLIENT_TTL,
10683 PINNED_CLIENT_MAX_ENTRIES,
10684 ));
10685
10686 let ctx = test_producer_ctx();
10687 let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
10688 let producer_a = endpoint.create_producer(rt(), &ctx);
10689 let producer_b = endpoint.create_producer(rt(), &ctx);
10690
10691 for producer in [producer_a, producer_b] {
10694 let producer = producer.expect("create producer");
10695 let exchange = Exchange::new(Message::default());
10696 let reply = producer.oneshot(exchange).await;
10697 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
10698 }
10699
10700 assert_eq!(
10701 pinned_cache.build_count(),
10702 1,
10703 "both producers must hit the same shared cache entry; a second \
10704 build means sharing is broken"
10705 );
10706 }
10707
10708 #[tokio::test]
10709 async fn producer_repeated_hostname_requests_build_one_client() {
10710 use tower::ServiceExt;
10711
10712 let (base_url, _handle) = spawn_multi_accept_200().await;
10713 let pinned_cache = Arc::new(PinnedClientCache::new(
10714 PINNED_CLIENT_TTL,
10715 PINNED_CLIENT_MAX_ENTRIES,
10716 ));
10717 let ctx = test_producer_ctx();
10718 let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
10719 let producer = endpoint
10720 .create_producer(rt(), &ctx)
10721 .expect("create producer");
10722
10723 for i in 0..2 {
10726 let exchange = Exchange::new(Message::default());
10727 let reply = producer.clone().oneshot(exchange).await;
10728 assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
10729 }
10730
10731 assert_eq!(
10732 pinned_cache.build_count(),
10733 1,
10734 "repeated hostname requests must reuse the one pinned client; \
10735 0 builds means the producer bypassed the cache, more than 1 \
10736 means the entry was dropped"
10737 );
10738 }
10739
10740 #[tokio::test]
10741 async fn ip_literal_request_never_enters_cache() {
10742 use tower::ServiceExt;
10743
10744 let (base_url, _handle) = spawn_multi_accept_200().await;
10745 let pinned_cache = Arc::new(PinnedClientCache::new(
10746 PINNED_CLIENT_TTL,
10747 PINNED_CLIENT_MAX_ENTRIES,
10748 ));
10749
10750 let ctx = test_producer_ctx();
10751 let destination = format!("http://127.0.0.1:{}/ping", responder_port(&base_url));
10752 let endpoint = endpoint_with_shared_cache(&destination, &pinned_cache);
10753 let producer = endpoint
10754 .create_producer(rt(), &ctx)
10755 .expect("create producer");
10756
10757 let exchange = Exchange::new(Message::default());
10758 let reply = producer.oneshot(exchange).await;
10759 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
10760
10761 assert_eq!(
10762 pinned_cache.build_count(),
10763 0,
10764 "an IP-literal URL must use the shared unpinned client and \
10765 never enter the pinned cache"
10766 );
10767 }
10768
10769 #[tokio::test]
10770 async fn test_component_endpoints_share_pinned_cache() {
10771 use tower::ServiceExt;
10772
10773 let component = HttpComponent::new();
10774 let (base_url, _handle) = spawn_multi_accept_200().await;
10775 let baseline = component.pinned_cache.build_count();
10776
10777 let ctx = test_producer_ctx();
10778 let endpoint_ctx = NoOpComponentContext;
10779 for uri in [
10780 format!("{base_url}/a?allowInternal=true&k=a"),
10781 format!("{base_url}/b?allowInternal=true&k=b"),
10782 ] {
10783 let endpoint = component
10784 .create_endpoint(&uri, &endpoint_ctx)
10785 .expect("create endpoint");
10786 let producer = endpoint
10787 .create_producer(rt(), &ctx)
10788 .expect("create producer");
10789 let exchange = Exchange::new(Message::default());
10790 let reply = producer.oneshot(exchange).await;
10791 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
10792 }
10793
10794 assert_eq!(
10795 component.pinned_cache.build_count() - baseline,
10796 1,
10797 "endpoints created by one component must share its pinned cache; \
10798 0 builds means the endpoints bypassed it, more than 1 means \
10799 per-endpoint caches came back"
10800 );
10801 }
10802
10803 #[tokio::test]
10804 async fn test_dynamic_resolution_sequence_hits_shared_cache() {
10805 use tower::ServiceExt;
10806
10807 let component = HttpComponent::new();
10808 let (base_url, _handle) = spawn_multi_accept_200().await;
10809 let baseline = component.pinned_cache.build_count();
10810
10811 let ctx = test_producer_ctx();
10812 let endpoint_ctx = NoOpComponentContext;
10813 for i in 0..3 {
10814 let endpoint = component
10815 .create_endpoint(
10816 &format!("{base_url}/r{i}?allowInternal=true&k={i}"),
10817 &endpoint_ctx,
10818 )
10819 .expect("create endpoint");
10820 let producer = endpoint
10821 .create_producer(rt(), &ctx)
10822 .expect("create producer");
10823 let exchange = Exchange::new(Message::default());
10824 let reply = producer.oneshot(exchange).await;
10825 assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
10826 }
10827
10828 assert_eq!(
10829 component.pinned_cache.build_count() - baseline,
10830 1,
10831 "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
10832 must reuse the component's one pinned cache entry; 0 builds \
10833 means the endpoints bypassed it, more than 1 means \
10834 per-endpoint caches came back"
10835 );
10836 }
10837
10838 #[test]
10839 fn test_https_component_owns_distinct_cache() {
10840 let http = HttpComponent::new();
10841 let https = HttpsComponent::new();
10842
10843 assert!(
10844 !Arc::ptr_eq(&http.pinned_cache, &https.pinned_cache),
10845 "http and https components must each own their own pinned cache"
10846 );
10847
10848 let endpoint_ctx = NoOpComponentContext;
10849 let _ = http
10850 .create_endpoint("http://localhost:1/?allowInternal=true", &endpoint_ctx)
10851 .expect("http endpoint");
10852 let _ = https
10853 .create_endpoint("https://localhost:1/?allowInternal=true", &endpoint_ctx)
10854 .expect("https endpoint");
10855
10856 assert_eq!(
10857 http.pinned_cache.build_count(),
10858 0,
10859 "endpoint creation must not build a pinned client"
10860 );
10861 assert_eq!(
10862 https.pinned_cache.build_count(),
10863 0,
10864 "endpoint creation must not build a pinned client"
10865 );
10866 }
10867
10868 #[test]
10869 fn test_component_constructor_builds_one_unpinned_client() {
10870 let baseline = build_client_call_count();
10871
10872 let _http = HttpComponent::new();
10873 assert_eq!(
10874 build_client_call_count() - baseline,
10875 1,
10876 "HttpComponent::new() must build exactly one shared unpinned client"
10877 );
10878
10879 let _https = HttpsComponent::new();
10880 assert_eq!(
10881 build_client_call_count() - baseline,
10882 2,
10883 "HttpsComponent::new() must build exactly one more shared unpinned client"
10884 );
10885 }
10886
10887 #[test]
10888 fn test_component_endpoints_share_unpinned_client() {
10889 let component = HttpComponent::new();
10890 let baseline = build_client_call_count();
10891
10892 let endpoint_ctx = NoOpComponentContext;
10893 for uri in [
10894 "http://localhost:1/a?allowInternal=true",
10895 "http://localhost:1/b?allowInternal=true",
10896 ] {
10897 let _endpoint = component
10898 .create_endpoint(uri, &endpoint_ctx)
10899 .expect("create endpoint");
10900 }
10901
10902 assert_eq!(
10903 build_client_call_count() - baseline,
10904 0,
10905 "create_endpoint must clone the component's shared unpinned client, \
10906 never build a fresh one"
10907 );
10908 }
10909
10910 #[test]
10911 fn test_dynamic_resolution_adds_no_unpinned_client_builds() {
10912 let component = HttpComponent::new();
10913 let baseline = build_client_call_count();
10914
10915 let ctx = test_producer_ctx();
10916 let endpoint_ctx = NoOpComponentContext;
10917 for i in 0..3 {
10918 let endpoint = component
10919 .create_endpoint(
10920 &format!("http://localhost:1/r{i}?allowInternal=true&k={i}"),
10921 &endpoint_ctx,
10922 )
10923 .expect("create endpoint");
10924 let _producer = endpoint
10925 .create_producer(rt(), &ctx)
10926 .expect("create producer");
10927 }
10928
10929 assert_eq!(
10930 build_client_call_count() - baseline,
10931 0,
10932 "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
10933 must reuse the component's shared unpinned client and build \
10934 no additional clients"
10935 );
10936 }
10937}