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