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