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};
46use futures::StreamExt;
47use futures::TryStreamExt;
48use futures::stream::BoxStream;
49
50#[derive(Debug, 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: HashMap<String, String>,
118 pub allow_internal: bool,
119 pub blocked_hosts: Vec<String>,
120 pub max_body_size: usize,
121 pub read_timeout_ms: u64,
122 pub max_response_bytes: usize,
123 pub auth: HttpAuth,
124 pub token_provider: Option<Arc<dyn TokenProvider>>,
125 pub user_agent: Option<String>,
126 pub bridge_endpoint: bool,
127 pub connection_close: bool,
128 pub skip_request_headers: Vec<String>,
129 pub skip_response_headers: Vec<String>,
130 pub follow_redirects: bool,
131 pub max_redirects: usize,
132}
133
134#[derive(Clone, PartialEq)]
135pub enum HttpAuth {
136 None,
137 Basic { username: String, password: String },
138 Bearer { token: String },
139}
140
141impl std::fmt::Debug for HttpAuth {
142 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143 match self {
144 HttpAuth::None => f.write_str("None"),
145 HttpAuth::Basic { username, .. } => f
146 .debug_struct("Basic")
147 .field("username", username)
148 .field("password", &"***")
149 .finish(),
150 HttpAuth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
151 }
152 }
153}
154
155const HTTP_CAMEL_OPTIONS: &[&str] = &[
157 "httpMethod",
158 "throwExceptionOnFailure",
159 "okStatusCodeRange",
160 "followRedirects",
161 "maxRedirects",
162 "connectTimeout",
163 "responseTimeout",
164 "allowInternal",
165 "blockedHosts",
166 "maxBodySize",
167 "readTimeout",
168 "maxResponseBytes",
169 "authMethod",
170 "authUsername",
171 "authPassword",
172 "authBearerToken",
173 "userAgent",
174 "cookieHandling",
175 "bridgeEndpoint",
176 "connectionClose",
177 "skipRequestHeaders",
178 "skipResponseHeaders",
179];
180
181impl UriConfig for HttpEndpointConfig {
182 fn scheme() -> &'static str {
184 "http"
185 }
186
187 fn from_uri(uri: &str) -> Result<Self, CamelError> {
188 let parts = parse_uri(uri)?;
189 Self::from_components(parts)
190 }
191
192 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
193 if parts.scheme != "http" && parts.scheme != "https" {
195 return Err(CamelError::InvalidUri(format!(
196 "expected scheme 'http' or 'https', got '{}'",
197 parts.scheme
198 )));
199 }
200
201 let base_url = format!("{}:{}", parts.scheme, parts.path);
204
205 let http_method = parts.params.get("httpMethod").cloned();
206
207 let throw_exception_on_failure = match parts.params.get("throwExceptionOnFailure") {
208 Some(v) => parse_bool_param_http(v).map_err(|e| {
209 CamelError::InvalidUri(format!("invalid value for throwExceptionOnFailure: {e}"))
210 })?,
211 None => true,
212 };
213
214 let ok_status_code_range = match parts.params.get("okStatusCodeRange") {
216 Some(v) => parse_ok_status_code_range(v)?,
217 None => (200, 299),
218 };
219
220 let response_timeout = match parts.params.get("responseTimeout") {
221 Some(v) => Some(v.parse::<u64>().map(Duration::from_millis).map_err(|e| {
222 CamelError::InvalidUri(format!("invalid value for responseTimeout: {e}"))
223 })?),
224 None => None,
225 };
226
227 let allow_internal = match parts.params.get("allowInternal") {
229 Some(v) => parse_bool_param_http(v).map_err(|e| {
230 CamelError::InvalidUri(format!("invalid value for allowInternal: {e}"))
231 })?,
232 None => false, };
234
235 let blocked_hosts = parts
237 .params
238 .get("blockedHosts")
239 .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
240 .unwrap_or_default();
241
242 let max_body_size = match parts.params.get("maxBodySize") {
243 Some(v) => v.parse::<usize>().map_err(|e| {
244 CamelError::InvalidUri(format!("invalid value for maxBodySize: {e}"))
245 })?,
246 None => 10 * 1024 * 1024, };
248
249 let read_timeout_ms = match parts.params.get("readTimeout") {
250 Some(v) => v.parse::<u64>().map_err(|e| {
251 CamelError::InvalidUri(format!("invalid value for readTimeout: {e}"))
252 })?,
253 None => 30_000, };
255
256 let max_response_bytes = match parts.params.get("maxResponseBytes") {
257 Some(v) => v.parse::<usize>().map_err(|e| {
258 CamelError::InvalidUri(format!("invalid value for maxResponseBytes: {e}"))
259 })?,
260 None => 10 * 1024 * 1024, };
262
263 let auth = parse_auth_from_params(&parts.params)?;
264
265 let user_agent = parts.params.get("userAgent").cloned();
266
267 if parts.params.contains_key("cookieHandling") {
268 return Err(CamelError::InvalidUri(
269 "cookieHandling is not supported".into(),
270 ));
271 }
272
273 let bridge_endpoint = match parts.params.get("bridgeEndpoint") {
274 Some(v) => parse_bool_param_http(v).map_err(|e| {
275 CamelError::InvalidUri(format!("invalid value for bridgeEndpoint: {e}"))
276 })?,
277 None => false,
278 };
279
280 let connection_close = match parts.params.get("connectionClose") {
281 Some(v) => parse_bool_param_http(v).map_err(|e| {
282 CamelError::InvalidUri(format!("invalid value for connectionClose: {e}"))
283 })?,
284 None => false,
285 };
286
287 let skip_request_headers = parts
288 .params
289 .get("skipRequestHeaders")
290 .map(|v| {
291 v.split(',')
292 .map(str::trim)
293 .filter(|s| !s.is_empty())
294 .map(|s| s.to_ascii_lowercase())
295 .collect::<Vec<_>>()
296 })
297 .unwrap_or_default();
298
299 let skip_response_headers = parts
300 .params
301 .get("skipResponseHeaders")
302 .map(|v| {
303 v.split(',')
304 .map(str::trim)
305 .filter(|s| !s.is_empty())
306 .map(|s| s.to_ascii_lowercase())
307 .collect::<Vec<_>>()
308 })
309 .unwrap_or_default();
310
311 let follow_redirects = match parts.params.get("followRedirects") {
312 Some(v) => parse_bool_param_http(v).map_err(|e| {
313 CamelError::InvalidUri(format!("invalid value for followRedirects: {e}"))
314 })?,
315 None => false,
316 };
317
318 let max_redirects = match parts.params.get("maxRedirects") {
319 Some(v) => v.parse::<usize>().map_err(|e| {
320 CamelError::InvalidUri(format!("invalid value for maxRedirects: {e}"))
321 })?,
322 None => 10,
323 };
324
325 let query_params: HashMap<String, String> = parts
327 .params
328 .into_iter()
329 .filter(|(k, _)| !HTTP_CAMEL_OPTIONS.contains(&k.as_str()))
330 .collect();
331
332 Ok(Self {
333 base_url,
334 http_method,
335 throw_exception_on_failure,
336 ok_status_code_range,
337 response_timeout,
338 query_params,
339 allow_internal,
340 blocked_hosts,
341 max_body_size,
342 read_timeout_ms,
343 max_response_bytes,
344 auth,
345 token_provider: None,
346 user_agent,
347 bridge_endpoint,
348 connection_close,
349 skip_request_headers,
350 skip_response_headers,
351 follow_redirects,
352 max_redirects,
353 })
354 }
355}
356
357#[derive(Debug, Clone, UriConfig)]
364#[allow(dead_code)]
365#[uri_scheme = "http"]
366#[uri_config(
367 skip_impl,
368 metadata(
369 scheme = "http",
370 description = "HTTP client and server component",
371 producer,
372 consumer,
373 streaming
374 ),
375 crate = "camel_component_api"
376)]
377struct HttpEndpointUriConfig {
378 #[allow(dead_code)]
379 _base_url: String,
380
381 #[uri_param(
382 name = "httpMethod",
383 desc = "HTTP method. Defaults to CamelHttpMethod header or POST/GET"
384 )]
385 http_method: Option<String>,
386
387 #[uri_param(
388 name = "throwExceptionOnFailure",
389 default = "true",
390 desc = "Throw on non-2xx status"
391 )]
392 throw_exception_on_failure: bool,
393
394 #[uri_param(
395 name = "okStatusCodeRange",
396 default = "200-299",
397 desc = "Success status code range"
398 )]
399 ok_status_code_range: String,
400
401 #[uri_param(name = "responseTimeout", desc = "Response timeout in milliseconds")]
402 response_timeout: Option<u64>,
403
404 #[uri_param(
405 name = "allowInternal",
406 default = "false",
407 desc = "Allow private/internal network destinations (SSRF)"
408 )]
409 allow_internal: bool,
410
411 #[uri_param(name = "blockedHosts", desc = "Comma-separated blocked host list")]
412 blocked_hosts: Option<String>,
413
414 #[uri_param(
415 name = "maxBodySize",
416 default = "10485760",
417 desc = "Max request/response body bytes"
418 )]
419 max_body_size: u64,
420
421 #[uri_param(name = "readTimeout", desc = "Socket read timeout in milliseconds")]
422 read_timeout: Option<u64>,
423
424 #[uri_param(name = "maxResponseBytes", desc = "Max response body bytes")]
425 max_response_bytes: Option<u64>,
426
427 #[uri_param(
428 name = "authMethod",
429 kind = "enum:Basic,Bearer",
430 desc = "Authentication method"
431 )]
432 auth_method: Option<String>,
433
434 #[uri_param(name = "authUsername", secret, desc = "Basic auth username")]
435 auth_username: Option<String>,
436
437 #[uri_param(name = "authPassword", secret, desc = "Basic auth password")]
438 auth_password: Option<String>,
439
440 #[uri_param(name = "authBearerToken", secret, desc = "Bearer auth token")]
441 auth_bearer_token: Option<String>,
442
443 #[uri_param(name = "userAgent", desc = "User-Agent header")]
444 user_agent: Option<String>,
445
446 #[uri_param(
447 name = "bridgeEndpoint",
448 default = "false",
449 desc = "Bridge endpoint mode"
450 )]
451 bridge_endpoint: bool,
452
453 #[uri_param(
454 name = "connectionClose",
455 default = "false",
456 desc = "Send Connection: close"
457 )]
458 connection_close: bool,
459
460 #[uri_param(
461 name = "skipRequestHeaders",
462 desc = "Comma-separated request headers to skip"
463 )]
464 skip_request_headers: Option<String>,
465
466 #[uri_param(
467 name = "skipResponseHeaders",
468 desc = "Comma-separated response headers to skip"
469 )]
470 skip_response_headers: Option<String>,
471
472 #[uri_param(
473 name = "followRedirects",
474 default = "false",
475 desc = "Follow HTTP redirects"
476 )]
477 follow_redirects: bool,
478
479 #[uri_param(name = "maxRedirects", default = "10", desc = "Max redirect hops")]
480 max_redirects: u64,
481}
482
483impl HttpEndpointConfig {
484 pub fn metadata() -> ComponentMetadata {
487 HttpEndpointUriConfig::metadata()
488 }
489
490 pub fn uri_options() -> Vec<camel_api::component_metadata::UriOption> {
492 HttpEndpointUriConfig::uri_options()
493 }
494}
495
496fn parse_auth_from_params(params: &HashMap<String, String>) -> Result<HttpAuth, CamelError> {
497 let Some(method) = params.get("authMethod") else {
498 return Ok(HttpAuth::None);
499 };
500
501 if method.eq_ignore_ascii_case("none") {
502 return Ok(HttpAuth::None);
503 }
504
505 if method.eq_ignore_ascii_case("basic") {
506 let username = params.get("authUsername").cloned().ok_or_else(|| {
507 CamelError::InvalidUri("authUsername is required for authMethod=Basic".to_string())
508 })?;
509 let password = params.get("authPassword").cloned().ok_or_else(|| {
510 CamelError::InvalidUri("authPassword is required for authMethod=Basic".to_string())
511 })?;
512 return Ok(HttpAuth::Basic { username, password });
513 }
514
515 if method.eq_ignore_ascii_case("bearer") {
516 let token = params.get("authBearerToken").cloned().ok_or_else(|| {
517 CamelError::InvalidUri("authBearerToken is required for authMethod=Bearer".to_string())
518 })?;
519 return Ok(HttpAuth::Bearer { token });
520 }
521
522 Err(CamelError::InvalidUri(format!(
523 "invalid value for authMethod: {method} (expected None, Basic, or Bearer)"
524 )))
525}
526
527fn parse_bool_param_http(value: &str) -> Result<bool, CamelError> {
528 match value.to_ascii_lowercase().as_str() {
529 "true" | "1" | "yes" => Ok(true),
530 "false" | "0" | "no" => Ok(false),
531 _ => Err(CamelError::InvalidUri(format!(
532 "invalid boolean value: '{value}'"
533 ))),
534 }
535}
536
537impl HttpEndpointConfig {
538 pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
539 let parts = parse_uri(uri)?;
540 let mut endpoint = Self::from_components(parts.clone())?;
541 if endpoint.response_timeout.is_none() {
542 endpoint.response_timeout = Some(Duration::from_millis(config.response_timeout_ms));
543 }
544 if !parts.params.contains_key("allowInternal") {
545 endpoint.allow_internal = config.allow_internal;
546 }
547 if !parts.params.contains_key("blockedHosts") {
548 endpoint.blocked_hosts = config.blocked_hosts.clone();
549 }
550 if !parts.params.contains_key("maxBodySize") {
551 endpoint.max_body_size = config.max_body_size;
552 }
553 if !parts.params.contains_key("readTimeout") {
554 endpoint.read_timeout_ms = config.read_timeout_ms;
555 }
556 if !parts.params.contains_key("maxResponseBytes") {
557 endpoint.max_response_bytes = config.max_response_bytes;
558 }
559 if !parts.params.contains_key("okStatusCodeRange")
560 && let Some(range) = &config.ok_status_code_range
561 {
562 endpoint.ok_status_code_range = parse_ok_status_code_range(range)?;
563 }
564 if !parts.params.contains_key("followRedirects") {
565 endpoint.follow_redirects = config.follow_redirects;
566 }
567 if !parts.params.contains_key("maxRedirects") {
568 endpoint.max_redirects = config.max_redirects.unwrap_or(10);
569 }
570
571 Ok(endpoint)
572 }
573}
574
575#[derive(Debug, Clone)]
581pub struct HttpServerConfig {
582 pub scheme: String,
584 pub host: String,
586 pub port: u16,
588 pub path: String,
590 pub max_request_body: usize,
592 pub max_response_body: usize,
594 pub max_inflight_requests: usize,
596 pub method: Option<String>,
603 pub tls_config: Option<crate::config::ServerTlsConfig>,
606}
607
608impl UriConfig for HttpServerConfig {
609 fn scheme() -> &'static str {
611 "http"
612 }
613
614 fn from_uri(uri: &str) -> Result<Self, CamelError> {
615 let parts = parse_uri(uri)?;
616 Self::from_components(parts)
617 }
618
619 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
620 if parts.scheme != "http" && parts.scheme != "https" {
622 return Err(CamelError::InvalidUri(format!(
623 "expected scheme 'http' or 'https', got '{}'",
624 parts.scheme
625 )));
626 }
627
628 let authority_and_path = parts.path.trim_start_matches('/');
631
632 let (authority, path_suffix) = if let Some(idx) = authority_and_path.find('/') {
634 (&authority_and_path[..idx], &authority_and_path[idx..])
635 } else {
636 (authority_and_path, "/")
637 };
638
639 let path = if path_suffix.is_empty() {
640 "/"
641 } else {
642 path_suffix
643 }
644 .to_string();
645
646 let (host, port) = if let Some(colon) = authority.rfind(':') {
648 let port_str = &authority[colon + 1..];
649 match port_str.parse::<u16>() {
650 Ok(p) => (authority[..colon].to_string(), p),
651 Err(_) => {
652 return Err(CamelError::InvalidUri(format!(
653 "invalid port '{}' in authority",
654 port_str
655 )));
656 }
657 }
658 } else {
659 let default_port = if parts.scheme == "https" { 443 } else { 80 };
661 (authority.to_string(), default_port)
662 };
663
664 let max_request_body = parts
665 .params
666 .get("maxRequestBody")
667 .and_then(|v| v.parse::<usize>().ok())
668 .unwrap_or(2 * 1024 * 1024); let max_response_body = parts
671 .params
672 .get("maxResponseBody")
673 .and_then(|v| v.parse::<usize>().ok())
674 .unwrap_or(10 * 1024 * 1024); let max_inflight_requests = parts
677 .params
678 .get("maxInflightRequests")
679 .and_then(|v| v.parse::<usize>().ok())
680 .unwrap_or(1024);
681
682 let method = parts.params.get("httpMethod").map(|m| m.to_uppercase());
688
689 Ok(Self {
690 scheme: parts.scheme,
691 host,
692 port,
693 path,
694 max_request_body,
695 max_response_body,
696 max_inflight_requests,
697 method,
698 tls_config: {
699 let cert = parts.params.get("tlsCert").cloned();
700 let key = parts.params.get("tlsKey").cloned();
701 match (cert, key) {
702 (Some(c), Some(k)) => Some(crate::config::ServerTlsConfig {
703 cert_path: c,
704 key_path: k,
705 }),
706 (None, None) => None,
707 _ => None, }
709 },
710 })
711 }
712}
713
714impl HttpServerConfig {
715 pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
716 let parts = parse_uri(uri)?;
717 let mut server = Self::from_components(parts.clone())?;
718 if !parts.params.contains_key("maxRequestBody") {
719 server.max_request_body = config.max_request_body;
720 }
721 if !parts.params.contains_key("maxResponseBody") {
722 server.max_response_body = config.max_body_size;
724 }
725 Ok(server)
726 }
727}
728
729pub enum HttpReplyBody {
737 Bytes(bytes::Bytes),
738 Stream(BoxStream<'static, Result<bytes::Bytes, CamelError>>),
739}
740
741pub struct RequestEnvelope {
746 pub method: String,
747 pub path: String,
748 pub query: String,
749 pub headers: http::HeaderMap,
750 pub body: StreamBody,
751 pub path_params: std::collections::HashMap<String, String>,
757 pub reply_tx: tokio::sync::oneshot::Sender<HttpReply>,
758}
759
760pub struct HttpReply {
764 pub status: u16,
765 pub headers: Vec<(String, String)>,
766 pub body: HttpReplyBody,
767}
768
769type ServerKey = (String, u16);
774
775struct ServerHandle {
777 registry: HttpRouteRegistry,
778 bound_addr: std::net::SocketAddr,
781 max_request_body: usize,
782 max_response_body: usize,
783 max_inflight_requests: usize,
784 is_tls: bool,
785 tls_cert_path: Option<String>,
786 tls_key_path: Option<String>,
787 monitor_task: tokio::task::JoinHandle<()>,
790 tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
793 tls_source: Option<ServerTlsSource>,
794}
795
796#[derive(Default)]
799struct RegistryState {
800 entries: HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>,
801 staged: HashMap<ServerKey, tokio::net::TcpListener>,
802}
803
804pub struct ServerRegistry {
806 inner: Mutex<RegistryState>,
807}
808
809impl ServerRegistry {
810 pub fn global() -> &'static Self {
812 static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
813 INSTANCE.get_or_init(|| ServerRegistry {
814 inner: Mutex::new(RegistryState::default()),
815 })
816 }
817
818 #[allow(clippy::too_many_arguments)]
821 pub async fn get_or_spawn(
822 &'static self,
823 host: &str,
824 port: u16,
825 max_request_body: usize,
826 max_response_body: usize,
827 max_inflight_requests: usize,
828 runtime: Arc<dyn RuntimeObservability>,
829 route_id: String,
830 tls_config: Option<crate::config::ServerTlsConfig>,
831 ) -> Result<HttpRouteRegistry, CamelError> {
832 self.get_or_spawn_internal(
833 host,
834 port,
835 max_request_body,
836 max_response_body,
837 max_inflight_requests,
838 runtime,
839 route_id,
840 tls_config,
841 None,
842 )
843 .await
844 }
845
846 #[allow(clippy::too_many_arguments)]
853 pub async fn get_or_spawn_with_listener(
854 &'static self,
855 listener: tokio::net::TcpListener,
856 max_request_body: usize,
857 max_response_body: usize,
858 max_inflight_requests: usize,
859 runtime: Arc<dyn RuntimeObservability>,
860 route_id: String,
861 tls_config: Option<crate::config::ServerTlsConfig>,
862 ) -> Result<HttpRouteRegistry, CamelError> {
863 let addr = listener
864 .local_addr()
865 .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
866 self.get_or_spawn_internal(
867 &addr.ip().to_string(),
868 addr.port(),
869 max_request_body,
870 max_response_body,
871 max_inflight_requests,
872 runtime,
873 route_id,
874 tls_config,
875 Some(listener),
876 )
877 .await
878 }
879
880 pub async fn stage_listener(
887 &'static self,
888 listener: tokio::net::TcpListener,
889 ) -> Result<(), CamelError> {
890 let addr = listener
891 .local_addr()
892 .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
893 let host = addr.ip().to_string();
894 use std::collections::hash_map::Entry;
895 let mut guard = self.inner.lock().map_err(|_| {
896 CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
897 })?;
898 match guard.staged.entry((host.clone(), addr.port())) {
899 Entry::Occupied(_) => Err(CamelError::EndpointCreationFailed(format!(
900 "listener already staged for {host}:{}",
901 addr.port()
902 ))),
903 Entry::Vacant(slot) => {
904 slot.insert(listener);
905 Ok(())
906 }
907 }
908 }
909
910 pub fn bound_addr(&'static self, host: &str, port: u16) -> Option<std::net::SocketAddr> {
913 let guard = self.inner.lock().ok()?;
914 guard
915 .entries
916 .get(&(host.to_string(), port))
917 .and_then(|cell| cell.get())
918 .map(|handle| handle.bound_addr)
919 }
920
921 #[allow(clippy::too_many_arguments)]
922 async fn get_or_spawn_internal(
923 &'static self,
924 host: &str,
925 port: u16,
926 max_request_body: usize,
927 max_response_body: usize,
928 max_inflight_requests: usize,
929 runtime: Arc<dyn RuntimeObservability>,
930 route_id: String,
931 tls_config: Option<crate::config::ServerTlsConfig>,
932 provided: Option<tokio::net::TcpListener>,
933 ) -> Result<HttpRouteRegistry, CamelError> {
934 let host_owned = host.to_string();
935 let key = (host.to_string(), port);
936
937 let cell = {
938 let mut guard = self.inner.lock().map_err(|_| {
939 CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
940 })?;
941 if let Some(existing) = guard.entries.get(&key)
945 && let Some(handle) = existing.get()
946 && handle.monitor_task.is_finished()
947 {
948 if handle.is_tls {
951 let scheme = if handle.is_tls { "https" } else { "http" };
952 camel_component_api::tls_source::TlsReloadRegistry::global()
953 .unregister(scheme, host, port);
954 }
955 guard.entries.remove(&key);
956 }
957 guard
958 .entries
959 .entry(key)
960 .or_insert_with(|| Arc::new(OnceCell::new()))
961 .clone()
962 };
963
964 if let Some(existing) = cell.get()
965 && existing.max_request_body != max_request_body
966 {
967 return Err(CamelError::EndpointCreationFailed(format!(
968 "incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
969 existing.max_request_body, max_request_body
970 )));
971 }
972
973 if let Some(existing) = cell.get()
974 && existing.max_response_body != max_response_body
975 {
976 return Err(CamelError::EndpointCreationFailed(format!(
977 "incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
978 existing.max_response_body, max_response_body
979 )));
980 }
981
982 if let Some(existing) = cell.get()
983 && existing.max_inflight_requests != max_inflight_requests
984 {
985 return Err(CamelError::EndpointCreationFailed(format!(
986 "incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
987 existing.max_inflight_requests, max_inflight_requests
988 )));
989 }
990
991 if let Some(existing) = cell.get()
993 && existing.is_tls != tls_config.is_some()
994 {
995 return Err(CamelError::EndpointCreationFailed(format!(
996 "incompatible TLS mode for shared server (host={host}, port={port}): existing is_tls={}, new has_tls={}",
997 existing.is_tls,
998 tls_config.is_some()
999 )));
1000 }
1001
1002 if let (Some(existing), Some(new_tls)) = (cell.get(), &tls_config)
1004 && (existing.tls_cert_path.as_deref() != Some(&new_tls.cert_path)
1005 || existing.tls_key_path.as_deref() != Some(&new_tls.key_path))
1006 {
1007 return Err(CamelError::EndpointCreationFailed(format!(
1008 "incompatible TLS cert/key for shared server (host={host}, port={port}): routes on the same TLS port must use the same cert and key"
1009 )));
1010 }
1011
1012 let handle = cell
1013 .get_or_try_init(|| {
1014 let rt = Arc::clone(&runtime);
1015 let rid = route_id.clone();
1016 let key = (host_owned.clone(), port);
1017 async move {
1018 let source = match provided {
1027 Some(listener) => ListenerSource::Staged(listener),
1028 None => {
1029 let mut guard = self.inner.lock().map_err(|_| {
1030 CamelError::EndpointCreationFailed(
1031 "ServerRegistry lock poisoned".into(),
1032 )
1033 })?;
1034 match guard.staged.remove(&key) {
1035 Some(listener) => ListenerSource::Staged(listener),
1036 None => {
1040 if let Some((staged_host, _)) = guard
1041 .staged
1042 .keys()
1043 .find(|(_, staged_port)| *staged_port == port)
1044 {
1045 let staged_host = staged_host.clone();
1046 return Err(CamelError::EndpointCreationFailed(
1047 format!(
1048 "staged listener conflict on port {port}: staged under host {staged_host}, requested {host_owned}"
1049 ),
1050 ));
1051 }
1052 ListenerSource::Bind
1053 }
1054 }
1055 }
1056 };
1057 spawn_entry(
1058 key,
1059 source,
1060 max_request_body,
1061 max_response_body,
1062 max_inflight_requests,
1063 rt,
1064 rid,
1065 tls_config,
1066 )
1067 .await
1068 .and_then(|handle| {
1069 Arc::try_unwrap(handle).map_err(|_| {
1073 CamelError::EndpointCreationFailed(
1074 "spawned server handle has dangling clones".into(),
1075 )
1076 })
1077 })
1078 }
1079 })
1080 .await?;
1081
1082 Ok(handle.registry.clone())
1083 }
1084
1085 pub async fn unregister(&self, host: &str, port: u16) {
1089 debug!(
1090 host = host,
1091 port = port,
1092 "consumer unregistered from HTTP server"
1093 );
1094 }
1095
1096 #[cfg(test)]
1103 pub fn reset() {
1104 let instance = Self::global();
1105 let mut guard = instance
1106 .inner
1107 .lock()
1108 .expect("ServerRegistry lock poisoned during test reset");
1109 guard.entries.clear();
1110 guard.staged.clear();
1111 }
1112}
1113
1114enum ListenerSource {
1117 Bind,
1118 Staged(tokio::net::TcpListener),
1119}
1120
1121#[allow(clippy::too_many_arguments)]
1126async fn spawn_entry(
1127 key: ServerKey,
1128 source: ListenerSource,
1129 max_request_body: usize,
1130 max_response_body: usize,
1131 max_inflight_requests: usize,
1132 runtime: Arc<dyn RuntimeObservability>,
1133 route_id: String,
1134 tls_config: Option<crate::config::ServerTlsConfig>,
1135) -> Result<Arc<ServerHandle>, CamelError> {
1136 let rt = Arc::clone(&runtime);
1137 let rid = route_id.clone();
1138 let (host_owned, port) = key;
1139 let listener = match source {
1140 ListenerSource::Bind => {
1141 let addr = format!("{host_owned}:{port}");
1142 tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
1143 CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
1144 })?
1145 }
1146 ListenerSource::Staged(listener) => listener,
1147 };
1148 let bound_addr = listener
1149 .local_addr()
1150 .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
1151 let registry = HttpRouteRegistry::new();
1152 let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
1153 let tls_rustls_cfg: Option<axum_server::tls_rustls::RustlsConfig>;
1156 let tls_source: Option<ServerTlsSource>;
1157 let server_task = if let Some(ref tls) = tls_config {
1158 let rustls_config = load_tls_config(&tls.cert_path, &tls.key_path)?;
1159 let source = ServerTlsSource {
1160 cert_path: std::path::PathBuf::from(&tls.cert_path),
1161 key_path: std::path::PathBuf::from(&tls.key_path),
1162 client_ca_path: None,
1163 };
1164 let rustls_cfg =
1168 axum_server::tls_rustls::RustlsConfig::from_config(std::sync::Arc::new(rustls_config));
1169 tls_rustls_cfg = Some(rustls_cfg.clone());
1170 tls_source = Some(source);
1171 let std_listener = listener.into_std().map_err(|e| {
1173 CamelError::EndpointCreationFailed(format!("TLS listener conversion: {e}"))
1174 })?;
1175 tokio::spawn(run_axum_server_tls(
1176 std_listener,
1177 rustls_cfg,
1178 registry.clone(),
1179 max_request_body,
1180 max_response_body,
1181 Arc::clone(&inflight),
1182 Arc::clone(&rt),
1183 rid.clone(),
1184 ))
1185 } else {
1186 tls_rustls_cfg = None;
1187 tls_source = None;
1188 tokio::spawn(run_axum_server(
1189 listener,
1190 registry.clone(),
1191 max_request_body,
1192 max_response_body,
1193 Arc::clone(&inflight),
1194 Arc::clone(&rt),
1195 rid.clone(),
1196 ))
1197 };
1198 let addr_for_monitor = format!("{host_owned}:{port}");
1199 let monitor_task = tokio::spawn(monitor_axum_task(
1200 server_task,
1201 addr_for_monitor,
1202 Arc::clone(&rt),
1203 rid,
1204 ));
1205 let handle = ServerHandle {
1206 registry,
1207 bound_addr,
1208 max_request_body,
1209 max_response_body,
1210 max_inflight_requests,
1211 is_tls: tls_config.is_some(),
1212 tls_cert_path: tls_config.as_ref().map(|t| t.cert_path.clone()),
1213 tls_key_path: tls_config.as_ref().map(|t| t.key_path.clone()),
1214 monitor_task,
1215 tls_config: tls_rustls_cfg,
1216 tls_source,
1217 };
1218 if let (Some(tls_cfg), Some(source)) = (handle.tls_config.as_ref(), handle.tls_source.as_ref())
1223 {
1224 let handler = Arc::new(crate::tls_reload::HttpReloadHandler::new(
1225 tls_cfg.clone(),
1226 source.clone(),
1227 host_owned.clone(),
1228 port,
1229 ));
1230 camel_component_api::tls_source::TlsReloadRegistry::global().register(handler);
1231 }
1232 Ok(Arc::new(handle))
1233}
1234
1235use axum::{
1240 Router,
1241 body::Body as AxumBody,
1242 extract::{Request, State},
1243 http::{Response, StatusCode},
1244 response::IntoResponse,
1245};
1246
1247#[derive(Clone)]
1248pub(crate) struct AppState {
1249 registry: HttpRouteRegistry,
1250 max_request_body: usize,
1251 max_response_body: usize,
1252 inflight: Arc<tokio::sync::Semaphore>,
1253}
1254
1255const CONSUMER_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
1262
1263async fn run_axum_server(
1264 listener: tokio::net::TcpListener,
1265 registry: HttpRouteRegistry,
1266 max_request_body: usize,
1267 max_response_body: usize,
1268 inflight: Arc<tokio::sync::Semaphore>,
1269 runtime: Arc<dyn RuntimeObservability>,
1270 route_id: String,
1271) {
1272 let state = AppState {
1273 registry,
1274 max_request_body,
1275 max_response_body,
1276 inflight,
1277 };
1278 let app = Router::new()
1279 .fallback(dispatch_handler)
1280 .with_state(state)
1281 .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1282 StatusCode::REQUEST_TIMEOUT,
1283 CONSUMER_REQUEST_TIMEOUT,
1284 ));
1285
1286 axum::serve(listener, app).await.unwrap_or_else(|e| {
1287 runtime
1288 .metrics()
1289 .increment_errors(&route_id, "e:http:accept");
1290 tracing::error!(error = %e, "Axum server error");
1292 });
1293}
1294
1295#[allow(clippy::too_many_arguments)]
1296async fn run_axum_server_tls(
1297 listener: std::net::TcpListener,
1298 tls_cfg: axum_server::tls_rustls::RustlsConfig,
1299 registry: HttpRouteRegistry,
1300 max_request_body: usize,
1301 max_response_body: usize,
1302 inflight: Arc<tokio::sync::Semaphore>,
1303 runtime: Arc<dyn RuntimeObservability>,
1304 route_id: String,
1305) {
1306 let state = AppState {
1307 registry,
1308 max_request_body,
1309 max_response_body,
1310 inflight,
1311 };
1312 let app = Router::new()
1313 .fallback(dispatch_handler)
1314 .with_state(state)
1315 .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1316 StatusCode::REQUEST_TIMEOUT,
1317 CONSUMER_REQUEST_TIMEOUT,
1318 ));
1319
1320 let server = match axum_server::from_tcp_rustls(listener, tls_cfg) {
1325 Ok(server) => server,
1326 Err(e) => {
1327 runtime
1328 .metrics()
1329 .increment_errors(&route_id, "e:http:accept-tls");
1330 tracing::error!(error = %e, "Axum TLS server setup error");
1332 return;
1333 }
1334 };
1335
1336 server
1337 .serve(app.into_make_service())
1338 .await
1339 .unwrap_or_else(|e| {
1340 runtime
1341 .metrics()
1342 .increment_errors(&route_id, "e:http:accept-tls");
1343 tracing::error!(error = %e, "Axum TLS server error");
1345 });
1346}
1347
1348async fn monitor_axum_task(
1356 handle: tokio::task::JoinHandle<()>,
1357 addr: String,
1358 runtime: Arc<dyn RuntimeObservability>,
1359 route_id: String,
1360) {
1361 match handle.await {
1362 Ok(()) => {
1363 }
1365 Err(join_err) => {
1366 runtime
1367 .metrics()
1368 .increment_errors(&route_id, "e:http:server-task-exited");
1369 tracing::error!(
1371 addr = %addr,
1372 error = %join_err,
1373 "Axum server task exited unexpectedly — all routes on this port are now dead"
1374 );
1375 }
1376 }
1377}
1378
1379fn load_tls_config(
1382 cert_path: &str,
1383 key_path: &str,
1384) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
1385 use std::fs::File;
1386 use std::io::BufReader;
1387
1388 let cert_file = File::open(cert_path)
1389 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
1390 let key_file = File::open(key_path)
1391 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
1392
1393 let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
1394 .collect::<Result<Vec<_>, _>>()
1395 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
1396
1397 let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
1398 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
1399 .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
1400
1401 tokio_rustls::rustls::ServerConfig::builder()
1402 .with_no_client_auth()
1403 .with_single_cert(certs, key)
1404 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
1405}
1406
1407async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
1408 let path = req.uri().path().to_owned();
1409 let method = req.method().to_string();
1410
1411 let api_sender = {
1428 let inner = state.registry.inner.read().await;
1429 inner.api_routes.get(&path).cloned()
1430 }; let (rest_sender, path_params) = if api_sender.is_some() {
1433 (None, Default::default())
1435 } else {
1436 let inner = state.registry.inner.read().await;
1437 match rest_match::match_endpoint(&method, &path, &inner.rest_endpoints) {
1438 rest_match::MatchOutcome::Found(m) => (Some(m.payload), m.path_params),
1439 rest_match::MatchOutcome::Ambiguous => {
1440 tracing::warn!(
1446 method = %method,
1447 path = %path,
1448 "ambiguous REST template match — returning 500"
1449 );
1450 return Response::builder()
1451 .status(StatusCode::INTERNAL_SERVER_ERROR)
1452 .body(AxumBody::from("Internal Server Error"))
1453 .expect("infallible"); }
1455 rest_match::MatchOutcome::NotFound => (None, Default::default()),
1456 }
1457 }; let sender = api_sender.or(rest_sender);
1460
1461 if let Some(sender) = sender {
1462 let query = req.uri().query().unwrap_or("").to_string();
1463 let headers = req.headers().clone();
1464
1465 let content_length: Option<u64> = headers
1467 .get(http::header::CONTENT_LENGTH)
1468 .and_then(|v| v.to_str().ok())
1469 .and_then(|s| s.parse().ok());
1470
1471 if let Some(len) = content_length
1472 && len > state.max_request_body as u64
1473 {
1474 return Response::builder()
1475 .status(StatusCode::PAYLOAD_TOO_LARGE)
1476 .body(AxumBody::from("Request body exceeds configured limit"))
1477 .expect("infallible"); }
1479
1480 let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
1481 Ok(permit) => permit,
1482 Err(_) => {
1483 return Response::builder()
1484 .status(StatusCode::SERVICE_UNAVAILABLE)
1485 .body(AxumBody::from("Service Unavailable"))
1486 .expect("infallible"); }
1488 };
1489
1490 let content_type = headers
1496 .get(http::header::CONTENT_TYPE)
1497 .and_then(|v| v.to_str().ok())
1498 .map(|s| s.to_string());
1499
1500 let data_stream: BodyDataStream = req.into_body().into_data_stream();
1501 let max_body = state.max_request_body;
1502 let mut seen: u64 = 0;
1503 let capped_stream =
1504 data_stream
1505 .map_err(|e| CamelError::Io(e.to_string()))
1506 .map(move |chunk| match chunk {
1507 Ok(bytes) => {
1508 seen = seen.saturating_add(bytes.len() as u64);
1509 if seen > max_body as u64 {
1510 Err(CamelError::ProcessorError(format!(
1511 "Request body exceeds configured limit of {max_body} bytes"
1512 )))
1513 } else {
1514 Ok(bytes)
1515 }
1516 }
1517 Err(e) => Err(e),
1518 });
1519 let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(capped_stream);
1520
1521 let stream_body = StreamBody {
1522 stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
1523 metadata: StreamMetadata {
1524 size_hint: content_length,
1525 content_type,
1526 origin: None,
1527 },
1528 };
1529
1530 let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
1531 let envelope = RequestEnvelope {
1532 method,
1533 path,
1534 query,
1535 headers,
1536 body: stream_body,
1537 path_params,
1538 reply_tx,
1539 };
1540
1541 if sender.send(envelope).await.is_err() {
1542 return Response::builder()
1543 .status(StatusCode::SERVICE_UNAVAILABLE)
1544 .body(AxumBody::from("Consumer unavailable"))
1545 .expect("infallible"); }
1547
1548 match reply_rx.await {
1549 Ok(reply) => {
1550 let reply = match reply.body {
1551 HttpReplyBody::Bytes(b)
1552 if exceeds_max_response_body(b.len(), state.max_response_body) =>
1553 {
1554 HttpReply {
1555 status: 500,
1556 headers: vec![],
1557 body: HttpReplyBody::Bytes(bytes::Bytes::from(
1558 "Response body exceeds configured limit",
1559 )),
1560 }
1561 }
1562 _ => reply,
1563 };
1564
1565 let status =
1566 StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1567 let mut builder = Response::builder().status(status);
1568 for (k, v) in &reply.headers {
1569 builder = builder.header(k.as_str(), v.as_str());
1570 }
1571 match reply.body {
1572 HttpReplyBody::Bytes(b) => {
1573 builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
1574 Response::builder()
1575 .status(StatusCode::INTERNAL_SERVER_ERROR)
1576 .body(AxumBody::from("Invalid response headers from consumer"))
1577 .expect("infallible") })
1579 }
1580 HttpReplyBody::Stream(stream) => builder
1581 .body(AxumBody::from_stream(stream))
1582 .unwrap_or_else(|_| {
1583 Response::builder()
1584 .status(StatusCode::INTERNAL_SERVER_ERROR)
1585 .body(AxumBody::from("Invalid response headers from consumer"))
1586 .expect("infallible") }),
1588 }
1589 }
1590 Err(_) => Response::builder()
1591 .status(StatusCode::INTERNAL_SERVER_ERROR)
1592 .body(AxumBody::from("Pipeline error"))
1593 .expect("infallible"), }
1595 } else {
1596 static_dispatch::dispatch_static(&state, req, &path).await
1598 }
1599}
1600
1601fn exceeds_max_response_body(len: usize, max: usize) -> bool {
1602 len > max
1603}
1604
1605fn title_case_header(name: &str) -> String {
1606 name.split('-')
1607 .map(|part| {
1608 let mut chars = part.chars();
1609 match chars.next() {
1610 None => String::new(),
1611 Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
1612 }
1613 })
1614 .collect::<Vec<_>>()
1615 .join("-")
1616}
1617
1618pub(crate) struct HttpKernelAuth {
1633 pub(crate) plan: camel_api::security_policy::RouteSecurityPlan,
1634 pub(crate) providers: Arc<camel_auth::ProviderRegistry>,
1635}
1636
1637impl HttpKernelAuth {
1638 pub(crate) fn from_security_context(
1643 ctx: &camel_component_api::SecurityContext,
1644 ) -> Option<Self> {
1645 Some(Self {
1646 plan: ctx.plan.clone()?,
1647 providers: ctx.providers.clone()?,
1648 })
1649 }
1650}
1651
1652fn envelope_channel_capacity(max_inflight_requests: usize) -> usize {
1666 max_inflight_requests.max(1)
1667}
1668
1669pub struct HttpConsumer {
1670 config: HttpServerConfig,
1671 runtime: Arc<dyn RuntimeObservability>,
1673 kernel: Option<Arc<HttpKernelAuth>>,
1677}
1678
1679impl HttpConsumer {
1680 pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
1681 Self {
1682 config,
1683 runtime,
1684 kernel: None,
1685 }
1686 }
1687}
1688
1689#[async_trait::async_trait]
1690impl Consumer for HttpConsumer {
1691 async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
1692 use camel_component_api::{Body, Exchange, Message};
1693
1694 let registry = ServerRegistry::global()
1695 .get_or_spawn(
1696 &self.config.host,
1697 self.config.port,
1698 self.config.max_request_body,
1699 self.config.max_response_body,
1700 self.config.max_inflight_requests,
1701 self.runtime.clone(),
1702 ctx.route_id().to_string(),
1703 self.config.tls_config.clone(),
1704 )
1705 .await?;
1706
1707 let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(
1711 envelope_channel_capacity(self.config.max_inflight_requests),
1712 );
1713 if let Some(method) = self.config.method.clone() {
1720 let segments = rest_match::parse_path_template(&self.config.path);
1721 registry
1722 .register_rest_endpoint(method, segments, env_tx)
1723 .await;
1724 } else {
1725 registry
1726 .register_api_route(self.config.path.clone(), env_tx)
1727 .await;
1728 }
1729
1730 ctx.mark_ready();
1739
1740 let path = self.config.path.clone();
1741 let registry_for_cleanup = registry.clone();
1742 let cancel_token = ctx.cancel_token();
1743 let kernel = self.kernel.clone();
1744 loop {
1745 tokio::select! {
1746 _ = ctx.cancelled() => {
1747 break;
1748 }
1749 envelope = env_rx.recv() => {
1750 let Some(envelope) = envelope else { break; };
1751
1752 let mut msg = Message::default();
1754
1755 msg.set_header("CamelHttpMethod",
1757 serde_json::Value::String(envelope.method.clone()));
1758 msg.set_header("CamelHttpPath",
1759 serde_json::Value::String(envelope.path.clone()));
1760 msg.set_header("CamelHttpQuery",
1761 serde_json::Value::String(envelope.query.clone()));
1762
1763 for (param_name, param_value) in &envelope.path_params {
1770 msg.set_header(
1771 format!("CamelHttpPath_{param_name}"),
1772 serde_json::Value::String(param_value.clone()),
1773 );
1774 }
1775
1776 for (k, v) in &envelope.headers {
1778 if let Ok(val_str) = v.to_str() {
1779 msg.set_header(
1780 title_case_header(k.as_str()),
1781 serde_json::Value::String(val_str.to_string()),
1782 );
1783 }
1784 }
1785
1786 msg.body = Body::Stream(envelope.body);
1789
1790 #[allow(unused_mut)]
1791 let mut exchange = Exchange::new(msg);
1792
1793 #[cfg(feature = "otel")]
1795 {
1796 let headers: HashMap<String, String> = envelope
1797 .headers
1798 .iter()
1799 .filter_map(|(k, v)| {
1800 Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1801 })
1802 .collect();
1803 camel_otel::extract_into_exchange(&mut exchange, &headers);
1804 }
1805
1806 let reply_tx = envelope.reply_tx;
1807 let sender = ctx.sender().clone();
1808 let path_clone = path.clone();
1809 let cancel = cancel_token.clone();
1810 let auth_headers = envelope.headers.clone();
1814 let auth_uri: http::Uri = {
1815 let full = if envelope.query.is_empty() {
1816 envelope.path.clone()
1817 } else {
1818 format!("{}?{}", envelope.path, envelope.query)
1819 };
1820 full.parse().unwrap_or_default()
1824 };
1825 let kernel = kernel.clone();
1826
1827 tokio::spawn(async move {
1847 if cancel.is_cancelled() {
1855 let _ = reply_tx.send(HttpReply {
1856 status: 503,
1857 headers: vec![],
1858 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
1859 });
1860 return;
1861 }
1862
1863 if let Some(kernel) = kernel.as_ref()
1872 && !matches!(
1873 kernel.plan.access_mode,
1874 camel_api::security_policy::AccessMode::Public
1875 )
1876 {
1877 let principal = match camel_auth::extract_token_multi(
1878 &auth_headers,
1879 &auth_uri,
1880 &kernel.plan.credential_sources,
1881 ) {
1882 Some(extracted) => {
1883 match camel_auth::kernel_authenticate(
1884 &kernel.plan,
1885 &kernel.providers,
1886 &extracted,
1887 )
1888 .await
1889 {
1890 Ok(principal) => principal,
1891 Err(e) => {
1892 tracing::warn!(
1894 path = %path_clone,
1895 error = %e,
1896 "HTTP request authentication failed"
1897 );
1898 let _ = reply_tx.send(pipeline_error_to_reply(
1899 e,
1900 &path_clone,
1901 ));
1902 return;
1903 }
1904 }
1905 }
1906 None => {
1907 tracing::warn!(
1909 path = %path_clone,
1910 "HTTP request rejected: no credential found in any source"
1911 );
1912 let _ = reply_tx.send(pipeline_error_to_reply(
1913 CamelError::Unauthenticated(
1914 "no credential found in any source".to_string(),
1915 ),
1916 &path_clone,
1917 ));
1918 return;
1919 }
1920 };
1921 camel_auth::install_carrier(&mut exchange, &principal);
1922 }
1923
1924 let (tx, rx) = tokio::sync::oneshot::channel();
1926 let envelope = camel_component_api::consumer::ExchangeEnvelope {
1927 exchange,
1928 reply_tx: Some(tx),
1929 };
1930
1931 let result = match sender.send(envelope).await {
1932 Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
1933 Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
1934 }
1935 .and_then(|r| r);
1936
1937 let reply = match result {
1938 Ok(out) => {
1939 let status = out
1940 .input
1941 .header("CamelHttpResponseCode")
1942 .and_then(|v| {
1943 let raw = v.as_u64()
1944 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
1945 let code = raw as u16;
1946 (100..1000).contains(&code).then_some(code)
1947 })
1948 .unwrap_or(200);
1949
1950 let user_content_type = out
1951 .input
1952 .header("Content-Type")
1953 .and_then(|v| v.as_str().map(|s| s.to_string()));
1954
1955 let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
1956 Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
1957 Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
1958 Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
1959 Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
1960 v.to_string().into_bytes(),
1961 )), Some("application/json".to_string())),
1962 Body::Stream(s) => {
1963 let ct = s.metadata.content_type.clone();
1964 match s.stream.lock().await.take() {
1965 Some(stream) => (
1966 HttpReplyBody::Stream(stream),
1967 ct,
1968 ),
1969 None => {
1970 tracing::error!(
1972 "Body::Stream already consumed before HTTP reply — returning 500"
1973 );
1974 let error_reply = HttpReply {
1975 status: 500,
1976 headers: vec![],
1977 body: HttpReplyBody::Bytes(bytes::Bytes::new()),
1978 };
1979 if reply_tx.send(error_reply).is_err() {
1980 debug!("reply_tx dropped before error reply could be sent");
1981 }
1982 return;
1983 }
1984 }
1985 }
1986 _ => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
1988 };
1989
1990 let resp_headers = select_response_headers(
1991 &out.input.headers,
1992 user_content_type,
1993 inferred_content_type,
1994 );
1995
1996 HttpReply {
1997 status,
1998 headers: resp_headers,
1999 body: reply_body,
2000 }
2001 }
2002 Err(e) => {
2003 pipeline_error_to_reply(e, &path_clone)
2004 }
2005 };
2006
2007 let _ = reply_tx.send(reply);
2009 });
2010 }
2011 }
2012 }
2013
2014 if let Some(method) = &self.config.method {
2019 registry_for_cleanup
2020 .unregister_rest_endpoint(method, &path)
2021 .await;
2022 } else {
2023 registry_for_cleanup.unregister_api_route(&path).await;
2024 }
2025
2026 ServerRegistry::global()
2030 .unregister(&self.config.host, self.config.port)
2031 .await;
2032
2033 Ok(())
2034 }
2035
2036 async fn stop(&mut self) -> Result<(), CamelError> {
2037 Ok(())
2038 }
2039
2040 fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
2041 camel_component_api::ConcurrencyModel::Concurrent { max: None }
2042 }
2043
2044 fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
2051 camel_component_api::ConsumerStartupMode::Explicit
2052 }
2053
2054 fn set_security_context(&mut self, ctx: camel_component_api::SecurityContext) {
2057 self.kernel = HttpKernelAuth::from_security_context(&ctx).map(Arc::new);
2058 }
2059}
2060
2061pub struct HttpComponent {
2066 config: HttpConfig,
2067 pinned_cache: std::sync::Arc<PinnedClientCache>,
2068 client: reqwest::Client,
2069}
2070
2071#[cfg(test)]
2072thread_local! {
2073 static BUILD_CLIENT_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
2074}
2075
2076pub(crate) fn build_client(
2077 config: &HttpConfig,
2078 resolve_override: Option<(&str, &[std::net::SocketAddr])>,
2079) -> reqwest::Client {
2080 #[cfg(test)]
2081 BUILD_CLIENT_CALLS.with(|c| c.set(c.get() + 1));
2082
2083 let mut builder = reqwest::Client::builder()
2084 .no_proxy() .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
2086 .pool_max_idle_per_host(config.pool_max_idle_per_host)
2087 .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
2088
2089 builder = builder.redirect(reqwest::redirect::Policy::none());
2093
2094 if let Some((host, addrs)) = resolve_override {
2095 builder = builder.resolve_to_addrs(host, addrs);
2096 }
2097
2098 if let Some(tls) = &config.tls
2099 && tls.enabled
2100 {
2101 if tls.insecure || !tls.verify_peer {
2102 tracing::warn!("HTTP TLS verification disabled — connections are vulnerable to MitM");
2104 builder = builder.danger_accept_invalid_certs(true);
2105 }
2106
2107 if let Some(ca_path) = &tls.ca_cert_path {
2108 match std::fs::read(ca_path) {
2113 Ok(ca_bytes) => {
2114 match reqwest::Certificate::from_pem(&ca_bytes)
2115 .or_else(|_| reqwest::Certificate::from_der(&ca_bytes))
2116 {
2117 Ok(ca_cert) => {
2118 builder = builder.add_root_certificate(ca_cert);
2119 }
2120 Err(e) => {
2121 tracing::warn!(
2123 error = %e,
2124 "configured CA certificate failed to parse — falling back to system roots"
2125 );
2126 }
2127 }
2128 }
2129 Err(e) => {
2130 tracing::warn!(
2132 error = %e,
2133 "configured CA certificate file unreadable — falling back to system roots"
2134 );
2135 }
2136 }
2137 }
2138
2139 if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path) {
2143 match (std::fs::read(cert_path), std::fs::read(key_path)) {
2144 (Ok(cert_bytes), Ok(key_bytes)) => {
2145 let mut identity_pem = cert_bytes;
2146 identity_pem.extend_from_slice(&key_bytes);
2147 match reqwest::Identity::from_pem(&identity_pem) {
2148 Ok(identity) => {
2149 builder = builder.identity(identity);
2150 }
2151 Err(e) => {
2152 tracing::warn!(
2154 error = %e,
2155 "configured mTLS identity failed to parse — client certificate NOT used"
2156 );
2157 }
2158 }
2159 }
2160 (cert_r, key_r) => {
2161 tracing::warn!(
2163 cert_ok = cert_r.is_ok(),
2164 key_ok = key_r.is_ok(),
2165 "configured mTLS cert/key file unreadable — client certificate NOT used"
2166 );
2167 }
2168 }
2169 }
2170 }
2171
2172 builder
2173 .build()
2174 .expect("reqwest::Client::build() with valid config should not fail") }
2176
2177#[cfg(test)]
2178pub(crate) fn build_client_call_count() -> u64 {
2179 BUILD_CLIENT_CALLS.with(|c| c.get())
2180}
2181
2182impl HttpComponent {
2183 pub fn new() -> Self {
2184 let config = HttpConfig::default();
2185 Self {
2186 client: build_client(&config, None),
2187 config,
2188 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2189 PINNED_CLIENT_TTL,
2190 PINNED_CLIENT_MAX_ENTRIES,
2191 )),
2192 }
2193 }
2194
2195 pub fn with_config(config: HttpConfig) -> Self {
2196 Self {
2197 client: build_client(&config, None),
2198 config,
2199 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2200 PINNED_CLIENT_TTL,
2201 PINNED_CLIENT_MAX_ENTRIES,
2202 )),
2203 }
2204 }
2205
2206 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2207 match config {
2208 Some(cfg) => Self::with_config(cfg),
2209 None => Self::new(),
2210 }
2211 }
2212}
2213
2214impl Default for HttpComponent {
2215 fn default() -> Self {
2216 Self::new()
2217 }
2218}
2219
2220impl Component for HttpComponent {
2221 fn scheme(&self) -> &str {
2222 "http"
2223 }
2224
2225 fn metadata(&self) -> ComponentMetadata {
2226 HttpEndpointConfig::metadata()
2227 }
2228
2229 fn create_endpoint(
2230 &self,
2231 uri: &str,
2232 ctx: &dyn camel_component_api::ComponentContext,
2233 ) -> Result<Box<dyn Endpoint>, CamelError> {
2234 self.config.validate()?;
2235 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2236 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2237 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2238 server_config.host.clone(),
2239 server_config.port,
2240 )));
2241 self.pinned_cache
2242 .wire(HttpComponentKind::Http, ctx.metrics());
2243 Ok(Box::new(HttpEndpoint {
2244 uri: uri.to_string(),
2245 config,
2246 server_config,
2247 client: self.client.clone(),
2248 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2249 http_config: self.config.clone(),
2250 }))
2251 }
2252}
2253
2254pub struct HttpsComponent {
2255 config: HttpConfig,
2256 pinned_cache: std::sync::Arc<PinnedClientCache>,
2257 client: reqwest::Client,
2258}
2259
2260impl HttpsComponent {
2261 pub fn new() -> Self {
2262 let config = HttpConfig::default();
2263 Self {
2264 client: build_client(&config, None),
2265 config,
2266 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2267 PINNED_CLIENT_TTL,
2268 PINNED_CLIENT_MAX_ENTRIES,
2269 )),
2270 }
2271 }
2272
2273 pub fn with_config(config: HttpConfig) -> Self {
2274 Self {
2275 client: build_client(&config, None),
2276 config,
2277 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2278 PINNED_CLIENT_TTL,
2279 PINNED_CLIENT_MAX_ENTRIES,
2280 )),
2281 }
2282 }
2283
2284 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2285 match config {
2286 Some(cfg) => Self::with_config(cfg),
2287 None => Self::new(),
2288 }
2289 }
2290}
2291
2292impl Default for HttpsComponent {
2293 fn default() -> Self {
2294 Self::new()
2295 }
2296}
2297
2298impl Component for HttpsComponent {
2299 fn scheme(&self) -> &str {
2300 "https"
2301 }
2302
2303 fn metadata(&self) -> ComponentMetadata {
2304 let mut meta = HttpEndpointConfig::metadata();
2307 meta.scheme = "https".to_string();
2308 meta.description = "HTTPS client and server component (TLS over HTTP)".to_string();
2309 meta
2310 }
2311
2312 fn create_endpoint(
2313 &self,
2314 uri: &str,
2315 ctx: &dyn camel_component_api::ComponentContext,
2316 ) -> Result<Box<dyn Endpoint>, CamelError> {
2317 self.config.validate()?;
2318 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2319 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2320 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2321 server_config.host.clone(),
2322 server_config.port,
2323 )));
2324 self.pinned_cache
2325 .wire(HttpComponentKind::Https, ctx.metrics());
2326 Ok(Box::new(HttpEndpoint {
2327 uri: uri.to_string(),
2328 config,
2329 server_config,
2330 client: self.client.clone(),
2331 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2332 http_config: self.config.clone(),
2333 }))
2334 }
2335}
2336
2337struct HttpEndpoint {
2342 uri: String,
2343 config: HttpEndpointConfig,
2344 server_config: HttpServerConfig,
2345 client: reqwest::Client,
2346 pinned_cache: std::sync::Arc<PinnedClientCache>,
2347 http_config: HttpConfig,
2348}
2349
2350impl Endpoint for HttpEndpoint {
2351 fn uri(&self) -> &str {
2352 &self.uri
2353 }
2354
2355 fn create_consumer(
2356 &self,
2357 rt: Arc<dyn camel_component_api::RuntimeObservability>,
2358 ) -> Result<Box<dyn Consumer>, CamelError> {
2359 let scheme_is_https = self.server_config.scheme == "https";
2362 let has_tls = self.server_config.tls_config.is_some();
2363
2364 if scheme_is_https && !has_tls {
2365 return Err(CamelError::EndpointCreationFailed(
2366 "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
2367 ));
2368 }
2369 if !scheme_is_https && has_tls {
2370 return Err(CamelError::EndpointCreationFailed(
2371 "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
2372 ));
2373 }
2374 Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
2375 }
2376
2377 fn create_producer(
2378 &self,
2379 rt: Arc<dyn camel_component_api::RuntimeObservability>,
2380 _ctx: &ProducerContext,
2381 ) -> Result<BoxProcessor, CamelError> {
2382 let producer = HttpProducer {
2383 config: Arc::new(self.config.clone()),
2384 client: self.client.clone(),
2385 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2386 http_config: Arc::new(self.http_config.clone()),
2387 runtime: rt,
2388 };
2389 if let Some(ref provider) = self.config.token_provider {
2390 let layer = BearerTokenLayer::new(Arc::clone(provider));
2391 Ok(BoxProcessor::new(layer.layer(producer)))
2392 } else {
2393 Ok(BoxProcessor::new(producer))
2394 }
2395 }
2396}
2397
2398#[derive(Clone)]
2403struct HttpProducer {
2404 config: Arc<HttpEndpointConfig>,
2405 client: reqwest::Client,
2406 pinned_cache: std::sync::Arc<PinnedClientCache>,
2407 http_config: Arc<HttpConfig>,
2408 runtime: Arc<dyn RuntimeObservability>,
2414}
2415
2416impl HttpProducer {
2417 fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
2418 if let Some(ref method) = config.http_method {
2419 return method.to_uppercase();
2420 }
2421 if let Some(method) = exchange
2422 .input
2423 .header("CamelHttpMethod")
2424 .and_then(|v| v.as_str())
2425 {
2426 return method.to_uppercase();
2427 }
2428 if !exchange.input.body.is_empty() {
2429 return "POST".to_string();
2430 }
2431 "GET".to_string()
2432 }
2433
2434 fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
2435 if config.bridge_endpoint {
2441 let url = config.base_url.clone();
2442 if config.query_params.is_empty() {
2443 return url;
2444 }
2445 let mut parsed = url::Url::parse(&url).expect("base URL must be valid"); for (k, v) in &config.query_params {
2447 parsed.query_pairs_mut().append_pair(k, v);
2448 }
2449 return parsed.to_string();
2450 }
2451
2452 if let Some(uri) = exchange
2453 .input
2454 .header("CamelHttpUri")
2455 .and_then(|v| v.as_str())
2456 {
2457 let mut url = uri.to_string();
2458 if let Some(path) = exchange
2459 .input
2460 .header("CamelHttpPath")
2461 .and_then(|v| v.as_str())
2462 {
2463 if !url.ends_with('/') && !path.starts_with('/') {
2464 url.push('/');
2465 }
2466 url.push_str(path);
2467 }
2468 if let Some(query) = exchange
2469 .input
2470 .header("CamelHttpQuery")
2471 .and_then(|v| v.as_str())
2472 {
2473 url.push('?');
2474 url.push_str(query);
2475 }
2476 return url;
2477 }
2478
2479 let mut url = config.base_url.clone();
2480
2481 if let Some(path) = exchange
2482 .input
2483 .header("CamelHttpPath")
2484 .and_then(|v| v.as_str())
2485 {
2486 if !url.ends_with('/') && !path.starts_with('/') {
2487 url.push('/');
2488 }
2489 url.push_str(path);
2490 }
2491
2492 if let Some(query) = exchange
2493 .input
2494 .header("CamelHttpQuery")
2495 .and_then(|v| v.as_str())
2496 {
2497 url.push('?');
2498 url.push_str(query);
2499 } else if !config.query_params.is_empty() {
2500 let mut parsed = url::Url::parse(&url).expect("base URL must be valid"); for (k, v) in &config.query_params {
2502 parsed.query_pairs_mut().append_pair(k, v);
2503 }
2504 url = parsed.to_string();
2505 }
2506
2507 url
2508 }
2509
2510 fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
2511 status >= range.0 && status <= range.1
2512 }
2513}
2514
2515fn redact_url_for_diagnostics(raw: &str) -> String {
2521 const MAX_URL_LOG_LEN: usize = 256;
2522 match url::Url::parse(raw) {
2523 Ok(mut u) => {
2524 if !u.username().is_empty() {
2525 let _ = u.set_username("***");
2526 let _ = u.set_password(None);
2527 }
2528 if u.query().is_some() {
2529 u.set_query(None);
2530 let mut s = u.to_string();
2532 if let Some(stripped) = s.strip_suffix('?') {
2533 s = stripped.to_string();
2534 }
2535 s.push_str("?[redacted]");
2536 if s.len() > MAX_URL_LOG_LEN {
2537 s.truncate(MAX_URL_LOG_LEN);
2538 }
2539 return s;
2540 }
2541 let mut s = u.to_string();
2542 if s.len() > MAX_URL_LOG_LEN {
2543 s.truncate(MAX_URL_LOG_LEN);
2544 }
2545 s
2546 }
2547 Err(_) => {
2548 let mut s = raw.to_string();
2549 s.truncate(MAX_URL_LOG_LEN);
2550 s
2551 }
2552 }
2553}
2554
2555const MAX_ERROR_RESPONSE_BODY_BYTES: usize = 4096;
2560
2561fn truncate_error_body(body: &[u8]) -> String {
2562 if body.len() <= MAX_ERROR_RESPONSE_BODY_BYTES {
2563 String::from_utf8_lossy(body).into_owned()
2564 } else {
2565 let mut s = String::from_utf8_lossy(&body[..MAX_ERROR_RESPONSE_BODY_BYTES]).into_owned();
2566 s.push_str("...[truncated]");
2567 s
2568 }
2569}
2570
2571impl HttpProducer {
2572 fn is_entity_enclosing(method: &str) -> bool {
2577 matches!(method, "POST" | "PUT" | "PATCH")
2578 }
2579}
2580
2581impl Service<Exchange> for HttpProducer {
2582 type Response = Exchange;
2583 type Error = CamelError;
2584 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
2585
2586 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
2587 Poll::Ready(Ok(()))
2588 }
2589
2590 fn call(&mut self, exchange: Exchange) -> Self::Future {
2591 let config = self.config.clone();
2592 let shared_client = self.client.clone();
2593 let pinned_cache = std::sync::Arc::clone(&self.pinned_cache);
2594 let http_config = self.http_config.clone();
2595 let component_metrics = self.runtime.component_metrics();
2596
2597 Box::pin(async move {
2598 let mut exchange = exchange;
2599 let outcome = async {
2600 let method_str = HttpProducer::resolve_method(&exchange, &config);
2601 let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
2606 let url = HttpProducer::resolve_url(&exchange, &config);
2607
2608 ssrf::validate_url_for_ssrf(&url, &config)?;
2610
2611 let resolved =
2619 ssrf::resolve_initial_url_for_ssrf(&url, config.allow_internal).await?;
2620 let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
2621 pinned_cache
2622 .get_or_build(host.as_str(), addrs, || {
2623 build_client(&http_config, Some((host.as_str(), addrs)))
2624 })
2625 .await
2626 } else {
2627 shared_client.clone()
2628 };
2629
2630 debug!(
2631 correlation_id = %exchange.correlation_id(),
2632 method = %method_str,
2633 url = %redact_url_for_diagnostics(&url),
2634 "HTTP request"
2635 );
2636
2637 let method = method_str.parse::<reqwest::Method>().map_err(|e| {
2638 CamelError::ProcessorError(format!(
2639 "Invalid HTTP method '{}': {}",
2640 method_str, e
2641 ))
2642 })?;
2643
2644 let mut collected_headers: Vec<(
2646 reqwest::header::HeaderName,
2647 reqwest::header::HeaderValue,
2648 )> = Vec::new();
2649
2650 if let Some(user_agent) = &config.user_agent
2651 && !config.bridge_endpoint
2652 && let Ok(val) = reqwest::header::HeaderValue::from_str(user_agent)
2653 {
2654 collected_headers.push((reqwest::header::USER_AGENT, val));
2655 }
2656
2657 #[cfg(feature = "otel")]
2659 let should_inject_otel = !config.bridge_endpoint;
2660 #[cfg(feature = "otel")]
2661 if should_inject_otel {
2662 let mut otel_headers = HashMap::new();
2663 camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
2664 for (k, v) in otel_headers {
2665 if let (Ok(name), Ok(val)) = (
2666 reqwest::header::HeaderName::from_bytes(k.as_bytes()),
2667 reqwest::header::HeaderValue::from_str(&v),
2668 ) {
2669 collected_headers.push((name, val));
2670 }
2671 }
2672 }
2673
2674 let conn_tokens = header_policy::connection_tokens(
2675 exchange
2676 .input
2677 .headers
2678 .iter()
2679 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
2680 .filter_map(|(_, v)| v.as_str()),
2681 );
2682
2683 for (key, value) in &exchange.input.headers {
2684 if !key.starts_with("Camel")
2685 && !config
2686 .skip_request_headers
2687 .iter()
2688 .any(|h| h.eq_ignore_ascii_case(key))
2689 && !header_policy::excluded_outbound(key, &conn_tokens)
2690 && let Some(val_str) = value.as_str()
2691 && let (Ok(name), Ok(val)) = (
2692 reqwest::header::HeaderName::from_bytes(key.as_bytes()),
2693 reqwest::header::HeaderValue::from_str(val_str),
2694 )
2695 {
2696 collected_headers.push((name, val));
2697 }
2698 }
2699
2700 if !config.bridge_endpoint {
2702 match &config.auth {
2703 HttpAuth::None => {}
2704 HttpAuth::Basic { username, password } => {
2705 use base64::Engine;
2706 let credentials = format!("{username}:{password}");
2708 let encoded =
2709 base64::engine::general_purpose::STANDARD.encode(credentials);
2710 if let Ok(val) =
2711 reqwest::header::HeaderValue::from_str(&format!("Basic {encoded}"))
2712 {
2713 collected_headers.push((reqwest::header::AUTHORIZATION, val));
2714 }
2715 }
2716 HttpAuth::Bearer { token } => {
2717 let bearer = format!("Bearer {token}");
2719 if let Ok(val) = reqwest::header::HeaderValue::from_str(&bearer) {
2720 collected_headers.push((reqwest::header::AUTHORIZATION, val));
2721 }
2722 }
2723 }
2724
2725 if config.connection_close
2726 && let Ok(val) = reqwest::header::HeaderValue::from_str("close")
2727 {
2728 collected_headers.push((reqwest::header::CONNECTION, val));
2729 }
2730 }
2731
2732 let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
2734 let materialized_body: Option<Vec<u8>> = if is_stream_body {
2735 if suppress_body {
2736 std::mem::take(&mut exchange.input.body);
2744 tracing::warn!(
2746 correlation_id = %exchange.correlation_id(),
2747 method = %method_str,
2748 "dropping request body for non-entity-enclosing HTTP method"
2749 );
2750 }
2751 None } else {
2753 let body = std::mem::take(&mut exchange.input.body);
2754 let bytes = body.into_bytes(config.max_body_size).await?;
2755 if bytes.is_empty() {
2756 None
2758 } else if suppress_body {
2759 tracing::warn!(
2761 correlation_id = %exchange.correlation_id(),
2762 method = %method_str,
2763 "dropping request body for non-entity-enclosing HTTP method"
2764 );
2765 None
2766 } else {
2767 Some(bytes.to_vec())
2768 }
2769 };
2770
2771 let response = if config.follow_redirects && !is_stream_body {
2772 ssrf::send_with_ssrf_safe_redirects(
2778 &client,
2779 &shared_client,
2780 &pinned_cache,
2781 &http_config,
2782 &config,
2783 method,
2784 &url,
2785 collected_headers,
2786 materialized_body,
2787 config.max_redirects,
2788 config.response_timeout,
2789 )
2790 .await?
2791 } else {
2792 let mut request = client.request(method, &url);
2794
2795 if let Some(timeout) = config.response_timeout {
2796 request = request.timeout(timeout);
2797 }
2798
2799 for (name, value) in &collected_headers {
2800 request = request.header(name, value);
2801 }
2802
2803 if is_stream_body {
2804 if let Body::Stream(ref s) = exchange.input.body {
2805 let mut stream_lock = s.stream.lock().await;
2806 if let Some(stream) = stream_lock.take() {
2807 request = request.body(reqwest::Body::wrap_stream(stream));
2808 } else {
2809 return Err(CamelError::AlreadyConsumed);
2810 }
2811 }
2812 } else if let Some(ref body_bytes) = materialized_body {
2813 request = request.body(body_bytes.clone());
2814 }
2815
2816 request.send().await.map_err(|e| {
2817 CamelError::ProcessorError(format!("HTTP request failed: {e}"))
2818 })?
2819 };
2820
2821 let status_code = response.status().as_u16();
2822 let status_text = response
2823 .status()
2824 .canonical_reason()
2825 .unwrap_or("Unknown")
2826 .to_string();
2827
2828 for (key, value) in response.headers() {
2829 if config
2830 .skip_response_headers
2831 .iter()
2832 .any(|h| h.eq_ignore_ascii_case(key.as_str()))
2833 {
2834 continue;
2835 }
2836 if let Ok(val_str) = value.to_str() {
2837 exchange.input.set_header(
2838 title_case_header(key.as_str()),
2839 serde_json::Value::String(val_str.to_string()),
2840 );
2841 }
2842 }
2843
2844 exchange.input.set_header(
2845 "CamelHttpResponseCode",
2846 serde_json::Value::Number(status_code.into()),
2847 );
2848 exchange.input.set_header(
2849 "CamelHttpResponseText",
2850 serde_json::Value::String(status_text.clone()),
2851 );
2852
2853 let read_timeout = Duration::from_millis(config.read_timeout_ms);
2855 let response_body = tokio::time::timeout(read_timeout, async {
2856 if let Some(content_len) = response.content_length()
2858 && content_len > config.max_response_bytes as u64
2859 {
2860 return Err(CamelError::ProcessorError(format!(
2861 "Response body too large: {} bytes exceeds limit of {} bytes",
2862 content_len, config.max_response_bytes
2863 )));
2864 }
2865 use futures::TryStreamExt;
2867 let mut stream = response.bytes_stream();
2868 let mut total: usize = 0;
2869 let mut collected = Vec::new();
2870 while let Some(chunk) = stream.try_next().await.map_err(|e| {
2871 CamelError::ProcessorError(format!("Failed to read response body: {e}"))
2872 })? {
2873 total += chunk.len();
2874 if total > config.max_response_bytes {
2875 return Err(CamelError::ProcessorError(format!(
2876 "Response body too large: {} bytes exceeds limit of {} bytes",
2877 total, config.max_response_bytes
2878 )));
2879 }
2880 collected.push(chunk);
2881 }
2882 let mut result = bytes::BytesMut::with_capacity(total);
2883 for chunk in collected {
2884 result.extend_from_slice(&chunk);
2885 }
2886 Ok::<bytes::Bytes, CamelError>(result.freeze())
2887 })
2888 .await
2889 .map_err(|_| {
2890 CamelError::ProcessorError(format!(
2891 "Read timeout after {}ms",
2892 config.read_timeout_ms
2893 ))
2894 })??;
2895
2896 if config.throw_exception_on_failure
2897 && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
2898 {
2899 return Err(CamelError::HttpOperationFailed {
2900 method: method_str,
2901 url: redact_url_for_diagnostics(&url),
2904 status_code,
2905 status_text,
2906 response_body: Some(truncate_error_body(&response_body)),
2907 });
2908 }
2909
2910 if !response_body.is_empty() {
2911 exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
2912 }
2913
2914 debug!(
2915 correlation_id = %exchange.correlation_id(),
2916 status = status_code,
2917 url = %redact_url_for_diagnostics(&url),
2918 "HTTP response"
2919 );
2920 Ok(exchange)
2921 }
2922 .await;
2923 component_metrics.observe("http", "request", outcome.is_err());
2930 outcome
2931 })
2932 }
2933}
2934
2935#[cfg(test)]
2945pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
2946
2947fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
2955 match e {
2956 CamelError::Unauthenticated(msg) => {
2957 tracing::warn!(error = %msg, path = %path, "Authentication failed");
2958 HttpReply {
2959 status: 401,
2960 headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
2961 body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
2962 }
2963 }
2964 CamelError::Unauthorized(msg) => {
2965 tracing::warn!(error = %msg, path = %path, "Authorization failed");
2966 HttpReply {
2967 status: 403,
2968 headers: vec![],
2969 body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
2970 }
2971 }
2972 CamelError::TypeConversionFailed(msg) => {
2973 tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
2974 let body = serde_json::to_string(&serde_json::json!({
2975 "error": "bad_request",
2976 "message": msg,
2977 }))
2978 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
2980 status: 400,
2981 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
2982 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
2983 }
2984 }
2985 CamelError::ValidationError(msg) => {
2986 tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
2987 let body = serde_json::to_string(&serde_json::json!({
2988 "error": "validation_error",
2989 "message": msg,
2990 }))
2991 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
2993 status: 400,
2994 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
2995 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
2996 }
2997 }
2998 CamelError::ConsumerStopping => {
2999 tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
3000 HttpReply {
3001 status: 503,
3002 headers: vec![],
3003 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
3004 }
3005 }
3006 e => {
3007 tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
3009 HttpReply {
3010 status: 500,
3011 headers: vec![],
3012 body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
3013 }
3014 }
3015 }
3016}
3017
3018fn select_response_headers(
3028 headers: &HashMap<String, serde_json::Value>,
3029 user_content_type: Option<String>,
3030 inferred_content_type: Option<String>,
3031) -> Vec<(String, String)> {
3032 let conn_tokens = header_policy::connection_tokens(
3033 headers
3034 .iter()
3035 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3036 .filter_map(|(_, v)| v.as_str()),
3037 );
3038 let mut selected: Vec<(String, String)> = headers
3039 .iter()
3040 .filter(|(k, _)| !k.starts_with("Camel"))
3041 .filter(|(k, _)| !header_policy::excluded_response(k, &conn_tokens))
3042 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
3043 .collect();
3044 if let Some(ct) = user_content_type.or(inferred_content_type) {
3045 selected.push(("Content-Type".to_string(), ct));
3046 }
3047 selected
3048}
3049
3050#[cfg(test)]
3051mod tests {
3052 use camel_component_api::test_support::NoopRuntimeObservability;
3053
3054 fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3058 std::sync::Arc::new(NoopRuntimeObservability)
3059 }
3060 fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3061 std::sync::Arc::new(NoopRuntimeObservability)
3062 }
3063 fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
3064 std::sync::Arc::new(NoopRuntimeObservability)
3065 }
3066
3067 use super::*;
3068 use crate::rest_match::PathSegment;
3069 use camel_component_api::{Message, NoOpComponentContext};
3070 use std::sync::Arc;
3071 use std::time::Duration;
3072
3073 fn test_producer_ctx() -> ProducerContext {
3074 ProducerContext::new()
3075 }
3076
3077 #[test]
3082 fn redact_url_masks_userinfo_and_query() {
3083 let redacted =
3084 redact_url_for_diagnostics("http://user:secretpass@internal.example/api?token=abc123");
3085 assert!(
3086 !redacted.contains("secretpass"),
3087 "password must be masked: {redacted}"
3088 );
3089 assert!(
3090 !redacted.contains("token=abc123"),
3091 "query must be masked: {redacted}"
3092 );
3093 assert!(
3094 !redacted.contains("user@"),
3095 "username must be masked: {redacted}"
3096 );
3097 assert!(
3098 redacted.contains("internal.example"),
3099 "host stays visible: {redacted}"
3100 );
3101 assert!(redacted.contains("[redacted]"), "query marked: {redacted}");
3102 }
3103
3104 #[test]
3105 fn redact_url_keeps_clean_urls_visible() {
3106 let redacted = redact_url_for_diagnostics("https://api.example.com/v1/items");
3107 assert_eq!(redacted, "https://api.example.com/v1/items");
3108 }
3109
3110 #[test]
3111 fn redact_url_truncates_unparseable() {
3112 let long = "x".repeat(1000);
3113 let redacted = redact_url_for_diagnostics(&long);
3114 assert_eq!(redacted.len(), 256, "unparseable URL must be truncated");
3115 }
3116
3117 #[test]
3118 fn truncate_error_body_caps_attacker_body() {
3119 let big = vec![b'A'; 10 * 1024 * 1024];
3120 let truncated = truncate_error_body(&big);
3121 assert!(
3122 truncated.len() <= MAX_ERROR_RESPONSE_BODY_BYTES + 20,
3123 "body must be capped near {} bytes, got {}",
3124 MAX_ERROR_RESPONSE_BODY_BYTES,
3125 truncated.len()
3126 );
3127 assert!(truncated.ends_with("...[truncated]"));
3128 }
3129
3130 #[test]
3131 fn truncate_error_body_keeps_small_body() {
3132 assert_eq!(truncate_error_body(b"boom"), "boom");
3133 }
3134
3135 #[test]
3136 fn test_http_config_defaults() {
3137 let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
3138 assert_eq!(config.base_url, "http://localhost:8080/api");
3139 assert!(config.http_method.is_none());
3140 assert!(config.throw_exception_on_failure);
3141 assert_eq!(config.ok_status_code_range, (200, 299));
3142 assert!(config.response_timeout.is_none());
3143 assert!(matches!(config.auth, HttpAuth::None));
3144 assert!(!config.bridge_endpoint);
3145 assert!(!config.connection_close);
3146 }
3147
3148 #[test]
3149 fn test_http_config_scheme() {
3150 assert_eq!(HttpEndpointConfig::scheme(), "http");
3152 }
3153
3154 #[test]
3155 fn test_http_config_from_components() {
3156 let components = camel_component_api::UriComponents {
3158 scheme: "https".to_string(),
3159 path: "//api.example.com/v1".to_string(),
3160 params: std::collections::HashMap::from([(
3161 "httpMethod".to_string(),
3162 "POST".to_string(),
3163 )]),
3164 };
3165 let config = HttpEndpointConfig::from_components(components).unwrap();
3166 assert_eq!(config.base_url, "https://api.example.com/v1");
3167 assert_eq!(config.http_method, Some("POST".to_string()));
3168 }
3169
3170 #[test]
3171 fn test_http_config_with_options() {
3172 let config = HttpEndpointConfig::from_uri(
3173 "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
3174 ).unwrap();
3175 assert_eq!(config.base_url, "https://api.example.com/v1");
3176 assert_eq!(config.http_method, Some("PUT".to_string()));
3177 assert!(!config.throw_exception_on_failure);
3178 assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
3179 }
3180
3181 #[test]
3182 fn test_http_endpoint_config_auth_and_headers_options() {
3183 let config = HttpEndpointConfig::from_uri(
3184 "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
3185 )
3186 .unwrap();
3187
3188 assert!(matches!(
3189 config.auth,
3190 HttpAuth::Basic { username, password } if username == "u" && password == "p"
3191 ));
3192 assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
3193 assert!(config.bridge_endpoint);
3194 assert!(config.connection_close);
3195 assert_eq!(
3196 config.skip_request_headers,
3197 vec!["authorization".to_string(), "x-secret".to_string()]
3198 );
3199 assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
3200 }
3201
3202 #[test]
3203 fn test_http_endpoint_config_bearer_auth() {
3204 let config = HttpEndpointConfig::from_uri(
3205 "http://localhost/api?authMethod=Bearer&authBearerToken=t",
3206 )
3207 .unwrap();
3208 assert!(matches!(
3209 config.auth,
3210 HttpAuth::Bearer { token } if token == "t"
3211 ));
3212 }
3213
3214 #[test]
3215 fn rejects_cookie_handling_inmemory() {
3216 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
3217 match result {
3218 Err(CamelError::InvalidUri(msg)) => {
3219 assert!(
3220 msg.contains("cookieHandling is not supported"),
3221 "expected rejection message, got: {msg}"
3222 );
3223 }
3224 other => panic!("expected InvalidUri error, got: {other:?}"),
3225 }
3226 }
3227
3228 #[test]
3229 fn rejects_cookie_handling_disabled() {
3230 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
3231 match result {
3232 Err(CamelError::InvalidUri(msg)) => {
3233 assert!(
3234 msg.contains("cookieHandling is not supported"),
3235 "expected rejection message, got: {msg}"
3236 );
3237 }
3238 other => panic!("expected InvalidUri error, got: {other:?}"),
3239 }
3240 }
3241
3242 #[test]
3243 fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
3244 let config = HttpConfig::default()
3245 .with_response_timeout_ms(999)
3246 .with_allow_internal(true)
3247 .with_blocked_hosts(vec!["evil.com".to_string()])
3248 .with_max_body_size(12345);
3249 let endpoint =
3250 HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
3251 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
3252 assert!(endpoint.allow_internal);
3253 assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
3254 assert_eq!(endpoint.max_body_size, 12345);
3255 }
3256
3257 #[test]
3258 fn test_from_uri_with_defaults_uri_overrides_config() {
3259 let config = HttpConfig::default()
3260 .with_response_timeout_ms(999)
3261 .with_allow_internal(true)
3262 .with_blocked_hosts(vec!["evil.com".to_string()])
3263 .with_max_body_size(12345);
3264 let endpoint = HttpEndpointConfig::from_uri_with_defaults(
3265 "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
3266 &config,
3267 )
3268 .unwrap();
3269 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
3270 assert!(!endpoint.allow_internal);
3271 assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
3272 assert_eq!(endpoint.max_body_size, 99);
3273 }
3274
3275 #[test]
3276 fn test_http_config_ok_status_range() {
3277 let config =
3278 HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
3279 assert_eq!(config.ok_status_code_range, (200, 204));
3280 }
3281
3282 #[test]
3283 fn test_http_config_wrong_scheme() {
3284 let result = HttpEndpointConfig::from_uri("file:/tmp");
3285 assert!(result.is_err());
3286 }
3287
3288 #[test]
3289 fn test_http_component_scheme() {
3290 let component = HttpComponent::new();
3291 assert_eq!(component.scheme(), "http");
3292 }
3293
3294 #[test]
3295 fn test_https_component_scheme() {
3296 let component = HttpsComponent::new();
3297 assert_eq!(component.scheme(), "https");
3298 }
3299
3300 #[test]
3301 fn test_http_endpoint_creates_consumer() {
3302 let component = HttpComponent::new();
3303 let ctx = NoOpComponentContext;
3304 let endpoint = component
3305 .create_endpoint("http://0.0.0.0:19100/test", &ctx)
3306 .unwrap();
3307 assert!(endpoint.create_consumer(rt()).is_ok());
3308 }
3309
3310 #[test]
3311 fn test_https_endpoint_creates_consumer_errors_without_tls() {
3312 let component = HttpsComponent::new();
3313 let ctx = NoOpComponentContext;
3314 let endpoint = component
3315 .create_endpoint("https://0.0.0.0:8443/test", &ctx)
3316 .unwrap();
3317 assert!(endpoint.create_consumer(rt()).is_err());
3319 }
3320
3321 #[test]
3322 fn test_http_endpoint_creates_producer() {
3323 let ctx = test_producer_ctx();
3324 let component = HttpComponent::new();
3325 let endpoint_ctx = NoOpComponentContext;
3326 let endpoint = component
3327 .create_endpoint("http://localhost/api", &endpoint_ctx)
3328 .unwrap();
3329 assert!(endpoint.create_producer(rt(), &ctx).is_ok());
3330 }
3331
3332 #[tokio::test]
3337 async fn test_producer_with_token_provider() {
3338 use camel_auth::oauth2::TokenProvider;
3339 use tower::ServiceExt;
3340
3341 let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
3342 Arc::new(std::sync::Mutex::new(None));
3343 let captured_clone = Arc::clone(&captured_auth);
3344
3345 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3346 let port = listener.local_addr().unwrap().port();
3347
3348 let _handle = tokio::spawn(async move {
3349 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3350 if let Ok((mut stream, _)) = listener.accept().await {
3351 let mut buf = vec![0u8; 8192];
3352 let n = stream.read(&mut buf).await.unwrap_or(0);
3353 let request = String::from_utf8_lossy(&buf[..n]).to_string();
3354 let auth = request
3355 .lines()
3356 .find(|l| l.to_lowercase().starts_with("authorization:"))
3357 .map(|l| {
3358 l.split(':')
3359 .nth(1)
3360 .map(|s| s.trim().to_string())
3361 .unwrap_or_default()
3362 });
3363 *captured_clone.lock().unwrap() = auth;
3364 let body = r#"{"echo":"ok"}"#;
3365 let resp = format!(
3366 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
3367 body.len(),
3368 body
3369 );
3370 let _ = stream.write_all(resp.as_bytes()).await;
3371 }
3372 });
3373
3374 #[derive(Debug)]
3375 struct StaticProvider;
3376 #[async_trait::async_trait]
3377 impl TokenProvider for StaticProvider {
3378 async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
3379 Ok("injected-token".into())
3380 }
3381 }
3382
3383 let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
3384 let ctx = test_producer_ctx();
3385 let component = HttpComponent::new();
3386 let endpoint_ctx = NoOpComponentContext;
3387 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
3388 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3389
3390 let exchange = Exchange::new(Message::new("hello"));
3391
3392 let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
3393 let mut layered = layer.layer(producer);
3394 let result = layered.ready().await.unwrap().call(exchange).await;
3395 assert!(result.is_ok(), "producer call failed: {:?}", result);
3396
3397 tokio::time::sleep(Duration::from_millis(100)).await;
3398 let auth = captured_auth.lock().unwrap().take();
3399 assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
3400 }
3401
3402 async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
3403 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3404 let addr = listener.local_addr().unwrap();
3405 let url = format!("http://127.0.0.1:{}", addr.port());
3406
3407 let handle = tokio::spawn(async move {
3408 loop {
3409 if let Ok((mut stream, _)) = listener.accept().await {
3410 tokio::spawn(async move {
3411 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3412 let mut buf = vec![0u8; 4096];
3413 let n = stream.read(&mut buf).await.unwrap_or(0);
3414 let request = String::from_utf8_lossy(&buf[..n]).to_string();
3415
3416 let method = request.split_whitespace().next().unwrap_or("GET");
3417
3418 let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
3419 let response = format!(
3420 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
3421 body.len(),
3422 body
3423 );
3424 let _ = stream.write_all(response.as_bytes()).await;
3425 });
3426 }
3427 }
3428 });
3429
3430 (url, handle)
3431 }
3432
3433 async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
3434 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3435 let addr = listener.local_addr().unwrap();
3436 let url = format!("http://127.0.0.1:{}", addr.port());
3437
3438 let handle = tokio::spawn(async move {
3439 loop {
3440 if let Ok((mut stream, _)) = listener.accept().await {
3441 let status = status;
3442 tokio::spawn(async move {
3443 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3444 let mut buf = vec![0u8; 4096];
3445 let _ = stream.read(&mut buf).await;
3446
3447 let status_text = match status {
3448 404 => "Not Found",
3449 500 => "Internal Server Error",
3450 _ => "Error",
3451 };
3452 let body = "error body";
3453 let response = format!(
3454 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
3455 status,
3456 status_text,
3457 body.len(),
3458 body
3459 );
3460 let _ = stream.write_all(response.as_bytes()).await;
3461 });
3462 }
3463 }
3464 });
3465
3466 (url, handle)
3467 }
3468
3469 async fn start_request_capturing_server() -> (
3470 String,
3471 Arc<std::sync::Mutex<Option<String>>>,
3472 tokio::task::JoinHandle<()>,
3473 ) {
3474 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3475 let port = listener.local_addr().unwrap().port();
3476 let url = format!("http://127.0.0.1:{port}");
3477 let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
3478 let captured_clone = Arc::clone(&captured);
3479 let handle = tokio::spawn(async move {
3480 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3481 if let Ok((mut stream, _)) = listener.accept().await {
3482 let mut buf = vec![0u8; 16384];
3483 let n = stream.read(&mut buf).await.unwrap_or(0);
3484 let request = String::from_utf8_lossy(&buf[..n]).to_string();
3485 if request.contains("\r\n\r\n") {
3486 *captured_clone.lock().unwrap() = Some(request);
3487 }
3488 let body = r#"{"echo":"ok"}"#;
3489 let resp = format!(
3490 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
3491 body.len(),
3492 body
3493 );
3494 let _ = stream.write_all(resp.as_bytes()).await;
3495 }
3496 });
3497 (url, captured, handle)
3498 }
3499
3500 #[tokio::test]
3501 async fn test_http_producer_get_request() {
3502 use tower::ServiceExt;
3503
3504 let (url, _handle) = start_test_server().await;
3505 let ctx = test_producer_ctx();
3506
3507 let component = HttpComponent::new();
3508 let endpoint_ctx = NoOpComponentContext;
3509 let endpoint = component
3510 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
3511 .unwrap();
3512 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3513
3514 let exchange = Exchange::new(Message::default());
3515 let result = producer.oneshot(exchange).await.unwrap();
3516
3517 let status = result
3518 .input
3519 .header("CamelHttpResponseCode")
3520 .and_then(|v| v.as_u64())
3521 .unwrap();
3522 assert_eq!(status, 200);
3523
3524 assert!(!result.input.body.is_empty());
3525 }
3526
3527 #[tokio::test]
3528 async fn producer_excludes_host_and_framing() {
3529 use tower::ServiceExt;
3530
3531 let (url, captured, _handle) = start_request_capturing_server().await;
3532 let ctx = test_producer_ctx();
3533 let component = HttpComponent::new();
3534 let endpoint_ctx = NoOpComponentContext;
3535 let endpoint = component
3536 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
3537 .unwrap();
3538 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3539
3540 let mut exchange = Exchange::new(Message::default());
3541 exchange.input.set_header("Host", "localhost");
3542 exchange.input.set_header("Content-Length", "42");
3543 exchange.input.set_header("Connection", "keep-alive");
3544 exchange.input.set_header("Upgrade", "h2c");
3545
3546 let result = producer.oneshot(exchange).await;
3547 assert!(result.is_ok(), "producer call failed: {:?}", result);
3548
3549 tokio::time::sleep(Duration::from_millis(100)).await;
3550 let request = captured
3551 .lock()
3552 .unwrap()
3553 .take()
3554 .expect("no outbound request captured");
3555 let lower = request.to_ascii_lowercase();
3556 assert!(
3557 !lower.contains("\r\nhost: localhost"),
3558 "forwarded Host: localhost must be stripped\n{request}"
3559 );
3560 assert!(
3561 !lower.contains("content-length: 42"),
3562 "exchange Content-Length must not be copied\n{request}"
3563 );
3564 assert!(
3565 !lower.lines().any(|l| l.starts_with("connection:")),
3566 "Connection header must not be forwarded\n{request}"
3567 );
3568 assert!(
3569 !lower.lines().any(|l| l.starts_with("upgrade:")),
3570 "Upgrade header must not be forwarded\n{request}"
3571 );
3572 let host_header = lower
3573 .lines()
3574 .find(|l| l.starts_with("host:"))
3575 .map(|l| l.split_once(':').map(|(_, v)| v).unwrap_or("").trim())
3576 .expect("outbound Host header must be set by reqwest");
3577 assert!(
3578 host_header.starts_with("127.0.0.1:"),
3579 "outbound Host '{host_header}' must match the capture-server address"
3580 );
3581 }
3582
3583 #[tokio::test]
3584 async fn producer_forwards_request_only_headers() {
3585 use tower::ServiceExt;
3586
3587 let (url, captured, _handle) = start_request_capturing_server().await;
3588 let ctx = test_producer_ctx();
3589 let component = HttpComponent::new();
3590 let endpoint_ctx = NoOpComponentContext;
3591 let endpoint = component
3592 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
3593 .unwrap();
3594 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3595
3596 let mut exchange = Exchange::new(Message::default());
3597 exchange.input.set_header("Accept", "application/json");
3598 exchange.input.set_header("User-Agent", "myclient/1.0");
3599
3600 let result = producer.oneshot(exchange).await;
3601 assert!(result.is_ok(), "producer call failed: {:?}", result);
3602
3603 tokio::time::sleep(Duration::from_millis(100)).await;
3604 let request = captured
3605 .lock()
3606 .unwrap()
3607 .take()
3608 .expect("no outbound request captured");
3609 let lower = request.to_ascii_lowercase();
3610 assert!(
3611 lower.contains("accept: application/json"),
3612 "request-only Accept header must be forwarded\n{request}"
3613 );
3614 assert!(
3615 lower.contains("user-agent: myclient/1.0"),
3616 "request-only User-Agent header must be forwarded\n{request}"
3617 );
3618 }
3619
3620 #[tokio::test]
3621 async fn producer_honours_skip_request_headers() {
3622 use tower::ServiceExt;
3623
3624 let (url, captured, _handle) = start_request_capturing_server().await;
3625 let ctx = test_producer_ctx();
3626 let component = HttpComponent::new();
3627 let endpoint_ctx = NoOpComponentContext;
3628 let endpoint = component
3629 .create_endpoint(
3630 &format!("{url}/api/test?allowInternal=true&skipRequestHeaders=Authorization"),
3631 &endpoint_ctx,
3632 )
3633 .unwrap();
3634 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3635
3636 let mut exchange = Exchange::new(Message::default());
3637 exchange.input.set_header("Authorization", "Bearer x");
3638
3639 let result = producer.oneshot(exchange).await;
3640 assert!(result.is_ok(), "producer call failed: {:?}", result);
3641
3642 tokio::time::sleep(Duration::from_millis(100)).await;
3643 let request = captured
3644 .lock()
3645 .unwrap()
3646 .take()
3647 .expect("no outbound request captured");
3648 assert!(
3649 !request.to_ascii_lowercase().contains("authorization"),
3650 "Authorization must be stripped by skipRequestHeaders\n{request}"
3651 );
3652 }
3653
3654 #[tokio::test]
3655 async fn test_http_producer_post_with_body() {
3656 use tower::ServiceExt;
3657
3658 let (url, _handle) = start_test_server().await;
3659 let ctx = test_producer_ctx();
3660
3661 let component = HttpComponent::new();
3662 let endpoint_ctx = NoOpComponentContext;
3663 let endpoint = component
3664 .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
3665 .unwrap();
3666 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3667
3668 let exchange = Exchange::new(Message::new("request body"));
3669 let result = producer.oneshot(exchange).await.unwrap();
3670
3671 let status = result
3672 .input
3673 .header("CamelHttpResponseCode")
3674 .and_then(|v| v.as_u64())
3675 .unwrap();
3676 assert_eq!(status, 200);
3677 }
3678
3679 #[tokio::test]
3680 async fn test_http_producer_method_from_header() {
3681 use tower::ServiceExt;
3682
3683 let (url, _handle) = start_test_server().await;
3684 let ctx = test_producer_ctx();
3685
3686 let component = HttpComponent::new();
3687 let endpoint_ctx = NoOpComponentContext;
3688 let endpoint = component
3689 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
3690 .unwrap();
3691 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3692
3693 let mut exchange = Exchange::new(Message::default());
3694 exchange.input.set_header(
3695 "CamelHttpMethod",
3696 serde_json::Value::String("DELETE".to_string()),
3697 );
3698
3699 let result = producer.oneshot(exchange).await.unwrap();
3700 let status = result
3701 .input
3702 .header("CamelHttpResponseCode")
3703 .and_then(|v| v.as_u64())
3704 .unwrap();
3705 assert_eq!(status, 200);
3706 }
3707
3708 #[tokio::test]
3709 async fn test_http_producer_forced_method() {
3710 use tower::ServiceExt;
3711
3712 let (url, _handle) = start_test_server().await;
3713 let ctx = test_producer_ctx();
3714
3715 let component = HttpComponent::new();
3716 let endpoint_ctx = NoOpComponentContext;
3717 let endpoint = component
3718 .create_endpoint(
3719 &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
3720 &endpoint_ctx,
3721 )
3722 .unwrap();
3723 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3724
3725 let exchange = Exchange::new(Message::default());
3726 let result = producer.oneshot(exchange).await.unwrap();
3727
3728 let status = result
3729 .input
3730 .header("CamelHttpResponseCode")
3731 .and_then(|v| v.as_u64())
3732 .unwrap();
3733 assert_eq!(status, 200);
3734 }
3735
3736 #[tokio::test]
3737 async fn test_http_producer_throw_exception_on_failure() {
3738 use tower::ServiceExt;
3739
3740 let (url, _handle) = start_status_server(404).await;
3741 let ctx = test_producer_ctx();
3742
3743 let component = HttpComponent::new();
3744 let endpoint_ctx = NoOpComponentContext;
3745 let endpoint = component
3746 .create_endpoint(
3747 &format!("{url}/not-found?allowInternal=true"),
3748 &endpoint_ctx,
3749 )
3750 .unwrap();
3751 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3752
3753 let exchange = Exchange::new(Message::default());
3754 let result = producer.oneshot(exchange).await;
3755 assert!(result.is_err());
3756
3757 match result.unwrap_err() {
3758 CamelError::HttpOperationFailed { status_code, .. } => {
3759 assert_eq!(status_code, 404);
3760 }
3761 e => panic!("Expected HttpOperationFailed, got: {e}"),
3762 }
3763 }
3764
3765 #[tokio::test]
3766 async fn test_http_producer_no_throw_on_failure() {
3767 use tower::ServiceExt;
3768
3769 let (url, _handle) = start_status_server(500).await;
3770 let ctx = test_producer_ctx();
3771
3772 let component = HttpComponent::new();
3773 let endpoint_ctx = NoOpComponentContext;
3774 let endpoint = component
3775 .create_endpoint(
3776 &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
3777 &endpoint_ctx,
3778 )
3779 .unwrap();
3780 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3781
3782 let exchange = Exchange::new(Message::default());
3783 let result = producer.oneshot(exchange).await.unwrap();
3784
3785 let status = result
3786 .input
3787 .header("CamelHttpResponseCode")
3788 .and_then(|v| v.as_u64())
3789 .unwrap();
3790 assert_eq!(status, 500);
3791 }
3792
3793 #[tokio::test]
3794 async fn test_http_producer_uri_override() {
3795 use tower::ServiceExt;
3796
3797 let (url, _handle) = start_test_server().await;
3798 let ctx = test_producer_ctx();
3799
3800 let component = HttpComponent::new();
3801 let endpoint_ctx = NoOpComponentContext;
3802 let endpoint = component
3803 .create_endpoint(
3804 "http://localhost:1/does-not-exist?allowInternal=true",
3805 &endpoint_ctx,
3806 )
3807 .unwrap();
3808 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3809
3810 let mut exchange = Exchange::new(Message::default());
3811 exchange.input.set_header(
3812 "CamelHttpUri",
3813 serde_json::Value::String(format!("{url}/api")),
3814 );
3815
3816 let result = producer.oneshot(exchange).await.unwrap();
3817 let status = result
3818 .input
3819 .header("CamelHttpResponseCode")
3820 .and_then(|v| v.as_u64())
3821 .unwrap();
3822 assert_eq!(status, 200);
3823 }
3824
3825 #[tokio::test]
3826 async fn test_http_producer_response_headers_mapped() {
3827 use tower::ServiceExt;
3828
3829 let (url, _handle) = start_test_server().await;
3830 let ctx = test_producer_ctx();
3831
3832 let component = HttpComponent::new();
3833 let endpoint_ctx = NoOpComponentContext;
3834 let endpoint = component
3835 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
3836 .unwrap();
3837 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3838
3839 let exchange = Exchange::new(Message::default());
3840 let result = producer.oneshot(exchange).await.unwrap();
3841
3842 assert!(
3843 result.input.header("Content-Type").is_some(),
3844 "Response should have Content-Type header"
3845 );
3846 assert!(result.input.header("CamelHttpResponseText").is_some());
3847 }
3848
3849 async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
3854 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3855 let addr = listener.local_addr().unwrap();
3856 let url = format!("http://127.0.0.1:{}", addr.port());
3857
3858 let handle = tokio::spawn(async move {
3859 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3860 loop {
3861 if let Ok((mut stream, _)) = listener.accept().await {
3862 tokio::spawn(async move {
3863 let mut buf = vec![0u8; 4096];
3864 let n = stream.read(&mut buf).await.unwrap_or(0);
3865 let request = String::from_utf8_lossy(&buf[..n]).to_string();
3866
3867 if request.contains("GET /final") {
3869 let body = r#"{"status":"final"}"#;
3870 let response = format!(
3871 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
3872 body.len(),
3873 body
3874 );
3875 let _ = stream.write_all(response.as_bytes()).await;
3876 } else {
3877 let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
3882 let _ = stream.write_all(response.as_bytes()).await;
3883 }
3884 });
3885 }
3886 }
3887 });
3888
3889 (url, handle)
3890 }
3891
3892 struct CapturedRequest {
3893 method: String,
3894 path: String,
3895 body: Vec<u8>,
3896 content_length: Option<String>,
3897 transfer_encoding: Option<String>,
3898 }
3899
3900 async fn capture_request(stream: &mut tokio::net::TcpStream) -> Option<CapturedRequest> {
3906 use tokio::io::AsyncReadExt;
3907
3908 let mut buf: Vec<u8> = Vec::new();
3910 let mut chunk = [0u8; 4096];
3911 let head_end: usize;
3912 loop {
3913 let n = stream.read(&mut chunk).await.unwrap_or(0);
3914 if n == 0 {
3915 return None;
3916 }
3917 buf.extend_from_slice(&chunk[..n]);
3918 if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
3919 head_end = pos + 4;
3920 break;
3921 }
3922 }
3923
3924 let head = String::from_utf8_lossy(&buf[..head_end]);
3926 let mut lines = head.split("\r\n");
3927 let request_line = lines.next().unwrap_or("");
3928 let mut parts = request_line.split_whitespace();
3929 let method = parts.next().unwrap_or("").to_string();
3930 let path = parts.next().unwrap_or("").to_string();
3931
3932 let mut content_length: Option<String> = None;
3933 let mut transfer_encoding: Option<String> = None;
3934 for line in lines {
3935 if let Some((name, value)) = line.split_once(':') {
3936 let name = name.trim().to_ascii_lowercase();
3937 let value = value.trim().to_string();
3938 if name == "content-length" {
3939 content_length = Some(value);
3940 } else if name == "transfer-encoding" {
3941 transfer_encoding = Some(value);
3942 }
3943 }
3944 }
3945
3946 let body_len: usize = content_length
3948 .as_deref()
3949 .and_then(|v| v.parse::<usize>().ok())
3950 .unwrap_or(0);
3951
3952 let mut body: Vec<u8> = buf[head_end..].to_vec();
3953 while body.len() < body_len {
3954 let n = stream.read(&mut chunk).await.unwrap_or(0);
3955 if n == 0 {
3956 break;
3957 }
3958 body.extend_from_slice(&chunk[..n]);
3959 }
3960 body.truncate(body_len);
3961
3962 Some(CapturedRequest {
3963 method,
3964 path,
3965 body,
3966 content_length,
3967 transfer_encoding,
3968 })
3969 }
3970
3971 async fn start_capture_server() -> (
3976 String,
3977 tokio::task::JoinHandle<()>,
3978 Arc<Mutex<Vec<CapturedRequest>>>,
3979 ) {
3980 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3981 let addr = listener.local_addr().unwrap();
3982 let url = format!("http://127.0.0.1:{}", addr.port());
3983
3984 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
3985 let captured_for_return = Arc::clone(&captured);
3986
3987 let handle = tokio::spawn(async move {
3988 use tokio::io::AsyncWriteExt;
3989 loop {
3990 if let Ok((mut stream, _)) = listener.accept().await {
3991 let captured = Arc::clone(&captured);
3992 tokio::spawn(async move {
3993 let Some(req) = capture_request(&mut stream).await else {
3994 return;
3995 };
3996 captured.lock().unwrap().push(req);
3997
3998 let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
4001 let _ = stream.write_all(response.as_bytes()).await;
4002 });
4003 }
4004 }
4005 });
4006
4007 (url, handle, captured_for_return)
4008 }
4009
4010 async fn start_redirect_capture_server() -> (
4016 String,
4017 tokio::task::JoinHandle<()>,
4018 Arc<Mutex<Vec<CapturedRequest>>>,
4019 ) {
4020 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4021 let addr = listener.local_addr().unwrap();
4022 let url = format!("http://127.0.0.1:{}", addr.port());
4023
4024 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
4025 let captured_for_return = Arc::clone(&captured);
4026
4027 let handle = tokio::spawn(async move {
4028 use tokio::io::AsyncWriteExt;
4029 loop {
4030 if let Ok((mut stream, _)) = listener.accept().await {
4031 let captured = Arc::clone(&captured);
4032 tokio::spawn(async move {
4033 let Some(req) = capture_request(&mut stream).await else {
4034 return;
4035 };
4036 let path = req.path.clone();
4037 captured.lock().unwrap().push(req);
4038
4039 let (status_line, location) = match path.as_str() {
4040 "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
4041 "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
4042 "/final" => ("HTTP/1.1 200 OK", None),
4043 _ => ("HTTP/1.1 404 Not Found", None),
4044 };
4045
4046 let response = match location {
4047 Some(loc) => format!(
4051 "{status_line}\r\nLocation: {loc}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
4052 ),
4053 None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
4054 };
4055 let _ = stream.write_all(response.as_bytes()).await;
4056 });
4057 }
4058 }
4059 });
4060
4061 (url, handle, captured_for_return)
4062 }
4063
4064 #[tokio::test]
4065 async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
4066 use tower::ServiceExt;
4067
4068 let (url, _handle, captured) = start_capture_server().await;
4069 let ctx = test_producer_ctx();
4070
4071 let component = HttpComponent::with_config(HttpConfig::default());
4072 let endpoint_ctx = NoOpComponentContext;
4073 let endpoint = component
4074 .create_endpoint(
4075 &format!("{url}?httpMethod=GET&allowInternal=true"),
4076 &endpoint_ctx,
4077 )
4078 .unwrap();
4079 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4080
4081 let mut exchange = Exchange::new(Message::default());
4082 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
4083
4084 let result = producer.oneshot(exchange).await.unwrap();
4085
4086 let status = result
4087 .input
4088 .header("CamelHttpResponseCode")
4089 .and_then(|v| v.as_u64())
4090 .unwrap();
4091 assert_eq!(status, 200);
4092
4093 let captured = captured.lock().unwrap();
4094 assert_eq!(captured.len(), 1, "expected exactly one captured request");
4095 let req = &captured[0];
4096 assert_eq!(req.method, "GET");
4097 assert_eq!(req.path, "/");
4100 assert!(req.body.is_empty(), "GET must not carry a body");
4101 assert!(
4102 req.content_length.is_none(),
4103 "suppressed request must not carry Content-Length"
4104 );
4105 assert!(
4106 req.transfer_encoding.is_none(),
4107 "suppressed request must not carry Transfer-Encoding"
4108 );
4109
4110 assert!(
4112 result.input.body.is_empty(),
4113 "exchange body must be consumed"
4114 );
4115 }
4116
4117 #[tokio::test]
4118 async fn test_head_with_body_suppressed_via_header() {
4119 use tower::ServiceExt;
4120
4121 let (url, _handle, captured) = start_capture_server().await;
4122 let ctx = test_producer_ctx();
4123
4124 let component = HttpComponent::with_config(HttpConfig::default());
4125 let endpoint_ctx = NoOpComponentContext;
4126 let endpoint = component
4127 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
4128 .unwrap();
4129 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4130
4131 let mut exchange = Exchange::new(Message::default());
4132 exchange.input.set_header(
4133 "CamelHttpMethod",
4134 serde_json::Value::String("HEAD".to_string()),
4135 );
4136 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
4137
4138 let result = producer.oneshot(exchange).await.unwrap();
4139 let status = result
4140 .input
4141 .header("CamelHttpResponseCode")
4142 .and_then(|v| v.as_u64())
4143 .unwrap();
4144 assert_eq!(status, 200);
4145
4146 let captured = captured.lock().unwrap();
4147 assert_eq!(captured.len(), 1);
4148 let req = &captured[0];
4149 assert_eq!(req.method, "HEAD");
4150 assert!(req.body.is_empty(), "HEAD must not carry a body");
4151 }
4152
4153 #[tokio::test]
4154 async fn test_delete_options_trace_with_body_suppressed() {
4155 use tower::ServiceExt;
4156
4157 let (url, _handle, captured) = start_capture_server().await;
4158 let ctx = test_producer_ctx();
4159 let component = HttpComponent::with_config(HttpConfig::default());
4160 let endpoint_ctx = NoOpComponentContext;
4161
4162 for method in ["DELETE", "OPTIONS", "TRACE"] {
4163 let endpoint = component
4164 .create_endpoint(
4165 &format!("{url}?httpMethod={method}&allowInternal=true"),
4166 &endpoint_ctx,
4167 )
4168 .unwrap();
4169 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4170
4171 let mut exchange = Exchange::new(Message::default());
4172 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
4173
4174 let result = producer.oneshot(exchange).await.unwrap();
4175 let status = result
4176 .input
4177 .header("CamelHttpResponseCode")
4178 .and_then(|v| v.as_u64())
4179 .unwrap();
4180 assert_eq!(status, 200, "method {method} should succeed");
4181 }
4182
4183 let captured = captured.lock().unwrap();
4184 assert_eq!(captured.len(), 3, "expected three captured requests");
4185 for method in ["DELETE", "OPTIONS", "TRACE"] {
4186 let req = captured
4187 .iter()
4188 .find(|r| r.method == method)
4189 .unwrap_or_else(|| panic!("missing captured request for {method}"));
4190 assert!(req.body.is_empty(), "{} must not carry a body", req.method);
4191 }
4192 }
4193
4194 #[tokio::test]
4195 async fn test_post_put_patch_with_body_still_sent() {
4196 use tower::ServiceExt;
4197
4198 let (url, _handle, captured) = start_capture_server().await;
4199 let ctx = test_producer_ctx();
4200 let component = HttpComponent::with_config(HttpConfig::default());
4201 let endpoint_ctx = NoOpComponentContext;
4202
4203 for method in ["POST", "PUT", "PATCH"] {
4204 let endpoint = component
4205 .create_endpoint(
4206 &format!("{url}?httpMethod={method}&allowInternal=true"),
4207 &endpoint_ctx,
4208 )
4209 .unwrap();
4210 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4211
4212 let payload = format!("body-for-{method}");
4213 let mut exchange = Exchange::new(Message::default());
4214 exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
4215
4216 let result = producer.oneshot(exchange).await.unwrap();
4217 let status = result
4218 .input
4219 .header("CamelHttpResponseCode")
4220 .and_then(|v| v.as_u64())
4221 .unwrap();
4222 assert_eq!(status, 200, "method {method} should succeed");
4223 }
4224
4225 let captured = captured.lock().unwrap();
4226 assert_eq!(captured.len(), 3, "expected three captured requests");
4227 for method in ["POST", "PUT", "PATCH"] {
4228 let req = captured
4229 .iter()
4230 .find(|r| r.method == method)
4231 .unwrap_or_else(|| panic!("missing captured request for {method}"));
4232 let expected = format!("body-for-{method}");
4233 assert!(!req.body.is_empty(), "{method} must still carry its body");
4234 assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
4235 }
4236 }
4237
4238 #[tokio::test]
4242 async fn test_stream_body_under_get_not_attached() {
4243 use tower::ServiceExt;
4244
4245 let (url, _handle, captured) = start_capture_server().await;
4246 let ctx = test_producer_ctx();
4247
4248 let component = HttpComponent::with_config(HttpConfig::default());
4249 let endpoint_ctx = NoOpComponentContext;
4250 let endpoint = component
4251 .create_endpoint(
4252 &format!("{url}?httpMethod=GET&allowInternal=true"),
4253 &endpoint_ctx,
4254 )
4255 .unwrap();
4256 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4257
4258 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
4259 vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
4260 let stream = Box::pin(futures::stream::iter(chunks));
4261 let mut exchange = Exchange::new(Message::default());
4262 exchange.input.body = Body::Stream(StreamBody {
4263 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
4264 metadata: StreamMetadata::default(),
4265 });
4266
4267 let result = producer.oneshot(exchange).await.unwrap();
4268
4269 let status = result
4270 .input
4271 .header("CamelHttpResponseCode")
4272 .and_then(|v| v.as_u64())
4273 .unwrap();
4274 assert_eq!(status, 200);
4275
4276 let captured = captured.lock().unwrap();
4277 assert_eq!(captured.len(), 1, "expected exactly one captured request");
4278 assert!(
4279 captured[0].body.is_empty(),
4280 "GET must not carry a stream body"
4281 );
4282 assert!(
4283 captured[0].transfer_encoding.is_none(),
4284 "suppressed request must not carry Transfer-Encoding"
4285 );
4286 assert!(
4287 captured[0].content_length.is_none(),
4288 "suppressed request must not carry Content-Length"
4289 );
4290 assert!(
4291 result.input.body.is_empty(),
4292 "exchange body must be consumed to Empty, not left as a stream"
4293 );
4294 }
4295
4296 #[tokio::test]
4300 async fn test_redirect_hops_never_replay_suppressed_body() {
4301 use tower::ServiceExt;
4302
4303 let (url, _handle, captured) = start_redirect_capture_server().await;
4304 let ctx = test_producer_ctx();
4305
4306 let component =
4307 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4308 let endpoint_ctx = NoOpComponentContext;
4309
4310 for path in ["/hop307", "/hop308"] {
4311 let endpoint = component
4312 .create_endpoint(
4313 &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
4314 &endpoint_ctx,
4315 )
4316 .unwrap();
4317 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4318
4319 let mut exchange = Exchange::new(Message::default());
4320 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
4321
4322 let result = producer.oneshot(exchange).await.unwrap();
4323 let status = result
4324 .input
4325 .header("CamelHttpResponseCode")
4326 .and_then(|v| v.as_u64())
4327 .unwrap();
4328 assert_eq!(
4329 status, 200,
4330 "redirect chain for {path} should end at /final"
4331 );
4332 }
4333
4334 let captured = captured.lock().unwrap();
4336 assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
4337 for req in captured.iter() {
4338 assert!(
4339 req.body.is_empty(),
4340 "hop {} {} must not carry a body",
4341 req.method,
4342 req.path
4343 );
4344 }
4345 }
4346
4347 #[tracing_test::traced_test]
4356 #[tokio::test]
4357 async fn test_suppressed_body_logs_exactly_one_warn() {
4358 use tower::ServiceExt;
4359
4360 let (url, _handle, _captured) = start_capture_server().await;
4361 let ctx = test_producer_ctx();
4362
4363 let component = HttpComponent::with_config(HttpConfig::default());
4364 let endpoint_ctx = NoOpComponentContext;
4365 let endpoint = component
4366 .create_endpoint(
4367 &format!("{url}?httpMethod=GET&allowInternal=true"),
4368 &endpoint_ctx,
4369 )
4370 .unwrap();
4371 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4372
4373 let mut exchange = Exchange::new(Message::default());
4374 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
4375 let correlation_id = exchange.correlation_id().to_string();
4376
4377 let result = producer.oneshot(exchange).await.unwrap();
4378 let status = result
4379 .input
4380 .header("CamelHttpResponseCode")
4381 .and_then(|v| v.as_u64())
4382 .unwrap();
4383 assert_eq!(status, 200);
4384
4385 logs_assert(|lines: &[&str]| {
4386 let hits = lines
4387 .iter()
4388 .filter(|l| {
4389 l.contains("dropping request body")
4390 && l.contains("method=GET")
4391 && l.contains(&format!("correlation_id={correlation_id}"))
4392 })
4393 .count();
4394 match hits {
4395 1 => Ok(()),
4396 n => Err(format!("expected exactly one body-drop warn, found {n}")),
4397 }
4398 });
4399 }
4400
4401 #[tracing_test::traced_test]
4402 #[tokio::test]
4403 async fn test_empty_body_get_emits_no_warn() {
4404 use tower::ServiceExt;
4405
4406 let (url, _handle, _captured) = start_capture_server().await;
4407 let ctx = test_producer_ctx();
4408
4409 let component = HttpComponent::with_config(HttpConfig::default());
4410 let endpoint_ctx = NoOpComponentContext;
4411 let endpoint = component
4412 .create_endpoint(
4413 &format!("{url}?httpMethod=GET&allowInternal=true"),
4414 &endpoint_ctx,
4415 )
4416 .unwrap();
4417 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4418
4419 let exchange = Exchange::new(Message::default());
4420 let result = producer.oneshot(exchange).await.unwrap();
4421 let status = result
4422 .input
4423 .header("CamelHttpResponseCode")
4424 .and_then(|v| v.as_u64())
4425 .unwrap();
4426 assert_eq!(status, 200);
4427
4428 logs_assert(|lines: &[&str]| {
4429 let hits = lines
4430 .iter()
4431 .filter(|l| l.contains("dropping request body"))
4432 .count();
4433 match hits {
4434 0 => Ok(()),
4435 n => Err(format!("expected no body-drop warn, found {n}")),
4436 }
4437 });
4438 }
4439
4440 #[tokio::test]
4441 async fn test_follow_redirects_false_does_not_follow() {
4442 use tower::ServiceExt;
4443
4444 let (url, _handle) = start_redirect_server().await;
4445 let ctx = test_producer_ctx();
4446
4447 let component =
4448 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
4449 let endpoint_ctx = NoOpComponentContext;
4450 let endpoint = component
4451 .create_endpoint(
4452 &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
4453 &endpoint_ctx,
4454 )
4455 .unwrap();
4456 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4457
4458 let exchange = Exchange::new(Message::default());
4459 let result = producer.oneshot(exchange).await.unwrap();
4460
4461 let status = result
4463 .input
4464 .header("CamelHttpResponseCode")
4465 .and_then(|v| v.as_u64())
4466 .unwrap();
4467 assert_eq!(
4468 status, 302,
4469 "Should NOT follow redirect when followRedirects=false"
4470 );
4471 }
4472
4473 #[tokio::test]
4474 async fn test_follow_redirects_true_follows_redirect() {
4475 use tower::ServiceExt;
4476
4477 let (url, _handle) = start_redirect_server().await;
4478 let ctx = test_producer_ctx();
4479
4480 let component =
4481 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4482 let endpoint_ctx = NoOpComponentContext;
4483 let endpoint = component
4484 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
4485 .unwrap();
4486 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4487
4488 let exchange = Exchange::new(Message::default());
4489 let result = producer.oneshot(exchange).await.unwrap();
4490
4491 let status = result
4493 .input
4494 .header("CamelHttpResponseCode")
4495 .and_then(|v| v.as_u64())
4496 .unwrap();
4497 assert_eq!(
4498 status, 200,
4499 "Should follow redirect when followRedirects=true"
4500 );
4501 }
4502
4503 #[tokio::test]
4506 async fn test_redirect_to_private_ip_is_ssrf_blocked() {
4507 use tower::ServiceExt;
4508
4509 let (url, _handle) = start_redirect_server().await;
4511 let ctx = test_producer_ctx();
4512
4513 let component =
4514 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4515 let endpoint_ctx = NoOpComponentContext;
4516 let endpoint = component
4517 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
4518 .unwrap();
4519 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4520
4521 let exchange = Exchange::new(Message::default());
4522 let result = producer.oneshot(exchange).await;
4523
4524 assert!(
4526 result.is_ok(),
4527 "Redirect should succeed with allowInternal=true, got: {:?}",
4528 result
4529 );
4530 let exchange = result.unwrap();
4531 let status = exchange
4532 .input
4533 .header("CamelHttpResponseCode")
4534 .and_then(|v| v.as_u64())
4535 .unwrap();
4536 assert_eq!(status, 200, "Should follow redirect to /final");
4537 }
4538
4539 #[tokio::test]
4541 async fn test_redirect_to_private_ip_allowed_when_configured() {
4542 use tower::ServiceExt;
4543
4544 let (url, _handle) = start_redirect_server().await;
4546 let ctx = test_producer_ctx();
4547
4548 let component =
4549 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4550 let endpoint_ctx = NoOpComponentContext;
4551 let endpoint = component
4552 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
4553 .unwrap();
4554 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4555
4556 let exchange = Exchange::new(Message::default());
4557 let result = producer.oneshot(exchange).await.unwrap();
4558
4559 let status = result
4560 .input
4561 .header("CamelHttpResponseCode")
4562 .and_then(|v| v.as_u64())
4563 .unwrap();
4564 assert_eq!(
4565 status, 200,
4566 "Should follow redirect to private IP when allowInternal=true"
4567 );
4568 }
4569
4570 #[tokio::test]
4573 async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
4574 use tower::ServiceExt;
4575
4576 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4578 let addr = listener.local_addr().unwrap();
4579 let url = format!("http://127.0.0.1:{}", addr.port());
4580
4581 let handle = tokio::spawn(async move {
4582 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4583 loop {
4584 if let Ok((mut stream, _)) = listener.accept().await {
4585 tokio::spawn(async move {
4586 let mut buf = vec![0u8; 4096];
4587 let _ = stream.read(&mut buf).await;
4588 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";
4590 let _ = stream.write_all(response.as_bytes()).await;
4591 });
4592 }
4593 }
4594 });
4595
4596 let ctx = test_producer_ctx();
4597 let component =
4598 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4599 let endpoint_ctx = NoOpComponentContext;
4600 let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
4602 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4603
4604 let exchange = Exchange::new(Message::default());
4605 let result = producer.oneshot(exchange).await;
4606
4607 assert!(
4609 result.is_err(),
4610 "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
4611 );
4612 let err = result.unwrap_err().to_string();
4613 assert!(
4614 err.contains("blocked IP")
4615 || err.contains("private IP")
4616 || err.contains("SSRF")
4617 || err.contains("not allowed"),
4618 "Error should mention SSRF/IP blocking, got: {err}"
4619 );
4620
4621 handle.abort();
4622 }
4623
4624 #[tokio::test]
4626 async fn test_too_many_redirects_returns_error() {
4627 use tower::ServiceExt;
4628
4629 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4631 let addr = listener.local_addr().unwrap();
4632 let url = format!("http://127.0.0.1:{}", addr.port());
4633
4634 let handle = tokio::spawn(async move {
4635 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4636 loop {
4637 if let Ok((mut stream, _)) = listener.accept().await {
4638 tokio::spawn(async move {
4639 let mut buf = vec![0u8; 4096];
4640 let _ = stream.read(&mut buf).await;
4641 let response = "HTTP/1.1 302 Found\r\nLocation: /loop\r\nConnection: close\r\nContent-Length: 0\r\n\r\n";
4646 let _ = stream.write_all(response.as_bytes()).await;
4647 });
4648 }
4649 }
4650 });
4651
4652 let ctx = test_producer_ctx();
4653 let component =
4654 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4655 let endpoint_ctx = NoOpComponentContext;
4656 let endpoint = component
4657 .create_endpoint(
4658 &format!("{url}?allowInternal=true&maxRedirects=2"),
4659 &endpoint_ctx,
4660 )
4661 .unwrap();
4662 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4663
4664 let exchange = Exchange::new(Message::default());
4665 let result = producer.oneshot(exchange).await;
4666
4667 match result {
4675 Err(e) => {
4676 let msg = e.to_string();
4678 assert!(
4679 msg.contains("HTTP operation failed") || msg.contains("302"),
4680 "expected redirect-after-exhaustion error, got: {msg}"
4681 );
4682 }
4683 Ok(ex) => {
4684 let response_code = ex
4685 .input
4686 .header("CamelHttpResponseCode")
4687 .and_then(|v| v.as_u64());
4688 assert_eq!(
4689 response_code,
4690 Some(302),
4691 "expected 302 after exhausting redirects"
4692 );
4693 }
4694 }
4695
4696 handle.abort();
4697 }
4698
4699 #[tokio::test]
4700 async fn test_query_params_forwarded_to_http_request() {
4701 use tower::ServiceExt;
4702
4703 let (url, _handle) = start_test_server().await;
4704 let ctx = test_producer_ctx();
4705
4706 let component = HttpComponent::new();
4707 let endpoint_ctx = NoOpComponentContext;
4708 let endpoint = component
4710 .create_endpoint(
4711 &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
4712 &endpoint_ctx,
4713 )
4714 .unwrap();
4715 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4716
4717 let exchange = Exchange::new(Message::default());
4718 let result = producer.oneshot(exchange).await.unwrap();
4719
4720 let status = result
4723 .input
4724 .header("CamelHttpResponseCode")
4725 .and_then(|v| v.as_u64())
4726 .unwrap();
4727 assert_eq!(status, 200);
4728 }
4729
4730 #[tokio::test]
4731 async fn test_non_camel_query_params_are_forwarded() {
4732 let config = HttpEndpointConfig::from_uri(
4735 "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
4736 )
4737 .unwrap();
4738
4739 assert!(
4741 config.query_params.contains_key("apiKey"),
4742 "apiKey should be preserved"
4743 );
4744 assert!(
4745 config.query_params.contains_key("token"),
4746 "token should be preserved"
4747 );
4748 assert_eq!(config.query_params.get("apiKey").unwrap(), "secret123");
4749 assert_eq!(config.query_params.get("token").unwrap(), "abc456");
4750
4751 assert!(
4753 !config.query_params.contains_key("httpMethod"),
4754 "httpMethod should not be forwarded"
4755 );
4756 }
4757
4758 #[test]
4759 fn test_query_params_are_url_encoded_when_resolving_url() {
4760 let config =
4761 HttpEndpointConfig::from_uri("http://example.com/api?q=hello world&tag=a+b").unwrap();
4762 let exchange = Exchange::new(Message::default());
4763
4764 let url = HttpProducer::resolve_url(&exchange, &config);
4765
4766 assert!(url.contains("q=hello+world"), "url was: {url}");
4767 assert!(url.contains("tag=a%2Bb"), "url was: {url}");
4768 }
4769
4770 async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
4775 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4776 let addr = listener.local_addr().unwrap();
4777 let url = format!("http://127.0.0.1:{}", addr.port());
4778
4779 let handle = tokio::spawn(async move {
4780 loop {
4781 if let Ok((mut stream, _)) = listener.accept().await {
4782 let delay = delay_ms;
4783 tokio::spawn(async move {
4784 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4785 let mut buf = vec![0u8; 4096];
4786 let _ = stream.read(&mut buf).await;
4787 let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
4789 let _ = stream.write_all(headers.as_bytes()).await;
4790 tokio::time::sleep(Duration::from_millis(delay)).await;
4792 let body = r#"{"status":"slow"}"#;
4793 let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
4794 let _ = stream.write_all(chunk.as_bytes()).await;
4795 });
4796 }
4797 }
4798 });
4799
4800 (url, handle)
4801 }
4802
4803 #[tokio::test]
4804 async fn test_http_producer_timeout() {
4805 use tower::ServiceExt;
4806
4807 let (url, _handle) = start_slow_server(500).await;
4809 let ctx = test_producer_ctx();
4810
4811 let component = HttpComponent::with_config(
4812 HttpConfig::default()
4813 .with_read_timeout_ms(100)
4814 .with_response_timeout_ms(30_000), );
4816 let endpoint_ctx = NoOpComponentContext;
4817 let endpoint = component
4818 .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
4819 .unwrap();
4820 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4821
4822 let exchange = Exchange::new(Message::default());
4823 let result = producer.oneshot(exchange).await;
4824
4825 assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
4826 let err = result.unwrap_err().to_string();
4827 assert!(
4828 err.contains("Read timeout") || err.contains("timeout"),
4829 "Error should mention timeout, got: {}",
4830 err
4831 );
4832 }
4833
4834 #[tokio::test]
4835 async fn test_http_producer_no_timeout_when_fast() {
4836 use tower::ServiceExt;
4837
4838 let (url, _handle) = start_test_server().await;
4839 let ctx = test_producer_ctx();
4840
4841 let component =
4842 HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
4843 let endpoint_ctx = NoOpComponentContext;
4844 let endpoint = component
4845 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4846 .unwrap();
4847 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4848
4849 let exchange = Exchange::new(Message::default());
4850 let result = producer.oneshot(exchange).await.unwrap();
4851
4852 let status = result
4853 .input
4854 .header("CamelHttpResponseCode")
4855 .and_then(|v| v.as_u64())
4856 .unwrap();
4857 assert_eq!(status, 200);
4858 }
4859
4860 #[tokio::test]
4865 async fn test_http_producer_blocks_metadata_endpoint() {
4866 use tower::ServiceExt;
4867
4868 let ctx = test_producer_ctx();
4869 let component = HttpComponent::new();
4870 let endpoint_ctx = NoOpComponentContext;
4871 let endpoint = component
4872 .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
4873 .unwrap();
4874 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4875
4876 let mut exchange = Exchange::new(Message::default());
4877 exchange.input.set_header(
4878 "CamelHttpUri",
4879 serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
4880 );
4881
4882 let result = producer.oneshot(exchange).await;
4883 assert!(result.is_err(), "Should block AWS metadata endpoint");
4884
4885 let err = result.unwrap_err();
4886 assert!(
4887 err.to_string().contains("Private IP"),
4888 "Error should mention private IP blocking, got: {}",
4889 err
4890 );
4891 }
4892
4893 #[test]
4894 fn test_ssrf_config_defaults() {
4895 let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
4896 assert!(
4897 !config.allow_internal,
4898 "Private IPs should be blocked by default"
4899 );
4900 assert!(
4901 config.blocked_hosts.is_empty(),
4902 "Blocked hosts should be empty by default"
4903 );
4904 }
4905
4906 #[test]
4907 fn test_ssrf_config_allow_internal() {
4908 let config =
4909 HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
4910 assert!(
4911 config.allow_internal,
4912 "Private IPs should be allowed when explicitly set"
4913 );
4914 }
4915
4916 #[test]
4917 fn test_ssrf_config_blocked_hosts() {
4918 let config = HttpEndpointConfig::from_uri(
4919 "http://example.com/api?blockedHosts=evil.com,malware.net",
4920 )
4921 .unwrap();
4922 assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
4923 }
4924
4925 #[tokio::test]
4926 async fn test_http_producer_blocks_localhost() {
4927 use tower::ServiceExt;
4928
4929 let ctx = test_producer_ctx();
4930 let component = HttpComponent::new();
4931 let endpoint_ctx = NoOpComponentContext;
4932 let endpoint = component
4933 .create_endpoint("http://example.com/api", &endpoint_ctx)
4934 .unwrap();
4935 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4936
4937 let mut exchange = Exchange::new(Message::default());
4938 exchange.input.set_header(
4939 "CamelHttpUri",
4940 serde_json::Value::String("http://localhost:8080/internal".to_string()),
4941 );
4942
4943 let result = producer.oneshot(exchange).await;
4944 assert!(result.is_err(), "Should block localhost");
4945 }
4946
4947 #[tokio::test]
4948 async fn test_http_producer_blocks_loopback_ip() {
4949 use tower::ServiceExt;
4950
4951 let ctx = test_producer_ctx();
4952 let component = HttpComponent::new();
4953 let endpoint_ctx = NoOpComponentContext;
4954 let endpoint = component
4955 .create_endpoint("http://example.com/api", &endpoint_ctx)
4956 .unwrap();
4957 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4958
4959 let mut exchange = Exchange::new(Message::default());
4960 exchange.input.set_header(
4961 "CamelHttpUri",
4962 serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
4963 );
4964
4965 let result = producer.oneshot(exchange).await;
4966 assert!(result.is_err(), "Should block loopback IP");
4967 }
4968
4969 #[tokio::test]
4970 async fn test_http_producer_allows_private_ip_when_enabled() {
4971 use tower::ServiceExt;
4972
4973 let ctx = test_producer_ctx();
4974 let component = HttpComponent::new();
4975 let endpoint_ctx = NoOpComponentContext;
4976 let endpoint = component
4979 .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
4980 .unwrap();
4981 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4982
4983 let exchange = Exchange::new(Message::default());
4984
4985 let result = producer.oneshot(exchange).await;
4988 if let Err(ref e) = result {
4990 let err_str = e.to_string();
4991 assert!(
4992 !err_str.contains("Private IP") && !err_str.contains("not allowed"),
4993 "Should not be SSRF error, got: {}",
4994 err_str
4995 );
4996 }
4997 }
4998
4999 #[test]
5004 fn test_http_server_config_parse() {
5005 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
5006 assert_eq!(cfg.host, "0.0.0.0");
5007 assert_eq!(cfg.port, 8080);
5008 assert_eq!(cfg.path, "/orders");
5009 assert_eq!(cfg.max_inflight_requests, 1024);
5010 }
5011
5012 #[test]
5013 fn test_http_server_config_scheme() {
5014 assert_eq!(HttpServerConfig::scheme(), "http");
5016 }
5017
5018 #[test]
5019 fn test_http_server_config_from_components() {
5020 let components = camel_component_api::UriComponents {
5022 scheme: "https".to_string(),
5023 path: "//0.0.0.0:8443/api".to_string(),
5024 params: std::collections::HashMap::from([
5025 ("maxRequestBody".to_string(), "5242880".to_string()),
5026 ("maxInflightRequests".to_string(), "7".to_string()),
5027 ]),
5028 };
5029 let cfg = HttpServerConfig::from_components(components).unwrap();
5030 assert_eq!(cfg.host, "0.0.0.0");
5031 assert_eq!(cfg.port, 8443);
5032 assert_eq!(cfg.path, "/api");
5033 assert_eq!(cfg.max_request_body, 5242880);
5034 assert_eq!(cfg.max_inflight_requests, 7);
5035 }
5036
5037 #[test]
5038 fn test_http_server_config_default_path() {
5039 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
5040 assert_eq!(cfg.path, "/");
5041 }
5042
5043 #[test]
5044 fn test_http_server_config_wrong_scheme() {
5045 assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
5046 }
5047
5048 #[test]
5049 fn test_http_server_config_invalid_port() {
5050 assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
5051 }
5052
5053 #[test]
5054 fn test_http_server_config_default_port_by_scheme() {
5055 let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
5057 assert_eq!(cfg_http.port, 80);
5058
5059 let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
5061 assert_eq!(cfg_https.port, 443);
5062 }
5063
5064 #[test]
5065 fn test_request_envelope_and_reply_are_send() {
5066 fn assert_send<T: Send>() {}
5067 assert_send::<RequestEnvelope>();
5068 assert_send::<HttpReply>();
5069 }
5070
5071 #[test]
5076 fn test_server_registry_global_is_singleton() {
5077 let r1 = ServerRegistry::global();
5078 let r2 = ServerRegistry::global();
5079 assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
5080 }
5081
5082 #[allow(clippy::await_holding_lock)]
5083 #[tokio::test]
5084 async fn test_concurrent_get_or_spawn_returns_same_registry() {
5085 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5086 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5087 let port = listener.local_addr().unwrap().port();
5088 drop(listener);
5089
5090 let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
5091 Arc::new(std::sync::Mutex::new(Vec::new()));
5092
5093 let mut handles = Vec::new();
5094 for _ in 0..4 {
5095 let results = results.clone();
5096 handles.push(tokio::spawn(async move {
5097 let registry = ServerRegistry::global()
5098 .get_or_spawn(
5099 "127.0.0.1",
5100 port,
5101 2 * 1024 * 1024,
5102 10 * 1024 * 1024,
5103 1024,
5104 test_rt(),
5105 "test-route".into(),
5106 None,
5107 )
5108 .await
5109 .unwrap();
5110 results.lock().unwrap().push(registry);
5111 }));
5112 }
5113
5114 for h in handles {
5115 h.await.unwrap();
5116 }
5117
5118 let registries = results.lock().unwrap();
5119 assert_eq!(registries.len(), 4);
5120 for i in 1..registries.len() {
5121 assert!(
5122 Arc::ptr_eq(®istries[0].inner, ®istries[i].inner),
5123 "all concurrent callers should get same route registry"
5124 );
5125 }
5126 }
5127
5128 #[test]
5129 fn test_server_registry_distinguishes_host_and_port() {
5130 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5131 let rt = tokio::runtime::Runtime::new().expect("runtime");
5132 rt.block_on(async {
5133 let registry = ServerRegistry::global();
5134 let d1 = registry
5138 .get_or_spawn(
5139 "127.0.0.1",
5140 0,
5141 1024 * 1024,
5142 10 * 1024 * 1024,
5143 1024,
5144 test_rt(),
5145 "test-route-1".into(),
5146 None,
5147 )
5148 .await;
5149 let d2 = registry
5150 .get_or_spawn(
5151 "0.0.0.0",
5152 0,
5153 1024 * 1024,
5154 10 * 1024 * 1024,
5155 1024,
5156 test_rt(),
5157 "test-route-2".into(),
5158 None,
5159 )
5160 .await;
5161 assert!(d1.is_ok());
5162 assert!(d2.is_ok());
5163 assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
5164 });
5165 }
5166
5167 #[allow(clippy::await_holding_lock)]
5168 #[tokio::test]
5169 async fn test_shared_server_max_request_body_policy_is_deterministic() {
5170 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5171 let registry = ServerRegistry::global();
5172 let d1 = registry
5174 .get_or_spawn(
5175 "127.0.0.1",
5176 9991,
5177 1024 * 1024,
5178 10 * 1024 * 1024,
5179 1024,
5180 test_rt(),
5181 "test-route".into(),
5182 None,
5183 )
5184 .await;
5185 assert!(d1.is_ok());
5186
5187 let d2 = registry
5190 .get_or_spawn(
5191 "127.0.0.1",
5192 9991,
5193 2 * 1024 * 1024,
5194 10 * 1024 * 1024,
5195 1024,
5196 test_rt(),
5197 "test-route-2".into(),
5198 None,
5199 )
5200 .await;
5201 assert!(d2.is_err());
5202 let err = d2.unwrap_err();
5203 assert!(
5204 err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
5205 "Expected incompatible maxRequestBody error, got: {}",
5206 err
5207 );
5208 }
5209
5210 #[test]
5211 fn test_server_registry_reset_clears_entries() {
5212 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5213 let rt = tokio::runtime::Runtime::new().expect("runtime");
5214 rt.block_on(async {
5215 let d1 = ServerRegistry::global()
5217 .get_or_spawn(
5218 "127.0.0.1",
5219 9992,
5220 1024 * 1024,
5221 10 * 1024 * 1024,
5222 1024,
5223 test_rt(),
5224 "test-route".into(),
5225 None,
5226 )
5227 .await;
5228 assert!(d1.is_ok());
5229
5230 let guard = ServerRegistry::global().inner.lock().expect("lock");
5232 assert!(guard.entries.contains_key(&("127.0.0.1".to_string(), 9992)));
5233 drop(guard);
5234
5235 ServerRegistry::reset();
5237
5238 let guard = ServerRegistry::global().inner.lock().expect("lock");
5240 assert!(
5241 guard.entries.is_empty(),
5242 "registry should be empty after reset, has {} entries",
5243 guard.entries.len()
5244 );
5245 });
5246 }
5247
5248 #[tokio::test]
5249 async fn registry_rejects_tls_on_plain_port() {
5250 ServerRegistry::reset();
5251 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
5252
5253 let _r1 = ServerRegistry::global()
5255 .get_or_spawn(
5256 "127.0.0.1",
5257 0,
5258 1024,
5259 1024,
5260 16,
5261 Arc::clone(&rt),
5262 "route-1".into(),
5263 None, )
5265 .await;
5266
5267 let result = ServerRegistry::global()
5269 .get_or_spawn(
5270 "127.0.0.1",
5271 0,
5272 1024,
5273 1024,
5274 16,
5275 Arc::clone(&rt),
5276 "route-2".into(),
5277 Some(crate::config::ServerTlsConfig {
5278 cert_path: "/x.pem".into(),
5279 key_path: "/y.pem".into(),
5280 }),
5281 )
5282 .await;
5283 assert!(result.is_err(), "must reject TLS on plain port");
5284 }
5285
5286 #[allow(clippy::await_holding_lock)]
5291 #[tokio::test]
5292 async fn test_unregister_last_http_route_keeps_server_alive() {
5293 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5294 ServerRegistry::reset();
5295 let registry = ServerRegistry::global();
5296
5297 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5298 let port = listener.local_addr().unwrap().port();
5299 drop(listener); let rt = test_rt();
5301
5302 let _r1 = registry
5305 .get_or_spawn(
5306 "127.0.0.1",
5307 port,
5308 1024 * 1024,
5309 10 * 1024 * 1024,
5310 16,
5311 rt.clone(),
5312 "test-route-1".into(),
5313 None,
5314 )
5315 .await
5316 .unwrap();
5317 let _r2 = registry
5318 .get_or_spawn(
5319 "127.0.0.1",
5320 port,
5321 1024 * 1024,
5322 10 * 1024 * 1024,
5323 16,
5324 rt,
5325 "test-route-2".into(),
5326 None,
5327 )
5328 .await
5329 .unwrap();
5330
5331 let key = ("127.0.0.1".to_string(), port);
5332 let cell = {
5333 let guard = registry.inner.lock().expect("lock");
5334 guard.entries.get(&key).expect("entry should exist").clone()
5335 };
5336
5337 registry.unregister("127.0.0.1", port).await;
5339 {
5340 let handle = cell
5341 .get()
5342 .expect("handle should still exist after first unregister");
5343 assert!(
5344 !handle.monitor_task.is_finished(),
5345 "monitor task should still be alive after first unregister"
5346 );
5347 }
5348
5349 registry.unregister("127.0.0.1", port).await;
5351 tokio::time::sleep(Duration::from_millis(20)).await;
5352 {
5353 let handle = cell
5354 .get()
5355 .expect("handle should still exist after last unregister");
5356 assert!(
5357 !handle.monitor_task.is_finished(),
5358 "monitor task should still be alive — server is process-lifetime"
5359 );
5360 }
5361
5362 {
5364 let guard = registry.inner.lock().expect("lock");
5365 assert!(
5366 guard.entries.contains_key(&key),
5367 "entry should remain in registry — server kept alive for restart"
5368 );
5369 }
5370 }
5371
5372 async fn clone_fixture_listener() -> (
5381 tokio::net::TcpListener,
5382 std::net::TcpListener,
5383 std::net::SocketAddr,
5384 ) {
5385 let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind std listener");
5386 let probe = l.try_clone().expect("clone probe");
5387 l.set_nonblocking(true).expect("set_nonblocking");
5388 let listener = tokio::net::TcpListener::from_std(l).expect("from_std");
5389 let addr = listener.local_addr().expect("local_addr");
5390 (listener, probe, addr)
5391 }
5392
5393 fn staged_limits() -> (usize, usize, usize) {
5395 (1024 * 1024, 10 * 1024 * 1024, 1024)
5396 }
5397
5398 #[allow(clippy::await_holding_lock)]
5399 #[tokio::test]
5400 async fn staged_listener_first_spawn_serves_without_second_bind() {
5401 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5402 ServerRegistry::reset();
5403 let registry = ServerRegistry::global();
5404 let (listener, _probe, addr) = clone_fixture_listener().await;
5405 let port = addr.port();
5406 registry
5407 .stage_listener(listener)
5408 .await
5409 .expect("stage listener");
5410
5411 let (max_req, max_res, max_inflight) = staged_limits();
5412 let routes = registry
5413 .get_or_spawn(
5414 "127.0.0.1",
5415 port,
5416 max_req,
5417 max_res,
5418 max_inflight,
5419 test_rt(),
5420 "staged-first-spawn".into(),
5421 None,
5422 )
5423 .await
5424 .expect("spawn from staged listener must succeed");
5425
5426 assert_eq!(
5427 registry.bound_addr("127.0.0.1", port),
5428 Some(addr),
5429 "served socket must be the staged listener's addr"
5430 );
5431 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__staged_probe__"))
5434 .await
5435 .expect("http request against staged listener must connect");
5436 assert!(
5437 resp.status().as_u16() >= 200,
5438 "any status proves the staged socket serves"
5439 );
5440 drop(routes);
5441 }
5442
5443 #[allow(clippy::await_holding_lock)]
5444 #[tokio::test]
5445 async fn staged_entry_reused_by_second_caller() {
5446 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5447 ServerRegistry::reset();
5448 let registry = ServerRegistry::global();
5449 let (listener, _probe, addr) = clone_fixture_listener().await;
5450 let port = addr.port();
5451 registry
5452 .stage_listener(listener)
5453 .await
5454 .expect("stage listener");
5455
5456 let (max_req, max_res, max_inflight) = staged_limits();
5457 let first = registry
5458 .get_or_spawn(
5459 "127.0.0.1",
5460 port,
5461 max_req,
5462 max_res,
5463 max_inflight,
5464 test_rt(),
5465 "staged-reuse-1".into(),
5466 None,
5467 )
5468 .await
5469 .expect("first spawn from staged listener");
5470 let second = registry
5471 .get_or_spawn(
5472 "127.0.0.1",
5473 port,
5474 max_req,
5475 max_res,
5476 max_inflight,
5477 test_rt(),
5478 "staged-reuse-2".into(),
5479 None,
5480 )
5481 .await
5482 .expect("second caller must reuse the entry");
5483 assert_eq!(
5484 registry.bound_addr("127.0.0.1", port),
5485 Some(addr),
5486 "entry reused — bound addr unchanged, no second bind"
5487 );
5488 drop(first);
5489 drop(second);
5490 }
5491
5492 #[allow(clippy::await_holding_lock)]
5493 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5494 async fn staged_race_two_callers_single_resolver() {
5495 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5496 ServerRegistry::reset();
5497 let registry = ServerRegistry::global();
5498 let (listener, _probe, addr) = clone_fixture_listener().await;
5499 let port = addr.port();
5500 registry
5501 .stage_listener(listener)
5502 .await
5503 .expect("stage listener");
5504
5505 let (max_req, max_res, max_inflight) = staged_limits();
5510 let (first, second) = tokio::join!(
5511 registry.get_or_spawn(
5512 "127.0.0.1",
5513 port,
5514 max_req,
5515 max_res,
5516 max_inflight,
5517 test_rt(),
5518 "staged-race-1".into(),
5519 None,
5520 ),
5521 registry.get_or_spawn(
5522 "127.0.0.1",
5523 port,
5524 max_req,
5525 max_res,
5526 max_inflight,
5527 test_rt(),
5528 "staged-race-2".into(),
5529 None,
5530 ),
5531 );
5532 let first = first.expect("first racing caller must succeed");
5533 let second = second.expect("second racing caller must succeed");
5534 assert_eq!(
5535 registry.bound_addr("127.0.0.1", port),
5536 Some(addr),
5537 "single entry must be served from the staged socket — no EADDRINUSE path"
5538 );
5539 drop(first);
5540 drop(second);
5541 }
5542
5543 #[allow(clippy::await_holding_lock)]
5544 #[tokio::test]
5545 async fn unstaged_spawn_binds_legacy() {
5546 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5547 ServerRegistry::reset();
5548 let registry = ServerRegistry::global();
5549 let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe bind");
5551 let port = probe.local_addr().expect("local addr").port();
5552 drop(probe);
5553
5554 let (max_req, max_res, max_inflight) = staged_limits();
5555 registry
5556 .get_or_spawn(
5557 "127.0.0.1",
5558 port,
5559 max_req,
5560 max_res,
5561 max_inflight,
5562 test_rt(),
5563 "legacy-bind".into(),
5564 None,
5565 )
5566 .await
5567 .expect("legacy bind spawn");
5568 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__legacy_probe__"))
5569 .await
5570 .expect("connect to freshly bound port must succeed");
5571 assert!(resp.status().as_u16() >= 200);
5572 assert_eq!(
5573 registry.bound_addr("127.0.0.1", port),
5574 Some(std::net::SocketAddr::from(([127, 0, 0, 1], port))),
5575 "bound addr must be the legacy bound (host, port)"
5576 );
5577 }
5578
5579 #[allow(clippy::await_holding_lock)]
5580 #[tokio::test]
5581 async fn wrong_host_staged_port_fails_deterministically() {
5582 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5583 ServerRegistry::reset();
5584 let registry = ServerRegistry::global();
5585 let (listener, _probe, addr) = clone_fixture_listener().await;
5586 let port = addr.port();
5587 registry
5588 .stage_listener(listener)
5589 .await
5590 .expect("stage listener under 127.0.0.1");
5591
5592 let (max_req, max_res, max_inflight) = staged_limits();
5593 let err = registry
5594 .get_or_spawn(
5595 "localhost",
5596 port,
5597 max_req,
5598 max_res,
5599 max_inflight,
5600 test_rt(),
5601 "conflict-probe".into(),
5602 None,
5603 )
5604 .await
5605 .expect_err("wrong host on staged port must fail deterministically");
5606 assert!(
5607 err.to_string().contains("staged listener conflict on port"),
5608 "unexpected error: {err}"
5609 );
5610
5611 registry
5613 .get_or_spawn(
5614 "127.0.0.1",
5615 port,
5616 max_req,
5617 max_res,
5618 max_inflight,
5619 test_rt(),
5620 "conflict-after".into(),
5621 None,
5622 )
5623 .await
5624 .expect("correct host must serve the staged listener");
5625 assert_eq!(
5626 registry.bound_addr("127.0.0.1", port),
5627 Some(addr),
5628 "staged slot must be untouched by the conflicting call"
5629 );
5630 }
5631
5632 #[allow(clippy::await_holding_lock)]
5633 #[tokio::test]
5634 async fn duplicate_stage_same_key_rejected() {
5635 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5636 ServerRegistry::reset();
5637 let registry = ServerRegistry::global();
5638 let (listener, probe, addr) = clone_fixture_listener().await;
5639 registry
5640 .stage_listener(listener)
5641 .await
5642 .expect("stage listener A");
5643
5644 let dup = probe.try_clone().expect("clone2");
5646 dup.set_nonblocking(true).expect("set_nonblocking2");
5647 let b = tokio::net::TcpListener::from_std(dup).expect("from_std2");
5648
5649 let err = registry
5650 .stage_listener(b)
5651 .await
5652 .expect_err("duplicate stage must be rejected");
5653 assert!(
5654 err.to_string().contains("listener already staged"),
5655 "unexpected error: {err}"
5656 );
5657
5658 let (max_req, max_res, max_inflight) = staged_limits();
5659 registry
5660 .get_or_spawn(
5661 "127.0.0.1",
5662 addr.port(),
5663 max_req,
5664 max_res,
5665 max_inflight,
5666 test_rt(),
5667 "dup-stage-after".into(),
5668 None,
5669 )
5670 .await
5671 .expect("spawn from first staged listener");
5672 assert_eq!(
5673 registry.bound_addr("127.0.0.1", addr.port()),
5674 Some(addr),
5675 "first staged listener retained"
5676 );
5677 }
5678
5679 #[allow(clippy::await_holding_lock)]
5680 #[tokio::test]
5681 async fn distinct_keys_stage_independently() {
5682 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5683 ServerRegistry::reset();
5684 let registry = ServerRegistry::global();
5685 let (l1, _p1, addr1) = clone_fixture_listener().await;
5686 let (l2, _p2, addr2) = clone_fixture_listener().await;
5687 registry.stage_listener(l1).await.expect("stage P1");
5688 registry.stage_listener(l2).await.expect("stage P2");
5689
5690 let (max_req, max_res, max_inflight) = staged_limits();
5691 registry
5692 .get_or_spawn(
5693 "127.0.0.1",
5694 addr1.port(),
5695 max_req,
5696 max_res,
5697 max_inflight,
5698 test_rt(),
5699 "distinct-1".into(),
5700 None,
5701 )
5702 .await
5703 .expect("spawn P1");
5704 registry
5705 .get_or_spawn(
5706 "127.0.0.1",
5707 addr2.port(),
5708 max_req,
5709 max_res,
5710 max_inflight,
5711 test_rt(),
5712 "distinct-2".into(),
5713 None,
5714 )
5715 .await
5716 .expect("spawn P2");
5717 assert_eq!(
5718 registry.bound_addr("127.0.0.1", addr1.port()),
5719 Some(addr1),
5720 "P1 bound addr must be its own listener"
5721 );
5722 assert_eq!(
5723 registry.bound_addr("127.0.0.1", addr2.port()),
5724 Some(addr2),
5725 "P2 bound addr must be its own listener"
5726 );
5727 let r1 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr1.port()))
5728 .await
5729 .expect("connect P1");
5730 assert!(r1.status().as_u16() >= 200);
5731 let r2 = reqwest::get(format!("http://127.0.0.1:{}/__distinct__", addr2.port()))
5732 .await
5733 .expect("connect P2");
5734 assert!(r2.status().as_u16() >= 200);
5735 }
5736
5737 #[allow(clippy::await_holding_lock)]
5738 #[tokio::test]
5739 async fn tls_prebound_listener_served() {
5740 use camel_component_api::test_support::tls;
5741
5742 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
5745
5746 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5747 ServerRegistry::reset();
5748 let registry = ServerRegistry::global();
5749 let (listener, _probe, addr) = clone_fixture_listener().await;
5750 let port = addr.port();
5751
5752 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
5753 let cert_path = tls::write_pem_tmp("http-staged-tls-cert.pem", &cert_pem);
5754 let key_path = tls::write_pem_tmp("http-staged-tls-key.pem", &key_pem);
5755 let ca_path = tls::write_pem_tmp("http-staged-tls-ca.pem", &ca_pem);
5756
5757 let (max_req, max_res, max_inflight) = staged_limits();
5758 let routes = registry
5759 .get_or_spawn_with_listener(
5760 listener,
5761 max_req,
5762 max_res,
5763 max_inflight,
5764 test_rt(),
5765 "staged-tls".into(),
5766 Some(crate::config::ServerTlsConfig {
5767 cert_path: cert_path.to_string_lossy().into_owned(),
5768 key_path: key_path.to_string_lossy().into_owned(),
5769 }),
5770 )
5771 .await
5772 .expect("spawn TLS server from pre-bound listener");
5773
5774 let ca_bytes = std::fs::read(&ca_path).expect("read ca pem");
5777 let client = reqwest::Client::builder()
5778 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).expect("parse ca pem"))
5779 .build()
5780 .expect("build tls client");
5781
5782 let resp = client
5783 .get(format!("https://127.0.0.1:{port}/__staged_tls__"))
5784 .send()
5785 .await
5786 .expect("TLS handshake + request must succeed");
5787 assert_eq!(resp.status().as_u16(), 404, "unknown path 404s through TLS");
5788 assert_eq!(
5789 registry.bound_addr("127.0.0.1", port),
5790 Some(addr),
5791 "bound addr equals the pre-bound listener addr"
5792 );
5793 drop(routes);
5794 }
5795
5796 #[allow(clippy::await_holding_lock)]
5797 #[tokio::test]
5798 async fn with_listener_direct_spawn_keyed_by_actual_addr() {
5799 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5800 ServerRegistry::reset();
5801 let registry = ServerRegistry::global();
5802 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
5803 .await
5804 .expect("bind un-staged listener");
5805 let addr = listener.local_addr().expect("local addr");
5806 let port = addr.port();
5807
5808 let (max_req, max_res, max_inflight) = staged_limits();
5809 registry
5810 .get_or_spawn_with_listener(
5811 listener,
5812 max_req,
5813 max_res,
5814 max_inflight,
5815 test_rt(),
5816 "with-listener".into(),
5817 None,
5818 )
5819 .await
5820 .expect("direct spawn from un-staged listener");
5821 let resp = reqwest::get(format!("http://127.0.0.1:{port}/__with_listener__"))
5822 .await
5823 .expect("connect on actual port");
5824 assert!(resp.status().as_u16() >= 200);
5825 assert_eq!(
5826 registry.bound_addr("127.0.0.1", port),
5827 Some(addr),
5828 "registry key is the listener's actual port"
5829 );
5830
5831 registry
5832 .get_or_spawn(
5833 "127.0.0.1",
5834 port,
5835 max_req,
5836 max_res,
5837 max_inflight,
5838 test_rt(),
5839 "with-listener-reuse".into(),
5840 None,
5841 )
5842 .await
5843 .expect("legacy caller must reuse the entry");
5844 assert_eq!(
5845 registry.bound_addr("127.0.0.1", port),
5846 Some(addr),
5847 "entry reused — no second bind"
5848 );
5849 }
5850
5851 #[tokio::test]
5856 async fn test_dispatch_handler_returns_404_for_unknown_path() {
5857 let registry = HttpRouteRegistry::new();
5858 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5860 let port = listener.local_addr().unwrap().port();
5861 tokio::spawn(run_axum_server(
5862 listener,
5863 registry,
5864 2 * 1024 * 1024,
5865 10 * 1024 * 1024,
5866 Arc::new(tokio::sync::Semaphore::new(1024)),
5867 test_rt(),
5868 "test-route".into(),
5869 ));
5870
5871 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
5873
5874 let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
5875 .await
5876 .unwrap();
5877 assert_eq!(resp.status().as_u16(), 404);
5878 }
5879
5880 #[tokio::test]
5885 async fn test_http_consumer_start_registers_path() {
5886 use camel_component_api::ConsumerContext;
5887
5888 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5890 let port = listener.local_addr().unwrap().port();
5891 drop(listener); let consumer_cfg = HttpServerConfig {
5894 scheme: "http".to_string(),
5895 host: "127.0.0.1".to_string(),
5896 port,
5897 path: "/ping".to_string(),
5898 max_request_body: 2 * 1024 * 1024,
5899 max_response_body: 10 * 1024 * 1024,
5900 max_inflight_requests: 1024,
5901 method: None,
5902 tls_config: None,
5903 };
5904 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5905
5906 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
5907 let token = tokio_util::sync::CancellationToken::new();
5908 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5909
5910 tokio::spawn(async move {
5911 consumer.start(ctx).await.unwrap();
5912 });
5913
5914 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5915
5916 let client = reqwest::Client::new();
5917 let resp_future = client
5918 .post(format!("http://127.0.0.1:{port}/ping"))
5919 .body("hello world")
5920 .send();
5921
5922 let (http_result, _) = tokio::join!(resp_future, async {
5923 if let Some(mut envelope) = rx.recv().await {
5924 envelope.exchange.input.set_header(
5926 "CamelHttpResponseCode",
5927 serde_json::Value::Number(201.into()),
5928 );
5929 if let Some(reply_tx) = envelope.reply_tx {
5930 let _ = reply_tx.send(Ok(envelope.exchange));
5931 }
5932 }
5933 });
5934
5935 let resp = http_result.unwrap();
5936 assert_eq!(resp.status().as_u16(), 201);
5937
5938 token.cancel();
5939 }
5940
5941 #[test]
5945 fn test_envelope_channel_capacity_follows_max_inflight() {
5946 assert_eq!(envelope_channel_capacity(0), 1);
5947 assert_eq!(envelope_channel_capacity(1), 1);
5948 assert_eq!(envelope_channel_capacity(7), 7);
5949 assert_eq!(envelope_channel_capacity(64), 64);
5950 assert_eq!(envelope_channel_capacity(1024), 1024);
5951 }
5952
5953 #[tokio::test]
5957 async fn test_http_consumer_start_with_zero_max_inflight_rejects_503() {
5958 use camel_component_api::ConsumerContext;
5959
5960 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5961 let port = listener.local_addr().unwrap().port();
5962 drop(listener);
5963
5964 let consumer_cfg = HttpServerConfig {
5965 scheme: "http".to_string(),
5966 host: "127.0.0.1".to_string(),
5967 port,
5968 path: "/ping".to_string(),
5969 max_request_body: 2 * 1024 * 1024,
5970 max_response_body: 10 * 1024 * 1024,
5971 max_inflight_requests: 0,
5972 method: None,
5973 tls_config: None,
5974 };
5975 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5976
5977 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
5978 let token = tokio_util::sync::CancellationToken::new();
5979 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5980
5981 let start_handle = tokio::spawn(async move {
5982 consumer.start(ctx).await.unwrap();
5983 });
5984
5985 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5986
5987 let client = reqwest::Client::new();
5988 let resp = client
5989 .post(format!("http://127.0.0.1:{port}/ping"))
5990 .body("hello world")
5991 .send()
5992 .await
5993 .unwrap();
5994 assert_eq!(resp.status().as_u16(), 503);
5995
5996 token.cancel();
5997 let _ = start_handle.await;
5998 }
5999
6000 #[test]
6003 fn test_http_consumer_startup_mode_is_explicit() {
6004 use camel_component_api::ConsumerStartupMode;
6005 let consumer_cfg = HttpServerConfig {
6006 scheme: "http".to_string(),
6007 host: "127.0.0.1".to_string(),
6008 port: 0,
6009 path: "/x".to_string(),
6010 max_request_body: 2 * 1024 * 1024,
6011 max_response_body: 10 * 1024 * 1024,
6012 max_inflight_requests: 1024,
6013 method: None,
6014 tls_config: None,
6015 };
6016 let consumer = HttpConsumer::new(consumer_cfg, test_rt());
6017 assert_eq!(
6018 consumer.startup_mode(),
6019 ConsumerStartupMode::Explicit,
6020 "HttpConsumer must opt into Explicit startup"
6021 );
6022 }
6023
6024 #[allow(clippy::await_holding_lock)]
6030 #[tokio::test]
6031 async fn test_http_consumer_emits_mark_ready_after_bind() {
6032 use camel_component_api::{ConsumerContext, StartupSignal};
6033
6034 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6035
6036 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6037 let port = listener.local_addr().unwrap().port();
6038 drop(listener);
6039
6040 let consumer_cfg = HttpServerConfig {
6041 scheme: "http".to_string(),
6042 host: "127.0.0.1".to_string(),
6043 port,
6044 path: "/ready-probe".to_string(),
6045 max_request_body: 2 * 1024 * 1024,
6046 max_response_body: 10 * 1024 * 1024,
6047 max_inflight_requests: 1024,
6048 method: None,
6049 tls_config: None,
6050 };
6051 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6052
6053 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6054 let token = tokio_util::sync::CancellationToken::new();
6055 let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
6056
6057 let (signal, startup_rx) = StartupSignal::pair();
6059 let ctx = ctx.with_startup(signal);
6060
6061 tokio::spawn(async move {
6064 let _ = consumer.start(ctx).await;
6065 });
6066
6067 let result =
6072 tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
6073 .await
6074 .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
6075 assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
6076
6077 token.cancel();
6079 }
6080
6081 #[tokio::test]
6082 async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
6083 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6084
6085 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6086 let port = listener.local_addr().unwrap().port();
6087 drop(listener);
6088
6089 let consumer_cfg = HttpServerConfig {
6090 scheme: "http".to_string(),
6091 host: "127.0.0.1".to_string(),
6092 port,
6093 path: "/saturation".to_string(),
6094 max_request_body: 2 * 1024 * 1024,
6095 max_response_body: 10 * 1024 * 1024,
6096 max_inflight_requests: 1,
6097 method: None,
6098 tls_config: None,
6099 };
6100 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6101
6102 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6103 let token = tokio_util::sync::CancellationToken::new();
6104 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6105 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6106 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6107
6108 let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
6109 let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
6110
6111 tokio::spawn(async move {
6112 let mut first_seen_tx = Some(first_seen_tx);
6113 let mut unblock_first_rx = Some(unblock_first_rx);
6114
6115 while let Some(envelope) = rx.recv().await {
6116 if let Some(tx) = first_seen_tx.take() {
6117 let _ = tx.send(());
6118 if let Some(rx_unblock) = unblock_first_rx.take() {
6119 let _ = rx_unblock.await;
6120 }
6121 }
6122
6123 if let Some(reply_tx) = envelope.reply_tx {
6124 let _ = reply_tx.send(Ok(envelope.exchange));
6125 }
6126 }
6127 });
6128
6129 let client = reqwest::Client::new();
6130 let first_req = {
6131 let client = client.clone();
6132 async move {
6133 client
6134 .get(format!("http://127.0.0.1:{port}/saturation"))
6135 .send()
6136 .await
6137 .unwrap()
6138 }
6139 };
6140
6141 let first_handle = tokio::spawn(first_req);
6142 first_seen_rx.await.unwrap();
6143
6144 let second_resp = client
6145 .get(format!("http://127.0.0.1:{port}/saturation"))
6146 .send()
6147 .await
6148 .unwrap();
6149
6150 assert_eq!(second_resp.status().as_u16(), 503);
6151
6152 let _ = unblock_first_tx.send(());
6153 let first_resp = first_handle.await.unwrap();
6154 assert_eq!(first_resp.status().as_u16(), 200);
6155
6156 token.cancel();
6157 }
6158
6159 #[tokio::test]
6163 async fn test_http_consumer_chunked_body_is_capped() {
6164 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6165
6166 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6167 let port = listener.local_addr().unwrap().port();
6168 drop(listener);
6169
6170 let consumer_cfg = HttpServerConfig {
6171 scheme: "http".to_string(),
6172 host: "127.0.0.1".to_string(),
6173 port,
6174 path: "/chunked-cap".to_string(),
6175 max_request_body: 1024, max_response_body: 10 * 1024 * 1024,
6177 max_inflight_requests: 16,
6178 method: None,
6179 tls_config: None,
6180 };
6181 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6182
6183 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6184 let token = tokio_util::sync::CancellationToken::new();
6185 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6186 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6187 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6188
6189 let chunks: Vec<Result<bytes::Bytes, std::io::Error>> = (0..8)
6191 .map(|_| Ok(bytes::Bytes::from(vec![b'A'; 512])))
6192 .collect();
6193 let stream_body = reqwest::Body::wrap_stream(futures::stream::iter(chunks));
6194
6195 let client = reqwest::Client::new();
6196 let send_fut = client
6197 .post(format!("http://127.0.0.1:{port}/chunked-cap"))
6198 .body(stream_body)
6199 .send();
6200
6201 let (http_result, _) = tokio::join!(send_fut, async {
6202 if let Some(mut envelope) = rx.recv().await {
6203 let materialized = envelope
6205 .exchange
6206 .input
6207 .body
6208 .clone()
6209 .into_bytes(64 * 1024)
6210 .await;
6211 assert!(
6212 materialized.is_err(),
6213 "materializing a 4 KiB chunked body under a 1 KiB cap must fail"
6214 );
6215 let err = materialized.unwrap_err().to_string();
6216 assert!(
6217 err.contains("limit") || err.contains("exceeds"),
6218 "error should mention the limit: {err}"
6219 );
6220 if let Some(reply_tx) = envelope.reply_tx {
6221 envelope.exchange.input.body =
6222 camel_component_api::Body::Text("handled".to_string());
6223 let _ = reply_tx.send(Ok(envelope.exchange));
6224 }
6225 }
6226 });
6227
6228 let resp = http_result.unwrap();
6229 assert_eq!(resp.status().as_u16(), 200);
6230
6231 token.cancel();
6232 }
6233
6234 #[tokio::test]
6235 #[allow(clippy::await_holding_lock)]
6236 async fn test_http_consumer_enforces_max_response_body_for_bytes() {
6237 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6238
6239 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6240
6241 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6242 let port = listener.local_addr().unwrap().port();
6243 drop(listener);
6244
6245 let consumer_cfg = HttpServerConfig {
6246 scheme: "http".to_string(),
6247 host: "127.0.0.1".to_string(),
6248 port,
6249 path: "/limit-bytes".to_string(),
6250 max_request_body: 2 * 1024 * 1024,
6251 max_response_body: 16,
6252 max_inflight_requests: 1024,
6253 method: None,
6254 tls_config: None,
6255 };
6256 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6257
6258 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6259 let token = tokio_util::sync::CancellationToken::new();
6260 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6261 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6262 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6263
6264 let client = reqwest::Client::new();
6265 let send_fut = client
6266 .get(format!("http://127.0.0.1:{port}/limit-bytes"))
6267 .send();
6268
6269 let (http_result, _) = tokio::join!(send_fut, async {
6270 if let Some(mut envelope) = rx.recv().await {
6271 envelope.exchange.input.body =
6272 camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
6273 if let Some(reply_tx) = envelope.reply_tx {
6274 let _ = reply_tx.send(Ok(envelope.exchange));
6275 }
6276 }
6277 });
6278
6279 let resp = http_result.unwrap();
6280 assert_eq!(resp.status().as_u16(), 500);
6281 let body = resp.text().await.unwrap();
6282 assert_eq!(body, "Response body exceeds configured limit");
6283 token.cancel();
6284 }
6285
6286 #[tokio::test]
6287 #[allow(clippy::await_holding_lock)]
6288 async fn test_http_consumer_enforces_max_response_body_for_json() {
6289 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6290
6291 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6292
6293 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6294 let port = listener.local_addr().unwrap().port();
6295 drop(listener);
6296
6297 let consumer_cfg = HttpServerConfig {
6298 scheme: "http".to_string(),
6299 host: "127.0.0.1".to_string(),
6300 port,
6301 path: "/limit-json".to_string(),
6302 max_request_body: 2 * 1024 * 1024,
6303 max_response_body: 16,
6304 max_inflight_requests: 1024,
6305 method: None,
6306 tls_config: None,
6307 };
6308 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6309
6310 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6311 let token = tokio_util::sync::CancellationToken::new();
6312 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6313 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6314 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6315
6316 let client = reqwest::Client::new();
6317 let send_fut = client
6318 .get(format!("http://127.0.0.1:{port}/limit-json"))
6319 .send();
6320
6321 let (http_result, _) = tokio::join!(send_fut, async {
6322 if let Some(mut envelope) = rx.recv().await {
6323 envelope.exchange.input.body = camel_component_api::Body::Json(
6324 serde_json::json!({"message":"this response is bigger than sixteen"}),
6325 );
6326 if let Some(reply_tx) = envelope.reply_tx {
6327 let _ = reply_tx.send(Ok(envelope.exchange));
6328 }
6329 }
6330 });
6331
6332 let resp = http_result.unwrap();
6333 assert_eq!(resp.status().as_u16(), 500);
6334 let body = resp.text().await.unwrap();
6335 assert_eq!(body, "Response body exceeds configured limit");
6336 token.cancel();
6337 }
6338
6339 #[tokio::test]
6340 #[allow(clippy::await_holding_lock)]
6341 async fn test_http_consumer_enforces_max_response_body_for_xml() {
6342 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6343
6344 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6345
6346 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6347 let port = listener.local_addr().unwrap().port();
6348 drop(listener);
6349
6350 let consumer_cfg = HttpServerConfig {
6351 scheme: "http".to_string(),
6352 host: "127.0.0.1".to_string(),
6353 port,
6354 path: "/limit-xml".to_string(),
6355 max_request_body: 2 * 1024 * 1024,
6356 max_response_body: 16,
6357 max_inflight_requests: 1024,
6358 method: None,
6359 tls_config: None,
6360 };
6361 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6362
6363 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6364 let token = tokio_util::sync::CancellationToken::new();
6365 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6366 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6367 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6368
6369 let client = reqwest::Client::new();
6370 let send_fut = client
6371 .get(format!("http://127.0.0.1:{port}/limit-xml"))
6372 .send();
6373
6374 let (http_result, _) = tokio::join!(send_fut, async {
6375 if let Some(mut envelope) = rx.recv().await {
6376 envelope.exchange.input.body = camel_component_api::Body::Xml(
6377 "<root><value>way-too-large</value></root>".into(),
6378 );
6379 if let Some(reply_tx) = envelope.reply_tx {
6380 let _ = reply_tx.send(Ok(envelope.exchange));
6381 }
6382 }
6383 });
6384
6385 let resp = http_result.unwrap();
6386 assert_eq!(resp.status().as_u16(), 500);
6387 let body = resp.text().await.unwrap();
6388 assert_eq!(body, "Response body exceeds configured limit");
6389 token.cancel();
6390 }
6391
6392 #[tokio::test]
6393 #[allow(clippy::await_holding_lock)]
6394 async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
6395 use camel_component_api::{
6396 CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
6397 };
6398 use futures::stream;
6399
6400 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6401
6402 let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
6403 let port = listener.local_addr().unwrap().port();
6404 drop(listener);
6405
6406 let consumer_cfg = HttpServerConfig {
6407 scheme: "http".to_string(),
6408 host: "0.0.0.0".to_string(),
6409 port,
6410 path: "/limit-stream".to_string(),
6411 max_request_body: 2 * 1024 * 1024,
6412 max_response_body: 16,
6413 max_inflight_requests: 1024,
6414 method: None,
6415 tls_config: None,
6416 };
6417 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6418
6419 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6420 let token = tokio_util::sync::CancellationToken::new();
6421 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6422 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6423 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6424
6425 let client = reqwest::Client::new();
6426 let send_fut = client
6427 .get(format!("http://127.0.0.1:{port}/limit-stream"))
6428 .send();
6429
6430 let (http_result, _) = tokio::join!(send_fut, async {
6431 if let Some(mut envelope) = rx.recv().await {
6432 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
6433 vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
6434 let stream = Box::pin(stream::iter(chunks));
6435 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
6436 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
6437 metadata: StreamMetadata {
6438 size_hint: Some(32),
6439 content_type: Some("application/octet-stream".into()),
6440 origin: None,
6441 },
6442 });
6443 if let Some(reply_tx) = envelope.reply_tx {
6444 let _ = reply_tx.send(Ok(envelope.exchange));
6445 }
6446 }
6447 });
6448
6449 let resp = http_result.unwrap();
6450 assert_eq!(resp.status().as_u16(), 200);
6451 let body = resp.bytes().await.unwrap();
6452 assert_eq!(body.len(), 32);
6453 token.cancel();
6454 }
6455
6456 #[tokio::test]
6461 #[allow(clippy::await_holding_lock)]
6462 async fn test_integration_single_consumer_round_trip() {
6463 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6464
6465 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6469
6470 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6472 let port = listener.local_addr().unwrap().port();
6473 drop(listener); let component = HttpComponent::new();
6476 let endpoint_ctx = NoOpComponentContext;
6477 let endpoint = component
6478 .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
6479 .unwrap();
6480 let mut consumer = endpoint.create_consumer(rt()).unwrap();
6481
6482 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6483 let token = tokio_util::sync::CancellationToken::new();
6484 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6485
6486 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6487 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6488
6489 let client = reqwest::Client::new();
6490 let send_fut = client
6491 .post(format!("http://127.0.0.1:{port}/echo"))
6492 .header("Content-Type", "text/plain")
6493 .body("ping")
6494 .send();
6495
6496 let (http_result, _) = tokio::join!(send_fut, async {
6497 if let Some(mut envelope) = rx.recv().await {
6498 assert_eq!(
6499 envelope.exchange.input.header("CamelHttpMethod"),
6500 Some(&serde_json::Value::String("POST".into()))
6501 );
6502 assert_eq!(
6503 envelope.exchange.input.header("CamelHttpPath"),
6504 Some(&serde_json::Value::String("/echo".into()))
6505 );
6506 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
6507 if let Some(reply_tx) = envelope.reply_tx {
6508 let _ = reply_tx.send(Ok(envelope.exchange));
6509 }
6510 }
6511 });
6512
6513 let resp = http_result.unwrap();
6514 assert_eq!(resp.status().as_u16(), 200);
6515 let body = resp.text().await.unwrap();
6516 assert_eq!(body, "pong");
6517
6518 token.cancel();
6519 }
6520
6521 #[tokio::test]
6522 #[allow(clippy::await_holding_lock)]
6523 async fn test_integration_two_consumers_shared_port() {
6524 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6525
6526 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6527
6528 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6530 let port = listener.local_addr().unwrap().port();
6531 drop(listener);
6532
6533 let component = HttpComponent::new();
6534 let endpoint_ctx = NoOpComponentContext;
6535
6536 let endpoint_a = component
6538 .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
6539 .unwrap();
6540 let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
6541
6542 let endpoint_b = component
6544 .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
6545 .unwrap();
6546 let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
6547
6548 let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6549 let token_a = tokio_util::sync::CancellationToken::new();
6550 let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
6551
6552 let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6553 let token_b = tokio_util::sync::CancellationToken::new();
6554 let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
6555
6556 tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
6557 tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
6558 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6559
6560 let client = reqwest::Client::new();
6561
6562 let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
6564 let (resp_hello, _) = tokio::join!(fut_hello, async {
6565 if let Some(mut envelope) = rx_a.recv().await {
6566 envelope.exchange.input.body =
6567 camel_component_api::Body::Text("hello-response".to_string());
6568 if let Some(reply_tx) = envelope.reply_tx {
6569 let _ = reply_tx.send(Ok(envelope.exchange));
6570 }
6571 }
6572 });
6573
6574 let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
6576 let (resp_world, _) = tokio::join!(fut_world, async {
6577 if let Some(mut envelope) = rx_b.recv().await {
6578 envelope.exchange.input.body =
6579 camel_component_api::Body::Text("world-response".to_string());
6580 if let Some(reply_tx) = envelope.reply_tx {
6581 let _ = reply_tx.send(Ok(envelope.exchange));
6582 }
6583 }
6584 });
6585
6586 let body_a = resp_hello.unwrap().text().await.unwrap();
6587 let body_b = resp_world.unwrap().text().await.unwrap();
6588
6589 assert_eq!(body_a, "hello-response");
6590 assert_eq!(body_b, "world-response");
6591
6592 token_a.cancel();
6593 token_b.cancel();
6594 }
6595
6596 #[tokio::test]
6597 #[allow(clippy::await_holding_lock)]
6598 async fn test_integration_unregistered_path_returns_404() {
6599 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6600
6601 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6602
6603 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6605 let port = listener.local_addr().unwrap().port();
6606 drop(listener);
6607
6608 let component = HttpComponent::new();
6609 let endpoint_ctx = NoOpComponentContext;
6610 let endpoint = component
6611 .create_endpoint(
6612 &format!("http://127.0.0.1:{port}/registered"),
6613 &endpoint_ctx,
6614 )
6615 .unwrap();
6616 let mut consumer = endpoint.create_consumer(rt()).unwrap();
6617
6618 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6619 let token = tokio_util::sync::CancellationToken::new();
6620 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6621
6622 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6623
6624 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
6626 loop {
6627 if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
6628 .await
6629 .is_ok()
6630 {
6631 break;
6632 }
6633 if std::time::Instant::now() >= deadline {
6634 panic!("HTTP server did not start within 5s on port {port}");
6635 }
6636 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
6637 }
6638
6639 let client = reqwest::Client::new();
6640 let resp = client
6641 .get(format!("http://127.0.0.1:{port}/not-there"))
6642 .send()
6643 .await
6644 .unwrap();
6645 assert_eq!(resp.status().as_u16(), 404);
6646
6647 token.cancel();
6648 }
6649
6650 #[test]
6651 fn test_http_consumer_declares_concurrent() {
6652 use camel_component_api::ConcurrencyModel;
6653
6654 let config = HttpServerConfig {
6655 scheme: "http".to_string(),
6656 host: "127.0.0.1".to_string(),
6657 port: 19999,
6658 path: "/test".to_string(),
6659 max_request_body: 2 * 1024 * 1024,
6660 max_response_body: 10 * 1024 * 1024,
6661 max_inflight_requests: 1024,
6662 method: None,
6663 tls_config: None,
6664 };
6665 let consumer = HttpConsumer::new(config, test_rt());
6666 assert_eq!(
6667 consumer.concurrency_model(),
6668 ConcurrencyModel::Concurrent { max: None }
6669 );
6670 }
6671
6672 #[test]
6673 fn server_config_parses_tls_cert_and_key() {
6674 let cfg = HttpServerConfig::from_uri(
6675 "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
6676 )
6677 .unwrap();
6678 assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
6679 assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
6680 }
6681
6682 #[test]
6683 fn server_config_no_tls_when_params_absent() {
6684 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
6685 assert!(cfg.tls_config.is_none());
6686 }
6687
6688 #[tokio::test]
6693 async fn test_http_reply_body_stream_variant_exists() {
6694 use bytes::Bytes;
6695 use camel_component_api::CamelError;
6696 use futures::stream;
6697
6698 let chunks: Vec<Result<Bytes, CamelError>> =
6699 vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
6700 let stream = Box::pin(stream::iter(chunks));
6701 let reply_body = HttpReplyBody::Stream(stream);
6702 match reply_body {
6704 HttpReplyBody::Stream(_) => {}
6705 HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
6706 }
6707 }
6708
6709 #[cfg(feature = "otel")]
6714 mod otel_tests {
6715 use super::*;
6716 use camel_component_api::Message;
6717 use tower::ServiceExt;
6718
6719 #[tokio::test]
6720 async fn test_producer_injects_traceparent_header() {
6721 let (url, _handle) = start_test_server_with_header_capture().await;
6722 let ctx = test_producer_ctx();
6723
6724 let component = HttpComponent::new();
6725 let endpoint_ctx = NoOpComponentContext;
6726 let endpoint = component
6727 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
6728 .unwrap();
6729 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6730
6731 let mut exchange = Exchange::new(Message::default());
6733 let mut headers = std::collections::HashMap::new();
6734 headers.insert(
6735 "traceparent".to_string(),
6736 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
6737 );
6738 camel_otel::extract_into_exchange(&mut exchange, &headers);
6739
6740 let result = producer.oneshot(exchange).await.unwrap();
6741
6742 let status = result
6744 .input
6745 .header("CamelHttpResponseCode")
6746 .and_then(|v| v.as_u64())
6747 .unwrap();
6748 assert_eq!(status, 200);
6749
6750 let traceparent = result.input.header("X-Received-Traceparent");
6752 assert!(
6753 traceparent.is_some(),
6754 "traceparent header should have been sent"
6755 );
6756
6757 let traceparent_str = traceparent.unwrap().as_str().unwrap();
6758 let parts: Vec<&str> = traceparent_str.split('-').collect();
6760 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
6761 assert_eq!(parts[0], "00", "version should be 00");
6762 assert_eq!(
6763 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
6764 "trace-id should match"
6765 );
6766 assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
6767 assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
6768 }
6769
6770 #[tokio::test]
6771 async fn test_consumer_extracts_traceparent_header() {
6772 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6773
6774 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6776 let port = listener.local_addr().unwrap().port();
6777 drop(listener);
6778
6779 let component = HttpComponent::new();
6780 let endpoint_ctx = NoOpComponentContext;
6781 let endpoint = component
6782 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
6783 .unwrap();
6784 let mut consumer = endpoint.create_consumer(rt()).unwrap();
6785
6786 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6787 let token = tokio_util::sync::CancellationToken::new();
6788 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6789
6790 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6791 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6792
6793 let client = reqwest::Client::new();
6795 let send_fut = client
6796 .post(format!("http://127.0.0.1:{port}/trace"))
6797 .header(
6798 "traceparent",
6799 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
6800 )
6801 .body("test")
6802 .send();
6803
6804 let (http_result, _) = tokio::join!(send_fut, async {
6805 if let Some(envelope) = rx.recv().await {
6806 let mut injected_headers = std::collections::HashMap::new();
6809 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
6810
6811 assert!(
6812 injected_headers.contains_key("traceparent"),
6813 "Exchange should have traceparent after extraction"
6814 );
6815
6816 let traceparent = injected_headers.get("traceparent").unwrap();
6817 let parts: Vec<&str> = traceparent.split('-').collect();
6818 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
6819 assert_eq!(
6820 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
6821 "Trace ID should match the original traceparent header"
6822 );
6823
6824 if let Some(reply_tx) = envelope.reply_tx {
6825 let _ = reply_tx.send(Ok(envelope.exchange));
6826 }
6827 }
6828 });
6829
6830 let resp = http_result.unwrap();
6831 assert_eq!(resp.status().as_u16(), 200);
6832
6833 token.cancel();
6834 }
6835
6836 #[tokio::test]
6837 async fn test_consumer_extracts_mixed_case_traceparent_header() {
6838 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6839
6840 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6842 let port = listener.local_addr().unwrap().port();
6843 drop(listener);
6844
6845 let component = HttpComponent::new();
6846 let endpoint_ctx = NoOpComponentContext;
6847 let endpoint = component
6848 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
6849 .unwrap();
6850 let mut consumer = endpoint.create_consumer(rt()).unwrap();
6851
6852 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6853 let token = tokio_util::sync::CancellationToken::new();
6854 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6855
6856 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6857 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6858
6859 let client = reqwest::Client::new();
6861 let send_fut = client
6862 .post(format!("http://127.0.0.1:{port}/trace"))
6863 .header(
6864 "TraceParent",
6865 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
6866 )
6867 .body("test")
6868 .send();
6869
6870 let (http_result, _) = tokio::join!(send_fut, async {
6871 if let Some(envelope) = rx.recv().await {
6872 let mut injected_headers = HashMap::new();
6875 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
6876
6877 assert!(
6878 injected_headers.contains_key("traceparent"),
6879 "Exchange should have traceparent after extraction from mixed-case header"
6880 );
6881
6882 let traceparent = injected_headers.get("traceparent").unwrap();
6883 let parts: Vec<&str> = traceparent.split('-').collect();
6884 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
6885 assert_eq!(
6886 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
6887 "Trace ID should match the original mixed-case TraceParent header"
6888 );
6889
6890 if let Some(reply_tx) = envelope.reply_tx {
6891 let _ = reply_tx.send(Ok(envelope.exchange));
6892 }
6893 }
6894 });
6895
6896 let resp = http_result.unwrap();
6897 assert_eq!(resp.status().as_u16(), 200);
6898
6899 token.cancel();
6900 }
6901
6902 #[tokio::test]
6903 async fn test_producer_no_trace_context_no_crash() {
6904 let (url, _handle) = start_test_server().await;
6905 let ctx = test_producer_ctx();
6906
6907 let component = HttpComponent::new();
6908 let endpoint_ctx = NoOpComponentContext;
6909 let endpoint = component
6910 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
6911 .unwrap();
6912 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6913
6914 let exchange = Exchange::new(Message::default());
6916
6917 let result = producer.oneshot(exchange).await.unwrap();
6919
6920 let status = result
6922 .input
6923 .header("CamelHttpResponseCode")
6924 .and_then(|v| v.as_u64())
6925 .unwrap();
6926 assert_eq!(status, 200);
6927 }
6928
6929 async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
6931 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6932 let addr = listener.local_addr().unwrap();
6933 let url = format!("http://127.0.0.1:{}", addr.port());
6934
6935 let handle = tokio::spawn(async move {
6936 loop {
6937 if let Ok((mut stream, _)) = listener.accept().await {
6938 tokio::spawn(async move {
6939 use tokio::io::{AsyncReadExt, AsyncWriteExt};
6940 let mut buf = vec![0u8; 8192];
6941 let n = stream.read(&mut buf).await.unwrap_or(0);
6942 let request = String::from_utf8_lossy(&buf[..n]).to_string();
6943
6944 let traceparent = request
6946 .lines()
6947 .find(|line| line.to_lowercase().starts_with("traceparent:"))
6948 .map(|line| {
6949 line.split(':')
6950 .nth(1)
6951 .map(|s| s.trim().to_string())
6952 .unwrap_or_default()
6953 })
6954 .unwrap_or_default();
6955
6956 let body =
6957 format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
6958 let response = format!(
6959 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
6960 body.len(),
6961 traceparent,
6962 body
6963 );
6964 let _ = stream.write_all(response.as_bytes()).await;
6965 });
6966 }
6967 }
6968 });
6969
6970 (url, handle)
6971 }
6972 }
6973
6974 #[tokio::test]
6983 async fn test_request_body_arrives_as_stream() {
6984 use camel_component_api::Body;
6985 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6986
6987 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6988 let port = listener.local_addr().unwrap().port();
6989 drop(listener);
6990
6991 let component = HttpComponent::new();
6992 let endpoint_ctx = NoOpComponentContext;
6993 let endpoint = component
6994 .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
6995 .unwrap();
6996 let mut consumer = endpoint.create_consumer(rt()).unwrap();
6997
6998 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6999 let token = tokio_util::sync::CancellationToken::new();
7000 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7001
7002 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7003 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7004
7005 let client = reqwest::Client::new();
7006 let send_fut = client
7007 .post(format!("http://127.0.0.1:{port}/upload"))
7008 .body("hello streaming world")
7009 .send();
7010
7011 let (http_result, _) = tokio::join!(send_fut, async {
7012 if let Some(mut envelope) = rx.recv().await {
7013 assert!(
7015 matches!(envelope.exchange.input.body, Body::Stream(_)),
7016 "expected Body::Stream, got discriminant {:?}",
7017 std::mem::discriminant(&envelope.exchange.input.body)
7018 );
7019 let bytes = envelope
7021 .exchange
7022 .input
7023 .body
7024 .into_bytes(1024 * 1024)
7025 .await
7026 .unwrap();
7027 assert_eq!(&bytes[..], b"hello streaming world");
7028
7029 envelope.exchange.input.body = camel_component_api::Body::Empty;
7030 if let Some(reply_tx) = envelope.reply_tx {
7031 let _ = reply_tx.send(Ok(envelope.exchange));
7032 }
7033 }
7034 });
7035
7036 let resp = http_result.unwrap();
7037 assert_eq!(resp.status().as_u16(), 200);
7038
7039 token.cancel();
7040 }
7041
7042 #[tokio::test]
7047 async fn test_streaming_response_chunked() {
7048 use bytes::Bytes;
7049 use camel_component_api::Body;
7050 use camel_component_api::CamelError;
7051 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7052 use camel_component_api::{StreamBody, StreamMetadata};
7053 use futures::stream;
7054 use std::sync::Arc;
7055 use tokio::sync::Mutex;
7056
7057 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7058 let port = listener.local_addr().unwrap().port();
7059 drop(listener);
7060
7061 let component = HttpComponent::new();
7062 let endpoint_ctx = NoOpComponentContext;
7063 let endpoint = component
7064 .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
7065 .unwrap();
7066 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7067
7068 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7069 let token = tokio_util::sync::CancellationToken::new();
7070 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7071
7072 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7073 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7074
7075 let client = reqwest::Client::new();
7076 let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
7077
7078 let (http_result, _) = tokio::join!(send_fut, async {
7079 if let Some(mut envelope) = rx.recv().await {
7080 let chunks: Vec<Result<Bytes, CamelError>> =
7082 vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
7083 let stream = Box::pin(stream::iter(chunks));
7084 envelope.exchange.input.body = Body::Stream(StreamBody {
7085 stream: Arc::new(Mutex::new(Some(stream))),
7086 metadata: StreamMetadata::default(),
7087 });
7088 if let Some(reply_tx) = envelope.reply_tx {
7089 let _ = reply_tx.send(Ok(envelope.exchange));
7090 }
7091 }
7092 });
7093
7094 let resp = http_result.unwrap();
7095 assert_eq!(resp.status().as_u16(), 200);
7096 let body = resp.text().await.unwrap();
7097 assert_eq!(body, "chunk1chunk2");
7098
7099 token.cancel();
7100 }
7101
7102 #[tokio::test]
7107 async fn test_413_when_content_length_exceeds_limit() {
7108 use camel_component_api::ConsumerContext;
7109
7110 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7111 let port = listener.local_addr().unwrap().port();
7112 drop(listener);
7113
7114 let component = HttpComponent::new();
7116 let endpoint_ctx = NoOpComponentContext;
7117 let endpoint = component
7118 .create_endpoint(
7119 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
7120 &endpoint_ctx,
7121 )
7122 .unwrap();
7123 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7124
7125 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7126 let token = tokio_util::sync::CancellationToken::new();
7127 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7128
7129 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7130 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7131
7132 let client = reqwest::Client::new();
7133 let resp = client
7134 .post(format!("http://127.0.0.1:{port}/upload"))
7135 .header("Content-Length", "1000") .body("x".repeat(1000))
7137 .send()
7138 .await
7139 .unwrap();
7140
7141 assert_eq!(resp.status().as_u16(), 413);
7142
7143 token.cancel();
7144 }
7145
7146 #[tokio::test]
7150 async fn test_chunked_upload_without_content_length_bypasses_limit() {
7151 use bytes::Bytes;
7152 use camel_component_api::Body;
7153 use camel_component_api::ConsumerContext;
7154 use futures::stream;
7155
7156 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7157 let port = listener.local_addr().unwrap().port();
7158 drop(listener);
7159
7160 let component = HttpComponent::new();
7162 let endpoint_ctx = NoOpComponentContext;
7163 let endpoint = component
7164 .create_endpoint(
7165 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
7166 &endpoint_ctx,
7167 )
7168 .unwrap();
7169 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7170
7171 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7172 let token = tokio_util::sync::CancellationToken::new();
7173 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7174
7175 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7176 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7177
7178 let client = reqwest::Client::new();
7179
7180 let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
7184 Ok(Bytes::from("y".repeat(50))),
7185 Ok(Bytes::from("y".repeat(50))),
7186 ];
7187 let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
7188 let send_fut = client
7189 .post(format!("http://127.0.0.1:{port}/upload"))
7190 .body(stream_body)
7191 .send();
7192
7193 let consumer_fut = async {
7194 match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
7196 Ok(Some(mut envelope)) => {
7197 assert!(
7198 matches!(envelope.exchange.input.body, Body::Stream(_)),
7199 "expected Body::Stream"
7200 );
7201 envelope.exchange.input.body = camel_component_api::Body::Empty;
7202 if let Some(reply_tx) = envelope.reply_tx {
7203 let _ = reply_tx.send(Ok(envelope.exchange));
7204 }
7205 }
7206 Ok(None) => panic!("consumer channel closed unexpectedly"),
7207 Err(_) => {
7208 }
7211 }
7212 };
7213
7214 let (http_result, _) = tokio::join!(send_fut, consumer_fut);
7215
7216 let resp = http_result.unwrap();
7217 assert_ne!(
7224 resp.status().as_u16(),
7225 413,
7226 "chunked upload has no Content-Length to pre-check"
7227 );
7228 assert_eq!(resp.status().as_u16(), 200);
7229
7230 token.cancel();
7231 }
7232
7233 #[test]
7234 fn test_is_private_ip_ranges() {
7235 use camel_api::is_ssrf_blocked_ip;
7236 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(
7255 &"2001:4860:4860::8888".parse().unwrap()
7256 )); }
7258
7259 #[test]
7260 fn test_title_case_header() {
7261 assert_eq!(title_case_header("content-type"), "Content-Type");
7262 assert_eq!(title_case_header("authorization"), "Authorization");
7263 assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
7264 assert_eq!(title_case_header("host"), "Host");
7265 assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
7266 assert_eq!(title_case_header("single"), "Single");
7267 assert_eq!(title_case_header(""), "");
7268 }
7269
7270 #[test]
7271 fn test_resolve_url_combines_path_and_query_sources() {
7272 let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
7273 let mut exchange = Exchange::new(Message::default());
7274 exchange.input.set_header(
7275 "CamelHttpPath",
7276 serde_json::Value::String("next".to_string()),
7277 );
7278 let url = HttpProducer::resolve_url(&exchange, &cfg);
7279 assert!(url.starts_with("http://example.com/base/next?"));
7280 assert!(url.contains("foo=bar"));
7281
7282 exchange.input.set_header(
7283 "CamelHttpUri",
7284 serde_json::Value::String("http://other.test/root".to_string()),
7285 );
7286 exchange.input.set_header(
7287 "CamelHttpQuery",
7288 serde_json::Value::String("a=1&b=2".to_string()),
7289 );
7290
7291 let override_url = HttpProducer::resolve_url(&exchange, &cfg);
7292 assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
7293 }
7294
7295 fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
7296 let mut exchange = Exchange::new(Message::default());
7297 exchange
7298 .input
7299 .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
7300 exchange.input.set_header(
7301 "CamelHttpQuery",
7302 serde_json::Value::String(query.to_string()),
7303 );
7304 exchange
7305 }
7306
7307 #[test]
7308 fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
7309 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
7310 cfg.bridge_endpoint = true;
7311 cfg.query_params
7312 .insert("token".to_string(), "secret".to_string());
7313 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
7314 let url = HttpProducer::resolve_url(&exchange, &cfg);
7315 assert_eq!(url, "http://x/?token=secret");
7316 assert!(!url.contains("/foo"));
7317 assert!(!url.contains("dropme"));
7318 }
7319
7320 #[test]
7321 fn resolve_url_bridge_endpoint_false_merges_path() {
7322 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
7323 cfg.bridge_endpoint = false;
7324 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
7325 let url = HttpProducer::resolve_url(&exchange, &cfg);
7326 assert!(url.contains("/foo"), "url should contain /foo: {url}");
7327 assert!(
7328 url.contains("dropme=1"),
7329 "url should contain dropme=1: {url}"
7330 );
7331 }
7332
7333 #[test]
7334 fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
7335 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
7336 cfg.bridge_endpoint = true;
7337 let mut exchange = Exchange::new(Message::default());
7338 exchange.input.set_header(
7339 "CamelHttpPath",
7340 serde_json::Value::String("/foo".to_string()),
7341 );
7342 let url = HttpProducer::resolve_url(&exchange, &cfg);
7343 assert_eq!(url, "http://x");
7344 assert!(!url.contains("/foo"));
7345 }
7346
7347 #[test]
7348 fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
7349 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
7350 cfg.bridge_endpoint = true;
7351 let mut exchange = Exchange::new(Message::default());
7353 exchange.input.set_header(
7354 "CamelHttpUri",
7355 serde_json::Value::String("http://dest/explicit".to_string()),
7356 );
7357 exchange.input.set_header(
7358 "CamelHttpPath",
7359 serde_json::Value::String("/foo".to_string()),
7360 );
7361 exchange.input.set_header(
7362 "CamelHttpQuery",
7363 serde_json::Value::String("x=1".to_string()),
7364 );
7365 let url = HttpProducer::resolve_url(&exchange, &cfg);
7366 assert_eq!(url, "http://x");
7370 }
7371
7372 #[test]
7373 fn test_http_producer_helpers_status_and_size_boundaries() {
7374 assert!(HttpProducer::is_ok_status(200, (200, 299)));
7375 assert!(HttpProducer::is_ok_status(299, (200, 299)));
7376 assert!(!HttpProducer::is_ok_status(199, (200, 299)));
7377 assert!(!HttpProducer::is_ok_status(300, (200, 299)));
7378
7379 assert!(!exceeds_max_response_body(10, 10));
7380 assert!(exceeds_max_response_body(11, 10));
7381 }
7382
7383 async fn setup_consumer_on_free_port(
7388 path: &str,
7389 ) -> (
7390 u16,
7391 tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
7392 tokio_util::sync::CancellationToken,
7393 ) {
7394 use camel_component_api::ConsumerContext;
7395
7396 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7397 let port = listener.local_addr().unwrap().port();
7398 drop(listener);
7399
7400 let consumer_cfg = HttpServerConfig {
7401 scheme: "http".to_string(),
7402 host: "127.0.0.1".to_string(),
7403 port,
7404 path: path.to_string(),
7405 max_request_body: 2 * 1024 * 1024,
7406 max_response_body: 10 * 1024 * 1024,
7407 max_inflight_requests: 1024,
7408 method: None,
7409 tls_config: None,
7410 };
7411 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
7412
7413 let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
7414 let token = tokio_util::sync::CancellationToken::new();
7415 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7416
7417 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7418 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7419
7420 (port, rx, token)
7421 }
7422
7423 #[tokio::test]
7424 async fn test_content_type_inferred_for_json_body() {
7425 let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
7426
7427 let client = reqwest::Client::new();
7428 let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
7429
7430 let (http_result, _) = tokio::join!(send_fut, async {
7431 if let Some(mut envelope) = rx.recv().await {
7432 envelope.exchange.input.body =
7433 camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
7434 if let Some(reply_tx) = envelope.reply_tx {
7435 let _ = reply_tx.send(Ok(envelope.exchange));
7436 }
7437 }
7438 });
7439
7440 let resp = http_result.unwrap();
7441 assert_eq!(resp.status().as_u16(), 200);
7442 let ct = resp
7443 .headers()
7444 .get("content-type")
7445 .expect("Content-Type header should be present");
7446 assert_eq!(ct, "application/json");
7447 let body = resp.text().await.unwrap();
7448 assert_eq!(body, r#"{"message":"hello"}"#);
7449
7450 token.cancel();
7451 }
7452
7453 #[tokio::test]
7454 async fn test_content_type_inferred_for_text_body() {
7455 let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
7456
7457 let client = reqwest::Client::new();
7458 let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
7459
7460 let (http_result, _) = tokio::join!(send_fut, async {
7461 if let Some(mut envelope) = rx.recv().await {
7462 envelope.exchange.input.body =
7463 camel_component_api::Body::Text("plain text response".to_string());
7464 if let Some(reply_tx) = envelope.reply_tx {
7465 let _ = reply_tx.send(Ok(envelope.exchange));
7466 }
7467 }
7468 });
7469
7470 let resp = http_result.unwrap();
7471 assert_eq!(resp.status().as_u16(), 200);
7472 let ct = resp
7473 .headers()
7474 .get("content-type")
7475 .expect("Content-Type header should be present");
7476 assert_eq!(ct, "text/plain; charset=utf-8");
7477 let body = resp.text().await.unwrap();
7478 assert_eq!(body, "plain text response");
7479
7480 token.cancel();
7481 }
7482
7483 #[tokio::test]
7484 async fn test_content_type_inferred_for_xml_body() {
7485 let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
7486
7487 let client = reqwest::Client::new();
7488 let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
7489
7490 let (http_result, _) = tokio::join!(send_fut, async {
7491 if let Some(mut envelope) = rx.recv().await {
7492 envelope.exchange.input.body =
7493 camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
7494 if let Some(reply_tx) = envelope.reply_tx {
7495 let _ = reply_tx.send(Ok(envelope.exchange));
7496 }
7497 }
7498 });
7499
7500 let resp = http_result.unwrap();
7501 assert_eq!(resp.status().as_u16(), 200);
7502 let ct = resp
7503 .headers()
7504 .get("content-type")
7505 .expect("Content-Type header should be present");
7506 assert_eq!(ct, "application/xml");
7507 let body = resp.text().await.unwrap();
7508 assert_eq!(body, "<root><item>value</item></root>");
7509
7510 token.cancel();
7511 }
7512
7513 #[tokio::test]
7514 async fn test_no_content_type_for_empty_body() {
7515 let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
7516
7517 let client = reqwest::Client::new();
7518 let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
7519
7520 let (http_result, _) = tokio::join!(send_fut, async {
7521 if let Some(mut envelope) = rx.recv().await {
7522 envelope.exchange.input.body = camel_component_api::Body::Empty;
7523 if let Some(reply_tx) = envelope.reply_tx {
7524 let _ = reply_tx.send(Ok(envelope.exchange));
7525 }
7526 }
7527 });
7528
7529 let resp = http_result.unwrap();
7530 assert_eq!(resp.status().as_u16(), 200);
7531 assert!(
7532 resp.headers().get("content-type").is_none(),
7533 "Empty body should not set Content-Type"
7534 );
7535
7536 token.cancel();
7537 }
7538
7539 #[tokio::test]
7540 async fn test_no_content_type_for_raw_bytes_body() {
7541 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
7542
7543 let client = reqwest::Client::new();
7544 let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
7545
7546 let (http_result, _) = tokio::join!(send_fut, async {
7547 if let Some(mut envelope) = rx.recv().await {
7548 envelope.exchange.input.body =
7549 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
7550 if let Some(reply_tx) = envelope.reply_tx {
7551 let _ = reply_tx.send(Ok(envelope.exchange));
7552 }
7553 }
7554 });
7555
7556 let resp = http_result.unwrap();
7557 assert_eq!(resp.status().as_u16(), 200);
7558 assert!(
7559 resp.headers().get("content-type").is_none(),
7560 "Raw Bytes body should not set Content-Type"
7561 );
7562
7563 token.cancel();
7564 }
7565
7566 #[tokio::test]
7567 async fn test_content_type_from_stream_metadata() {
7568 use camel_component_api::{StreamBody, StreamMetadata};
7569 use futures::stream;
7570
7571 let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
7572
7573 let client = reqwest::Client::new();
7574 let send_fut = client
7575 .get(format!("http://127.0.0.1:{port}/stream-ct"))
7576 .send();
7577
7578 let (http_result, _) = tokio::join!(send_fut, async {
7579 if let Some(mut envelope) = rx.recv().await {
7580 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
7581 vec![Ok(bytes::Bytes::from("audio data"))];
7582 let stream = Box::pin(stream::iter(chunks));
7583 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
7584 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
7585 metadata: StreamMetadata {
7586 size_hint: None,
7587 content_type: Some("audio/mpeg".to_string()),
7588 origin: None,
7589 },
7590 });
7591 if let Some(reply_tx) = envelope.reply_tx {
7592 let _ = reply_tx.send(Ok(envelope.exchange));
7593 }
7594 }
7595 });
7596
7597 let resp = http_result.unwrap();
7598 assert_eq!(resp.status().as_u16(), 200);
7599 let ct = resp
7600 .headers()
7601 .get("content-type")
7602 .expect("Content-Type header should be present");
7603 assert_eq!(ct, "audio/mpeg");
7604 let body = resp.text().await.unwrap();
7605 assert_eq!(body, "audio data");
7606
7607 token.cancel();
7608 }
7609
7610 #[tokio::test]
7611 async fn test_user_content_type_overrides_inferred() {
7612 let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
7613
7614 let client = reqwest::Client::new();
7615 let send_fut = client
7616 .get(format!("http://127.0.0.1:{port}/override-ct"))
7617 .send();
7618
7619 let (http_result, _) = tokio::join!(send_fut, async {
7620 if let Some(mut envelope) = rx.recv().await {
7621 envelope.exchange.input.body =
7622 camel_component_api::Body::Json(serde_json::json!({"ok": true}));
7623 envelope.exchange.input.set_header(
7624 "Content-Type",
7625 serde_json::Value::String("text/html".to_string()),
7626 );
7627 if let Some(reply_tx) = envelope.reply_tx {
7628 let _ = reply_tx.send(Ok(envelope.exchange));
7629 }
7630 }
7631 });
7632
7633 let resp = http_result.unwrap();
7634 assert_eq!(resp.status().as_u16(), 200);
7635 let ct = resp
7636 .headers()
7637 .get("content-type")
7638 .expect("Content-Type header should be present");
7639 assert_eq!(
7640 ct, "text/html",
7641 "User-set Content-Type should take precedence over inferred type"
7642 );
7643
7644 token.cancel();
7645 }
7646
7647 #[tokio::test]
7648 async fn test_user_content_type_with_bytes_body() {
7649 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
7650
7651 let client = reqwest::Client::new();
7652 let send_fut = client
7653 .get(format!("http://127.0.0.1:{port}/bytes-ct"))
7654 .send();
7655
7656 let (http_result, _) = tokio::join!(send_fut, async {
7657 if let Some(mut envelope) = rx.recv().await {
7658 envelope.exchange.input.body =
7659 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
7660 envelope.exchange.input.set_header(
7661 "Content-Type",
7662 serde_json::Value::String("application/json".to_string()),
7663 );
7664 if let Some(reply_tx) = envelope.reply_tx {
7665 let _ = reply_tx.send(Ok(envelope.exchange));
7666 }
7667 }
7668 });
7669
7670 let resp = http_result.unwrap();
7671 assert_eq!(resp.status().as_u16(), 200);
7672 let ct = resp
7673 .headers()
7674 .get("content-type")
7675 .expect("Content-Type header should be present for Bytes body with user header");
7676 assert_eq!(
7677 ct, "application/json",
7678 "User Content-Type should be sent for Bytes body"
7679 );
7680
7681 token.cancel();
7682 }
7683
7684 #[tokio::test]
7689 async fn monitor_task_silent_on_clean_exit() {
7690 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
7691 monitor_axum_task(
7693 handle,
7694 "127.0.0.1:0".to_string(),
7695 noop_rt(),
7696 "test-monitor".into(),
7697 )
7698 .await;
7699 }
7700
7701 #[tokio::test]
7702 async fn monitor_task_handles_panicked_task() {
7703 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
7704 panic!("simulated server crash");
7705 });
7706 monitor_axum_task(
7708 handle,
7709 "127.0.0.1:9999".to_string(),
7710 noop_rt(),
7711 "test-monitor".into(),
7712 )
7713 .await;
7714 }
7715
7716 #[test]
7721 fn http_auth_basic_debug_redacts_password() {
7722 let auth = HttpAuth::Basic {
7723 username: "admin".to_string(),
7724 password: "hunter2".to_string(),
7725 };
7726 let debug = format!("{:?}", auth);
7727 assert!(
7728 !debug.contains("hunter2"),
7729 "password must be redacted: {debug}"
7730 );
7731 assert!(debug.contains("admin"), "username should appear: {debug}");
7732 }
7733
7734 #[test]
7735 fn http_auth_bearer_debug_redacts_token() {
7736 let auth = HttpAuth::Bearer {
7737 token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
7738 };
7739 let debug = format!("{:?}", auth);
7740 assert!(
7741 !debug.contains("eyJhbGci"),
7742 "token must be redacted: {debug}"
7743 );
7744 }
7745
7746 #[test]
7747 fn http_auth_none_debug_shows_variant() {
7748 let debug = format!("{:?}", HttpAuth::None);
7749 assert!(
7750 debug.contains("None"),
7751 "None variant should appear: {debug}"
7752 );
7753 }
7754
7755 #[test]
7756 fn http_endpoint_config_debug_redacts_auth_credentials() {
7757 let config = HttpEndpointConfig::from_uri(
7758 "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
7759 )
7760 .unwrap();
7761 let debug = format!("{:?}", config);
7762 assert!(
7763 !debug.contains("secret123"),
7764 "password must be redacted in HttpEndpointConfig debug: {debug}"
7765 );
7766 }
7767
7768 use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
7773 use tower_http::services::ServeDir;
7774
7775 fn make_test_registry() -> HttpRouteRegistry {
7776 HttpRouteRegistry::new()
7777 }
7778
7779 fn make_test_state(registry: HttpRouteRegistry) -> AppState {
7780 AppState {
7781 registry,
7782 max_request_body: 2 * 1024 * 1024,
7783 max_response_body: 10 * 1024 * 1024,
7784 inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
7785 }
7786 }
7787
7788 #[allow(clippy::await_holding_lock)]
7789 #[tokio::test]
7790 async fn test_static_file_serving_serves_file_contents() {
7791 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7792 ServerRegistry::reset();
7793
7794 let temp_dir =
7796 std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
7797 std::fs::create_dir_all(&temp_dir).unwrap();
7798 std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
7799 std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
7800
7801 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
7802
7803 let registry = make_test_registry();
7804 let serve_dir = ServeDir::new(&canonical_dir)
7805 .precompressed_gzip()
7806 .precompressed_br()
7807 .append_index_html_on_directories(true);
7808
7809 let mount = StaticMount {
7810 mount_path: "/".to_string(),
7811 mode: MountMode::Static,
7812 dir: canonical_dir.clone(),
7813 cache_control: "public, max-age=3600".to_string(),
7814 error_pages: std::collections::HashMap::new(),
7815 serve_dir,
7816 };
7817 registry.register_static_mount(mount).await.unwrap();
7818
7819 let state = make_test_state(registry);
7820
7821 let req = Request::builder()
7823 .uri("/hello.txt")
7824 .body(AxumBody::empty())
7825 .unwrap();
7826 let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
7827 assert_eq!(resp.status(), StatusCode::OK);
7828 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7829 .await
7830 .unwrap();
7831 assert_eq!(&body[..], b"Hello, static world!");
7832
7833 let req = Request::builder()
7835 .uri("/style.css")
7836 .body(AxumBody::empty())
7837 .unwrap();
7838 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
7839 assert_eq!(resp.status(), StatusCode::OK);
7840 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7841 .await
7842 .unwrap();
7843 assert_eq!(&body[..], b"body { color: red; }");
7844
7845 let req = Request::builder()
7847 .uri("/missing.txt")
7848 .body(AxumBody::empty())
7849 .unwrap();
7850 let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
7851 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7852
7853 std::fs::remove_dir_all(&temp_dir).ok();
7855 }
7856
7857 #[allow(clippy::await_holding_lock)]
7858 #[tokio::test]
7859 async fn test_spa_fallback_serves_index_for_unknown_paths() {
7860 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7861 ServerRegistry::reset();
7862
7863 let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
7864 std::fs::create_dir_all(&temp_dir).unwrap();
7865 std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
7866 std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
7867
7868 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
7869
7870 let registry = make_test_registry();
7871 let serve_dir = ServeDir::new(&canonical_dir)
7872 .precompressed_gzip()
7873 .precompressed_br()
7874 .append_index_html_on_directories(true);
7875
7876 let mount = StaticMount {
7877 mount_path: "/".to_string(),
7878 mode: MountMode::Spa,
7879 dir: canonical_dir.clone(),
7880 cache_control: "public, max-age=0".to_string(),
7881 error_pages: std::collections::HashMap::new(),
7882 serve_dir,
7883 };
7884 registry.register_static_mount(mount).await.unwrap();
7886
7887 let state = make_test_state(registry);
7888
7889 let req = Request::builder()
7891 .method("GET")
7892 .uri("/dashboard")
7893 .header("Accept", "text/html")
7894 .body(AxumBody::empty())
7895 .unwrap();
7896 let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
7897 assert_eq!(resp.status(), StatusCode::OK);
7898 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7899 .await
7900 .unwrap();
7901 assert_eq!(&body[..], b"<h1>SPA App</h1>");
7902
7903 let req = Request::builder()
7905 .method("GET")
7906 .uri("/app.js")
7907 .body(AxumBody::empty())
7908 .unwrap();
7909 let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
7910 assert_eq!(resp.status(), StatusCode::OK);
7911 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7912 .await
7913 .unwrap();
7914 assert_eq!(&body[..], b"console.log('app')");
7915
7916 let req = Request::builder()
7918 .method("GET")
7919 .uri("/api/data")
7920 .header("Accept", "application/json")
7921 .body(AxumBody::empty())
7922 .unwrap();
7923 let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
7924 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7925
7926 let req = Request::builder()
7928 .method("GET")
7929 .uri("/style.css")
7930 .header("Accept", "text/html")
7931 .body(AxumBody::empty())
7932 .unwrap();
7933 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
7934 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7935
7936 std::fs::remove_dir_all(&temp_dir).ok();
7938 }
7939
7940 #[allow(clippy::await_holding_lock)]
7947 async fn run_conditional_get_returns_304(mode: MountMode) {
7948 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7949 ServerRegistry::reset();
7950
7951 let temp_dir = std::env::temp_dir().join(format!(
7952 "http_cond_get_{}_{}",
7953 if mode == MountMode::Spa {
7954 "spa"
7955 } else {
7956 "static"
7957 },
7958 std::process::id()
7959 ));
7960 std::fs::create_dir_all(&temp_dir).unwrap();
7961 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
7962
7963 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
7964
7965 let registry = make_test_registry();
7966 let serve_dir = ServeDir::new(&canonical_dir)
7967 .precompressed_gzip()
7968 .precompressed_br()
7969 .append_index_html_on_directories(true);
7970
7971 let mount = StaticMount {
7972 mount_path: "/".to_string(),
7973 mode,
7974 dir: canonical_dir.clone(),
7975 cache_control: "public, max-age=3600".to_string(),
7976 error_pages: std::collections::HashMap::new(),
7977 serve_dir,
7978 };
7979 registry.register_static_mount(mount).await.unwrap();
7980
7981 let state = make_test_state(registry);
7982
7983 let req = Request::builder()
7985 .method("GET")
7986 .uri("/index.html")
7987 .body(AxumBody::empty())
7988 .unwrap();
7989 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
7990 assert_eq!(
7991 resp.status(),
7992 StatusCode::OK,
7993 "first GET should return 200, got {}",
7994 resp.status()
7995 );
7996 assert!(
7998 resp.headers().contains_key(http::header::CACHE_CONTROL),
7999 "200 response missing Cache-Control"
8000 );
8001 let etag = resp
8002 .headers()
8003 .get(http::header::ETAG)
8004 .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
8005 .clone();
8006 let last_modified = resp
8007 .headers()
8008 .get(http::header::LAST_MODIFIED)
8009 .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
8010 .clone();
8011 let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
8013 .await
8014 .unwrap();
8015
8016 let req = Request::builder()
8020 .method("GET")
8021 .uri("/index.html")
8022 .header(http::header::IF_NONE_MATCH, etag.clone())
8023 .body(AxumBody::empty())
8024 .unwrap();
8025 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
8026 assert_eq!(
8027 resp.status(),
8028 StatusCode::NOT_MODIFIED,
8029 "If-None-Match with matching ETag should return 304, got {}",
8030 resp.status()
8031 );
8032 assert!(
8034 resp.headers().contains_key(http::header::CACHE_CONTROL),
8035 "304 (If-None-Match) missing Cache-Control"
8036 );
8037 assert_eq!(
8040 resp.headers().get(http::header::ETAG),
8041 Some(&etag),
8042 "304 (If-None-Match) must echo the ETag validator"
8043 );
8044 assert_eq!(
8045 resp.headers().get(http::header::LAST_MODIFIED),
8046 Some(&last_modified),
8047 "304 (If-None-Match) must carry Last-Modified"
8048 );
8049
8050 let req = Request::builder()
8052 .method("GET")
8053 .uri("/index.html")
8054 .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
8055 .body(AxumBody::empty())
8056 .unwrap();
8057 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
8058 assert_eq!(
8059 resp.status(),
8060 StatusCode::NOT_MODIFIED,
8061 "If-Modified-Since with matching timestamp should return 304, got {}",
8062 resp.status()
8063 );
8064 assert!(
8065 resp.headers().contains_key(http::header::CACHE_CONTROL),
8066 "304 (If-Modified-Since) missing Cache-Control"
8067 );
8068 assert_eq!(
8069 resp.headers().get(http::header::ETAG),
8070 Some(&etag),
8071 "304 (If-Modified-Since) must carry the ETag validator"
8072 );
8073 assert_eq!(
8074 resp.headers().get(http::header::LAST_MODIFIED),
8075 Some(&last_modified),
8076 "304 (If-Modified-Since) must echo Last-Modified"
8077 );
8078
8079 let req = Request::builder()
8085 .method("GET")
8086 .uri("/index.html")
8087 .header(
8088 http::header::IF_MODIFIED_SINCE,
8089 "Wed, 21 Oct 2000 07:28:00 GMT",
8090 )
8091 .body(AxumBody::empty())
8092 .unwrap();
8093 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
8094 assert_eq!(
8095 resp.status(),
8096 StatusCode::OK,
8097 "past If-Modified-Since should return 200 (file modified after it), got {}",
8098 resp.status()
8099 );
8100
8101 std::fs::remove_dir_all(&temp_dir).ok();
8103 }
8104
8105 #[tokio::test]
8106 async fn test_conditional_get_returns_304_static_mode() {
8107 run_conditional_get_returns_304(MountMode::Static).await;
8108 }
8109
8110 #[tokio::test]
8111 async fn test_conditional_get_returns_304_spa_mode() {
8112 run_conditional_get_returns_304(MountMode::Spa).await;
8113 }
8114
8115 #[allow(clippy::await_holding_lock)]
8116 #[tokio::test]
8117 async fn test_error_page_mapping_serves_custom_404() {
8118 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
8119 ServerRegistry::reset();
8120
8121 let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
8122 let errors_dir = temp_dir.join("errors");
8123 std::fs::create_dir_all(&errors_dir).unwrap();
8124 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
8125 std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
8126
8127 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
8128 let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
8129
8130 let registry = make_test_registry();
8131 let serve_dir = ServeDir::new(&canonical_dir)
8132 .precompressed_gzip()
8133 .precompressed_br()
8134 .append_index_html_on_directories(true);
8135
8136 let mut error_pages = std::collections::HashMap::new();
8137 error_pages.insert(404, canonical_404);
8138
8139 let mount = StaticMount {
8140 mount_path: "/".to_string(),
8141 mode: MountMode::Static,
8142 dir: canonical_dir.clone(),
8143 cache_control: "public, max-age=0".to_string(),
8144 error_pages,
8145 serve_dir,
8146 };
8147 registry.register_static_mount(mount).await.unwrap();
8148
8149 let state = make_test_state(registry);
8150
8151 let req = Request::builder()
8153 .method("GET")
8154 .uri("/missing.html")
8155 .body(AxumBody::empty())
8156 .unwrap();
8157 let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
8158 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
8159 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8160 .await
8161 .unwrap();
8162 assert_eq!(&body[..], b"<h1>Custom 404</h1>");
8163
8164 let req = Request::builder()
8166 .method("GET")
8167 .uri("/index.html")
8168 .body(AxumBody::empty())
8169 .unwrap();
8170 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
8171 assert_eq!(resp.status(), StatusCode::OK);
8172 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8173 .await
8174 .unwrap();
8175 assert_eq!(&body[..], b"<h1>Home</h1>");
8176
8177 std::fs::remove_dir_all(&temp_dir).ok();
8179 }
8180
8181 #[tokio::test]
8182 async fn http_consumer_returns_body_and_code_on_stop() {
8183 use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
8184 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
8185 use tower::ServiceExt;
8186
8187 let set_body_step = CompiledStep::Process {
8189 kind_hint: camel_api::SpanKindHint::Internal,
8190 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
8191 ex.input.body = Body::Text("nope".into());
8192 Box::pin(async move { Ok(ex) })
8193 }),
8194 body_contract: None,
8195 lifecycle: None,
8196 label: None,
8197 };
8198 let set_status_step = CompiledStep::Process {
8199 kind_hint: camel_api::SpanKindHint::Internal,
8200 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
8201 ex.input.set_header(
8202 "CamelHttpResponseCode",
8203 serde_json::Value::Number(409.into()),
8204 );
8205 Box::pin(async move { Ok(ex) })
8206 }),
8207 body_contract: None,
8208 lifecycle: None,
8209 label: None,
8210 };
8211 let pipeline = compose_pipeline_with_handler(
8212 vec![set_body_step, set_status_step, CompiledStep::Stop],
8213 None,
8214 PipelineRuntimeCtx::compile_time(),
8215 );
8216
8217 let ex = Exchange::new(Message::default());
8218 let result = pipeline.oneshot(ex).await;
8219 assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
8220 let returned = result.unwrap();
8221 assert_eq!(returned.input.body.as_text(), Some("nope"));
8222 assert_eq!(
8223 returned
8224 .input
8225 .header("CamelHttpResponseCode")
8226 .and_then(|v| v.as_u64()),
8227 Some(409)
8228 );
8229 }
8230
8231 #[tokio::test]
8232 async fn http_consumer_returns_200_when_body_empty_on_stop() {
8233 use camel_api::{Exchange, Message};
8241 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
8242 use tower::ServiceExt;
8243
8244 let pipeline = compose_pipeline_with_handler(
8245 vec![CompiledStep::Stop],
8246 None,
8247 PipelineRuntimeCtx::compile_time(),
8248 );
8249 let ex = Exchange::new(Message::default());
8250 let result = pipeline.oneshot(ex).await;
8251 assert!(result.is_ok(), "Stop with empty body arrives as Ok");
8252 }
8255
8256 async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
8264 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8265 let port = listener.local_addr().unwrap().port();
8266 let registry = HttpRouteRegistry::new();
8267 tokio::spawn(run_axum_server(
8268 listener,
8269 registry.clone(),
8270 2 * 1024 * 1024,
8271 10 * 1024 * 1024,
8272 Arc::new(tokio::sync::Semaphore::new(1024)),
8273 test_rt(),
8274 "test-route".into(),
8275 ));
8276 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8278 (port, registry)
8279 }
8280
8281 fn spawn_responder(
8286 mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
8287 status: u16,
8288 body: String,
8289 ) -> tokio::task::JoinHandle<()> {
8290 tokio::spawn(async move {
8291 if let Some(envelope) = rx.recv().await {
8292 let _ = envelope.reply_tx.send(HttpReply {
8293 status,
8294 headers: vec![],
8295 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
8296 });
8297 }
8298 })
8299 }
8300
8301 #[tokio::test]
8302 async fn method_aware_dispatch_same_path_different_verbs() {
8303 let (port, registry) = spawn_test_server().await;
8304
8305 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
8310 registry
8311 .register_rest_endpoint(
8312 "GET".into(),
8313 vec![PathSegment::Literal("users".into())],
8314 get_tx,
8315 )
8316 .await;
8317
8318 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
8319 registry
8320 .register_rest_endpoint(
8321 "POST".into(),
8322 vec![PathSegment::Literal("users".into())],
8323 post_tx,
8324 )
8325 .await;
8326
8327 let get_handle = spawn_responder(get_rx, 200, "list".into());
8328 let post_handle = spawn_responder(post_rx, 201, "create".into());
8329
8330 let client = reqwest::Client::new();
8331
8332 let resp = client
8334 .get(format!("http://127.0.0.1:{port}/users"))
8335 .send()
8336 .await
8337 .unwrap();
8338 assert_eq!(resp.status().as_u16(), 200);
8339 let body = resp.text().await.unwrap();
8340 assert_eq!(body, "list");
8341
8342 let resp = client
8344 .post(format!("http://127.0.0.1:{port}/users"))
8345 .send()
8346 .await
8347 .unwrap();
8348 assert_eq!(resp.status().as_u16(), 201);
8349 let body = resp.text().await.unwrap();
8350 assert_eq!(body, "create");
8351
8352 let _ = tokio::join!(get_handle, post_handle);
8353 }
8354
8355 #[tokio::test]
8356 async fn method_aware_dispatch_templated_path_extracts_params() {
8357 let (port, registry) = spawn_test_server().await;
8358
8359 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
8363 registry
8364 .register_rest_endpoint(
8365 "GET".into(),
8366 vec![
8367 PathSegment::Literal("users".into()),
8368 PathSegment::Param("id".into()),
8369 ],
8370 tx,
8371 )
8372 .await;
8373
8374 let handle = tokio::spawn(async move {
8377 if let Some(envelope) = rx.recv().await {
8378 let id = envelope.path_params.get("id").cloned().unwrap_or_default();
8379 let _ = envelope.reply_tx.send(HttpReply {
8380 status: 200,
8381 headers: vec![],
8382 body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
8383 });
8384 }
8385 });
8386
8387 let client = reqwest::Client::new();
8388 let resp = client
8389 .get(format!("http://127.0.0.1:{port}/users/42"))
8390 .send()
8391 .await
8392 .unwrap();
8393 assert_eq!(resp.status().as_u16(), 200);
8394 let body = resp.text().await.unwrap();
8395 assert_eq!(body, "id=42");
8396
8397 let _ = handle.await;
8398 }
8399
8400 #[tokio::test]
8401 async fn method_aware_dispatch_unmatched_method_falls_through() {
8402 let (port, _registry) = spawn_test_server().await;
8407
8408 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
8410 _registry
8411 .register_rest_endpoint(
8412 "GET".into(),
8413 vec![PathSegment::Literal("users".into())],
8414 get_tx,
8415 )
8416 .await;
8417
8418 let drain = tokio::spawn(async move {
8421 let mut get_rx = get_rx;
8422 while get_rx.recv().await.is_some() {}
8423 });
8424
8425 let client = reqwest::Client::new();
8426 let resp = client
8427 .delete(format!("http://127.0.0.1:{port}/users"))
8428 .send()
8429 .await
8430 .unwrap();
8431 assert_eq!(resp.status().as_u16(), 404);
8432
8433 drop(drain);
8434 }
8435
8436 #[tokio::test]
8437 async fn regression_legacy_exact_api_route_still_works() {
8438 let (port, registry) = spawn_test_server().await;
8443
8444 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
8445 registry.register_api_route("/legacy/path".into(), tx).await;
8446
8447 let handle = tokio::spawn(async move {
8448 if let Some(envelope) = rx.recv().await {
8449 let _ = envelope.reply_tx.send(HttpReply {
8450 status: 200,
8451 headers: vec![],
8452 body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
8453 });
8454 }
8455 });
8456
8457 let client = reqwest::Client::new();
8458 let resp = client
8459 .get(format!("http://127.0.0.1:{port}/legacy/path"))
8460 .send()
8461 .await
8462 .unwrap();
8463 assert_eq!(resp.status().as_u16(), 200);
8464 let body = resp.text().await.unwrap();
8465 assert_eq!(body, "legacy ok");
8466
8467 let _ = handle.await;
8468 }
8469
8470 #[allow(clippy::await_holding_lock)]
8471 #[tokio::test]
8472 async fn regression_static_mount_still_works() {
8473 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
8477 ServerRegistry::reset();
8478
8479 let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
8480 std::fs::create_dir_all(&temp_dir).unwrap();
8481 std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
8482 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
8483
8484 let registry = make_test_registry();
8485 let serve_dir = ServeDir::new(&canonical_dir)
8486 .precompressed_gzip()
8487 .precompressed_br()
8488 .append_index_html_on_directories(true);
8489 let mount = StaticMount {
8490 mount_path: "/".to_string(),
8491 mode: MountMode::Static,
8492 dir: canonical_dir.clone(),
8493 cache_control: "public, max-age=3600".to_string(),
8494 error_pages: std::collections::HashMap::new(),
8495 serve_dir,
8496 };
8497 registry.register_static_mount(mount).await.unwrap();
8498
8499 let state = make_test_state(registry);
8500 let req = Request::builder()
8501 .uri("/regress.txt")
8502 .body(AxumBody::empty())
8503 .unwrap();
8504 let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
8505 assert_eq!(resp.status(), StatusCode::OK);
8506 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
8507 .await
8508 .unwrap();
8509 assert_eq!(&body[..], b"static works");
8510
8511 std::fs::remove_dir_all(&temp_dir).ok();
8512 }
8513
8514 #[tokio::test]
8523 async fn deregister_one_method_keeps_sibling_verbs() {
8524 let (port, registry) = spawn_test_server().await;
8528
8529 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
8530 registry
8531 .register_rest_endpoint(
8532 "GET".into(),
8533 vec![PathSegment::Literal("users".into())],
8534 get_tx,
8535 )
8536 .await;
8537
8538 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
8539 registry
8540 .register_rest_endpoint(
8541 "POST".into(),
8542 vec![PathSegment::Literal("users".into())],
8543 post_tx,
8544 )
8545 .await;
8546
8547 let drain = tokio::spawn(async move {
8549 let mut get_rx = get_rx;
8550 while get_rx.recv().await.is_some() {}
8551 });
8552
8553 registry.unregister_rest_endpoint("GET", "/users").await;
8555 drop(drain);
8556
8557 let post_handle = spawn_responder(post_rx, 201, "create".into());
8558
8559 let client = reqwest::Client::new();
8560 let resp = client
8562 .post(format!("http://127.0.0.1:{port}/users"))
8563 .send()
8564 .await
8565 .unwrap();
8566 assert_eq!(resp.status().as_u16(), 201);
8567 assert_eq!(resp.text().await.unwrap(), "create");
8568
8569 let _ = post_handle.await;
8570 }
8571
8572 #[tokio::test]
8573 async fn dispatch_exact_legacy_beats_rest_template() {
8574 let (port, registry) = spawn_test_server().await;
8579
8580 let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
8582 registry
8583 .register_api_route("/api/users".into(), exact_tx)
8584 .await;
8585 let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
8586
8587 let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
8589 registry
8590 .register_rest_endpoint(
8591 "GET".into(),
8592 vec![
8593 PathSegment::Literal("api".into()),
8594 PathSegment::Param("resource".into()),
8595 ],
8596 tpl_tx,
8597 )
8598 .await;
8599 let _tpl_drain = tokio::spawn(async move {
8605 let mut tpl_rx = tpl_rx;
8606 if let Some(env) = tpl_rx.recv().await {
8607 let _ = env.reply_tx.send(HttpReply {
8608 status: 200,
8609 headers: vec![],
8610 body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
8611 });
8612 }
8613 });
8614
8615 let client = reqwest::Client::new();
8616 let resp = client
8617 .get(format!("http://127.0.0.1:{port}/api/users"))
8618 .send()
8619 .await
8620 .unwrap();
8621 assert_eq!(resp.status().as_u16(), 200);
8622 assert_eq!(resp.text().await.unwrap(), "exact");
8624
8625 let _ = exact_handle.await;
8626 }
8627
8628 #[tokio::test]
8629 async fn ambiguous_rest_templates_return_500_not_silent_404() {
8630 let (port, registry) = spawn_test_server().await;
8635
8636 let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
8637 registry
8638 .register_rest_endpoint(
8639 "GET".into(),
8640 vec![
8641 PathSegment::Literal("users".into()),
8642 PathSegment::Param("id".into()),
8643 ],
8644 a_tx,
8645 )
8646 .await;
8647
8648 let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
8649 registry
8650 .register_rest_endpoint(
8651 "GET".into(),
8652 vec![
8653 PathSegment::Literal("users".into()),
8654 PathSegment::Param("name".into()),
8655 ],
8656 b_tx,
8657 )
8658 .await;
8659
8660 let client = reqwest::Client::new();
8661 let resp = client
8662 .get(format!("http://127.0.0.1:{port}/users/42"))
8663 .send()
8664 .await
8665 .unwrap();
8666 assert_eq!(resp.status().as_u16(), 500);
8668 }
8669
8670 #[test]
8671 fn from_uri_round_trips_templated_path_with_http_method() {
8672 use crate::UriConfig;
8678 let cfg =
8679 HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
8680 assert_eq!(cfg.host, "0.0.0.0");
8681 assert_eq!(cfg.port, 8080);
8682 assert_eq!(cfg.path, "/users/{id}");
8683 assert_eq!(cfg.method.as_deref(), Some("GET"));
8684
8685 let cfg_lc =
8687 HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
8688 assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
8689 assert_eq!(cfg_lc.path, "/orders");
8690 }
8691
8692 #[test]
8697 fn type_conversion_failed_maps_to_400() {
8698 let reply = pipeline_error_to_reply(
8699 CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
8700 "/api/users",
8701 );
8702 assert_eq!(reply.status, 400);
8703 let ct = reply
8705 .headers
8706 .iter()
8707 .find(|(k, _)| k == "Content-Type")
8708 .map(|(_, v)| v.as_str());
8709 assert_eq!(ct, Some("application/json"));
8710 let body = match &reply.body {
8712 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
8713 _ => panic!("expected bytes body"),
8714 };
8715 assert!(body.contains("\"error\""));
8716 assert!(body.contains("bad_request"));
8717 assert!(body.contains("invalid JSON at line 1"));
8718 }
8719
8720 #[test]
8721 fn other_error_still_maps_to_500() {
8722 let reply =
8723 pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
8724 assert_eq!(reply.status, 500);
8725 }
8726
8727 #[test]
8728 fn unauthenticated_maps_to_401() {
8729 let reply = pipeline_error_to_reply(
8730 CamelError::Unauthenticated("no token".to_string()),
8731 "/api/users",
8732 );
8733 assert_eq!(reply.status, 401);
8734 }
8735
8736 #[test]
8737 fn unauthorized_maps_to_403() {
8738 let reply = pipeline_error_to_reply(
8739 CamelError::Unauthorized("forbidden".to_string()),
8740 "/api/users",
8741 );
8742 assert_eq!(reply.status, 403);
8743 }
8744
8745 #[test]
8746 fn validation_error_maps_to_400() {
8747 let reply = pipeline_error_to_reply(
8748 CamelError::ValidationError("body does not match schema".to_string()),
8749 "/api/users",
8750 );
8751 assert_eq!(reply.status, 400);
8752 let ct = reply
8753 .headers
8754 .iter()
8755 .find(|(k, _)| k == "Content-Type")
8756 .map(|(_, v)| v.as_str());
8757 assert_eq!(ct, Some("application/json"));
8758 let body = match &reply.body {
8759 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
8760 _ => panic!("expected bytes body"),
8761 };
8762 assert!(body.contains("\"error\""));
8763 assert!(body.contains("validation_error"));
8764 assert!(body.contains("body does not match schema"));
8765 }
8766
8767 #[test]
8768 fn https_consumer_without_tls_cert_errors() {
8769 let endpoint = HttpEndpoint {
8770 uri: "https://0.0.0.0:8443/api".to_string(),
8771 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
8772 server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
8773 client: reqwest::Client::new(),
8774 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
8775 PINNED_CLIENT_TTL,
8776 PINNED_CLIENT_MAX_ENTRIES,
8777 )),
8778 http_config: HttpConfig::default(),
8779 };
8780 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
8781 let result = endpoint.create_consumer(rt);
8782 assert!(result.is_err(), "expected error for https without tls cert");
8783 if let Err(e) = result {
8784 let msg = e.to_string();
8785 assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
8786 }
8787 }
8788
8789 #[test]
8790 fn http_consumer_with_tls_config_errors() {
8791 let endpoint = HttpEndpoint {
8792 uri: "http://0.0.0.0:8080/api".to_string(),
8793 config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
8794 server_config: HttpServerConfig::from_uri(
8795 "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
8796 )
8797 .unwrap(),
8798 client: reqwest::Client::new(),
8799 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
8800 PINNED_CLIENT_TTL,
8801 PINNED_CLIENT_MAX_ENTRIES,
8802 )),
8803 http_config: HttpConfig::default(),
8804 };
8805 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
8806 let result = endpoint.create_consumer(rt);
8807 assert!(result.is_err(), "expected error for http with tls config");
8808 if let Err(e) = result {
8809 let msg = e.to_string();
8810 assert!(msg.contains("https"), "error must mention https: {msg}");
8811 }
8812 }
8813
8814 #[test]
8815 fn https_consumer_with_partial_tls_cert_only_errors() {
8816 let server_config =
8819 HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
8820 assert!(
8821 server_config.tls_config.is_none(),
8822 "partial tlsCert must not create ServerTlsConfig"
8823 );
8824 let endpoint = HttpEndpoint {
8825 uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
8826 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
8827 .unwrap(),
8828 server_config,
8829 client: reqwest::Client::new(),
8830 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
8831 PINNED_CLIENT_TTL,
8832 PINNED_CLIENT_MAX_ENTRIES,
8833 )),
8834 http_config: HttpConfig::default(),
8835 };
8836 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
8837 let result = endpoint.create_consumer(rt);
8838 assert!(
8839 result.is_err(),
8840 "must error: https:// requires both tlsCert and tlsKey"
8841 );
8842 }
8843
8844 #[test]
8845 fn load_tls_config_parses_valid_pem() {
8846 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
8848 use camel_component_api::test_support::tls;
8849 let (_, cert_pem, key_pem) = tls::gen_server_cert();
8850 let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
8851 let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
8852
8853 let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
8854 assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
8855 }
8856
8857 #[tokio::test(flavor = "multi_thread")]
8858 #[allow(clippy::await_holding_lock)]
8859 async fn consumer_tls_handshake_roundtrip() {
8860 use camel_component_api::test_support::tls;
8861 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8862
8863 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
8865
8866 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
8868
8869 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
8871 let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
8872 let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
8873 let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
8874
8875 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8877 let port = probe.local_addr().unwrap().port();
8878 drop(probe);
8879
8880 ServerRegistry::reset();
8881
8882 let component = HttpComponent::new();
8884 let endpoint_ctx = NoOpComponentContext;
8885 let uri = format!(
8886 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
8887 cert_path.to_string_lossy(),
8888 key_path.to_string_lossy(),
8889 );
8890 let endpoint = component
8891 .create_endpoint(&uri, &endpoint_ctx)
8892 .expect("create TLS endpoint");
8893 let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
8894
8895 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8897 let token = tokio_util::sync::CancellationToken::new();
8898 let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
8899 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8900
8901 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
8903
8904 let ca_bytes = std::fs::read(&ca_path).unwrap();
8906 let client = reqwest::Client::builder()
8907 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
8908 .build()
8909 .unwrap();
8910
8911 let send_fut = client
8912 .post(format!("https://localhost:{port}/test"))
8913 .body("ping")
8914 .send();
8915
8916 let (http_result, _) = tokio::join!(send_fut, async {
8918 if let Some(mut envelope) = rx.recv().await {
8919 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
8920 if let Some(reply_tx) = envelope.reply_tx {
8921 let _ = reply_tx.send(Ok(envelope.exchange));
8922 }
8923 }
8924 });
8925
8926 let resp = http_result.expect("TLS handshake + request must succeed");
8927
8928 assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
8929 let body = resp.text().await.unwrap();
8930 assert_eq!(body, "pong");
8931
8932 token.cancel();
8933 }
8934
8935 #[tokio::test(flavor = "multi_thread")]
8936 #[allow(clippy::await_holding_lock)]
8937 async fn consumer_tls_rejects_client_without_ca() {
8938 use camel_component_api::test_support::tls;
8939 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8940
8941 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
8942
8943 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
8945
8946 let (_, cert_pem, key_pem) = tls::gen_server_cert();
8947 let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
8948 let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
8949
8950 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8951 let port = probe.local_addr().unwrap().port();
8952 drop(probe);
8953
8954 ServerRegistry::reset();
8955
8956 let component = HttpComponent::new();
8958 let endpoint_ctx = NoOpComponentContext;
8959 let uri = format!(
8960 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
8961 cert_path.to_string_lossy(),
8962 key_path.to_string_lossy(),
8963 );
8964 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
8965 let mut consumer = endpoint.create_consumer(rt()).unwrap();
8966 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8967 let token = tokio_util::sync::CancellationToken::new();
8968 let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
8969 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8970
8971 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
8972
8973 let client = reqwest::Client::builder().build().unwrap();
8975
8976 let result = client
8977 .get(format!("https://localhost:{port}/test"))
8978 .send()
8979 .await;
8980
8981 assert!(
8982 result.is_err(),
8983 "must reject without CA — proves real verification"
8984 );
8985
8986 token.cancel();
8987 }
8988
8989 #[test]
8990 fn server_config_partial_tls_cert_without_key() {
8991 let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
8993 assert!(cfg.tls_config.is_none());
8995 }
8996
8997 #[test]
8998 fn endpoint_uri_options_count_parity() {
8999 assert_eq!(
9001 HttpEndpointConfig::uri_options().len(),
9002 20,
9003 "HttpEndpointUriConfig #[uri_param] count drifted from parser"
9004 );
9005 }
9006
9007 fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
9008 pairs
9009 .iter()
9010 .map(|(k, v)| {
9011 (
9012 (*k).to_string(),
9013 serde_json::Value::String((*v).to_string()),
9014 )
9015 })
9016 .collect()
9017 }
9018
9019 #[test]
9020 fn response_emits_cache_control_via_pragma_warning() {
9021 let headers = make_headers(&[
9022 ("Cache-Control", "public, max-age=3600"),
9023 ("Via", "1.1 myproxy"),
9024 ("Pragma", "no-cache"),
9025 ("Warning", "199 misc"),
9026 ]);
9027 let selected = select_response_headers(&headers, None, None);
9028 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
9029 for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
9030 assert!(
9031 names.contains(&expected),
9032 "{expected} should pass through to the response"
9033 );
9034 }
9035 }
9036
9037 #[test]
9038 fn response_excludes_request_only_and_server_owned() {
9039 let headers = make_headers(&[
9040 ("User-Agent", "x"),
9041 ("Accept", "*/*"),
9042 ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
9043 ]);
9044 let selected = select_response_headers(&headers, None, None);
9045 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
9046 for excluded in ["User-Agent", "Accept", "Date"] {
9047 assert!(
9048 !names.contains(&excluded),
9049 "{excluded} should NOT appear in the response"
9050 );
9051 }
9052 }
9053
9054 #[test]
9055 fn response_re_derives_content_type() {
9056 let headers = make_headers(&[("Content-Type", "text/plain")]);
9057 let selected = select_response_headers(&headers, Some("application/json".into()), None);
9058 let ct_entries: Vec<&str> = selected
9059 .iter()
9060 .filter(|(k, _)| k == "Content-Type")
9061 .map(|(_, v)| v.as_str())
9062 .collect();
9063 assert_eq!(
9064 ct_entries,
9065 ["application/json"],
9066 "exactly one Content-Type entry, re-derived from user_content_type"
9067 );
9068 }
9069
9070 #[test]
9071 fn response_excludes_camel_headers() {
9072 let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
9073 let selected = select_response_headers(&headers, None, None);
9074 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
9075 assert!(
9076 !names.contains(&"CamelHttpPath"),
9077 "Camel-namespace headers must be excluded"
9078 );
9079 assert!(
9080 names.contains(&"Cache-Control"),
9081 "Cache-Control must pass through"
9082 );
9083 }
9084
9085 async fn start_host_capturing_destination() -> (
9097 String,
9098 Arc<std::sync::Mutex<Option<(String, String)>>>,
9099 tokio::task::JoinHandle<()>,
9100 ) {
9101 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9102 let port = listener.local_addr().unwrap().port();
9103 let url = format!("http://127.0.0.1:{port}");
9104 let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
9105 Arc::new(std::sync::Mutex::new(None));
9106 let captured_clone = Arc::clone(&captured);
9107 let handle = tokio::spawn(async move {
9108 use tokio::io::{AsyncReadExt, AsyncWriteExt};
9109 if let Ok((mut stream, _)) = listener.accept().await {
9110 let mut buf = vec![0u8; 16384];
9111 let n = stream.read(&mut buf).await.unwrap_or(0);
9112 let request = String::from_utf8_lossy(&buf[..n]).to_string();
9113 if request.contains("\r\n\r\n") {
9114 let request_line = request.lines().next().unwrap_or("").to_string();
9115 let host_value = request
9116 .lines()
9117 .find(|l| l.to_lowercase().starts_with("host:"))
9118 .and_then(|l| l.split_once(':'))
9119 .map(|(_, v)| v.trim().to_string())
9120 .unwrap_or_default();
9121 *captured_clone.lock().unwrap() = Some((host_value, request_line));
9122 }
9123 let body = r#"{"echo":"ok"}"#;
9124 let resp = format!(
9125 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
9126 body.len(),
9127 body
9128 );
9129 let _ = stream.write_all(resp.as_bytes()).await;
9130 }
9131 });
9132 (url, captured, handle)
9133 }
9134
9135 #[tokio::test]
9140 async fn bridge_proxy_outbound_host_matches_destination() {
9141 use tower::ServiceExt;
9142
9143 let (url, captured, _handle) = start_host_capturing_destination().await;
9144 let expected_host = url.strip_prefix("http://").unwrap();
9147
9148 let ctx = test_producer_ctx();
9149 let component = HttpComponent::new();
9150 let endpoint_ctx = NoOpComponentContext;
9151 let endpoint = component
9152 .create_endpoint(
9153 &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
9154 &endpoint_ctx,
9155 )
9156 .unwrap();
9157 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
9158
9159 let mut exchange = Exchange::new(Message::default());
9162 exchange.input.set_header("Host", "localhost");
9163 exchange.input.set_header("CamelHttpPath", "/foo");
9164
9165 let result = producer.oneshot(exchange).await;
9166 assert!(result.is_ok(), "producer call failed: {:?}", result);
9167
9168 tokio::time::sleep(Duration::from_millis(100)).await;
9169 let (host_value, request_line) = captured
9170 .lock()
9171 .unwrap()
9172 .take()
9173 .expect("destination capture mutex empty — producer did not reach the destination");
9174
9175 assert_ne!(
9176 host_value, "localhost",
9177 "bridge producer must not forward the exchange Host: localhost"
9178 );
9179 assert_eq!(
9180 host_value, expected_host,
9181 "Host must be derived from the destination authority (no scheme)"
9182 );
9183 assert!(
9184 !request_line.contains("/foo"),
9185 "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
9186 );
9187 }
9188
9189 #[tokio::test]
9194 async fn bridge_proxy_route_set_response_header_survives() {
9195 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
9196
9197 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
9198 let port = listener.local_addr().unwrap().port();
9199 drop(listener);
9200
9201 let component = HttpComponent::new();
9202 let endpoint_ctx = NoOpComponentContext;
9203 let endpoint = component
9204 .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
9205 .unwrap();
9206 let mut consumer = endpoint.create_consumer(rt()).unwrap();
9207
9208 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
9209 let token = tokio_util::sync::CancellationToken::new();
9210 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
9211
9212 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
9213 tokio::time::sleep(Duration::from_millis(50)).await;
9214
9215 let client = reqwest::Client::new();
9216 let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
9217
9218 let (http_result, _) = tokio::join!(send_fut, async {
9222 if let Some(mut envelope) = rx.recv().await {
9223 envelope
9224 .exchange
9225 .input
9226 .set_header("Cache-Control", "public, max-age=3600");
9227 if let Some(reply_tx) = envelope.reply_tx {
9228 let _ = reply_tx.send(Ok(envelope.exchange));
9229 }
9230 }
9231 });
9232
9233 let resp = http_result.unwrap();
9234 assert_eq!(resp.status().as_u16(), 200);
9235
9236 let cache_control = resp.headers().get("cache-control");
9237 assert!(
9238 cache_control.is_some(),
9239 "Cache-Control header must survive to the wire response"
9240 );
9241 assert_eq!(
9242 cache_control.unwrap().to_str().unwrap(),
9243 "public, max-age=3600"
9244 );
9245
9246 token.cancel();
9247 }
9248
9249 use camel_api::security_policy::CredentialSource;
9268 use camel_auth::credential_source::extract_token_from_exchange;
9269 use camel_auth::native_auth::NativeCredentialStore;
9270 use camel_auth::{StaticTokenAuthenticator, TokenAuthenticator};
9271
9272 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 {
9280 let mut msg = Message::default();
9281 msg.set_header(
9282 "CamelHttpMethod",
9283 serde_json::Value::String(envelope.method.clone()),
9284 );
9285 msg.set_header(
9286 "CamelHttpPath",
9287 serde_json::Value::String(envelope.path.clone()),
9288 );
9289 msg.set_header(
9290 "CamelHttpQuery",
9291 serde_json::Value::String(envelope.query.clone()),
9292 );
9293 for (k, v) in &envelope.headers {
9294 if let Ok(val_str) = v.to_str() {
9295 msg.set_header(
9296 title_case_header(k.as_str()),
9297 serde_json::Value::String(val_str.to_string()),
9298 );
9299 }
9300 }
9301 Exchange::new(msg)
9302 }
9303
9304 async fn spawn_failing_auth_route(
9311 registry: &HttpRouteRegistry,
9312 path: &str,
9313 sources: Vec<CredentialSource>,
9314 ) {
9315 let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
9316 NativeCredentialStore::try_new(vec![]).unwrap(),
9317 ));
9318 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
9319 registry.register_api_route(path.to_string(), tx).await;
9320 let path_owned = path.to_string();
9321 tokio::spawn(async move {
9322 while let Some(envelope) = rx.recv().await {
9323 let exchange = envelope_to_exchange(&envelope);
9324 let reply_tx = envelope.reply_tx;
9325 let result: Result<(), CamelError> = async {
9326 let token = extract_token_from_exchange(&exchange, &sources)
9327 .map(|extracted| extracted.token)
9328 .ok_or_else(|| {
9329 CamelError::Unauthenticated("no credential in any source".into())
9330 })?;
9331 authenticator.authenticate_bearer(&token).await?;
9332 Ok(())
9333 }
9334 .await;
9335 let reply = match result {
9336 Ok(()) => HttpReply {
9337 status: 200,
9338 headers: vec![],
9339 body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
9340 },
9341 Err(e) => pipeline_error_to_reply(e, &path_owned),
9342 };
9343 let _ = reply_tx.send(reply);
9344 }
9345 });
9346 }
9347
9348 fn captured_logs_contain(needle: &str) -> bool {
9352 let buf = tracing_test::internal::global_buf().lock().unwrap();
9353 String::from_utf8_lossy(&buf).contains(needle)
9354 }
9355
9356 #[tracing_test::traced_test]
9357 #[tokio::test]
9358 async fn error_context_redacts_query_sentinel() {
9359 let (port, registry) = spawn_test_server().await;
9360 spawn_failing_auth_route(
9361 ®istry,
9362 "/secure-query",
9363 vec![CredentialSource::QueryParam {
9364 param: "token".to_string(),
9365 }],
9366 )
9367 .await;
9368
9369 let client = reqwest::Client::new();
9370 let resp = client
9371 .get(format!(
9373 "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
9374 ))
9375 .send()
9376 .await
9377 .unwrap();
9378
9379 assert_eq!(resp.status().as_u16(), 401);
9380 let body = resp.text().await.unwrap();
9381 assert_eq!(body, "Unauthorized");
9382 assert!(
9383 !body.contains(SENTINEL_QRY_42),
9384 "reply body must not contain the query credential"
9385 );
9386 assert!(
9387 !captured_logs_contain(SENTINEL_QRY_42),
9388 "no tracing record during request handling may render the query credential"
9389 );
9390 assert!(
9394 captured_logs_contain("Authentication failed"),
9395 "positive control: the failed-auth warn! must be captured by the test subscriber"
9396 );
9397 }
9398
9399 #[tracing_test::traced_test]
9400 #[tokio::test]
9401 async fn error_context_redacts_cookie_sentinel() {
9402 let (port, registry) = spawn_test_server().await;
9403 spawn_failing_auth_route(
9404 ®istry,
9405 "/secure-cookie",
9406 vec![CredentialSource::Cookie {
9407 name: "session".to_string(),
9408 }],
9409 )
9410 .await;
9411
9412 let client = reqwest::Client::new();
9413 let resp = client
9414 .get(format!("http://127.0.0.1:{port}/secure-cookie"))
9415 .header("Cookie", format!("session={SENTINEL_CKY_7}"))
9416 .send()
9417 .await
9418 .unwrap();
9419
9420 assert_eq!(resp.status().as_u16(), 401);
9421 let body = resp.text().await.unwrap();
9422 assert_eq!(body, "Unauthorized");
9423 assert!(
9424 !body.contains(SENTINEL_CKY_7),
9425 "reply body must not contain the cookie credential"
9426 );
9427 assert!(
9428 !captured_logs_contain(SENTINEL_CKY_7),
9429 "no tracing record during request handling may render the cookie credential"
9430 );
9431 }
9432
9433 #[tracing_test::traced_test]
9434 #[tokio::test]
9435 async fn error_reply_no_credential_value() {
9436 let (port, registry) = spawn_test_server().await;
9437 spawn_failing_auth_route(
9438 ®istry,
9439 "/secure-bad",
9440 vec![CredentialSource::Cookie {
9441 name: "session".to_string(),
9442 }],
9443 )
9444 .await;
9445
9446 let client = reqwest::Client::new();
9447 let resp = client
9448 .get(format!("http://127.0.0.1:{port}/secure-bad"))
9449 .header("Cookie", format!("session={SENTINEL_BAD_1}"))
9450 .send()
9451 .await
9452 .unwrap();
9453
9454 assert_eq!(resp.status().as_u16(), 401);
9455 let body = resp.text().await.unwrap();
9456 assert_eq!(body, "Unauthorized");
9457 assert!(
9458 !body.contains(SENTINEL_BAD_1),
9459 "reply body must not contain the credential value"
9460 );
9461 assert!(
9462 !captured_logs_contain(SENTINEL_BAD_1),
9463 "error logs must not render the credential value"
9464 );
9465 }
9466
9467 async fn spawn_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
9481 use tokio::io::AsyncWriteExt;
9482
9483 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
9484 .await
9485 .expect("bind ephemeral 127.0.0.1 listener");
9486 let port = listener.local_addr().expect("local addr").port();
9487 let base_url = format!("http://localhost:{port}");
9488 let handle = tokio::spawn(async move {
9489 while let Ok((mut conn, _)) = listener.accept().await {
9490 let _ = conn
9491 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
9492 .await;
9493 let _ = conn.shutdown().await;
9494 }
9495 });
9496 (base_url, handle)
9497 }
9498
9499 fn responder_port(base_url: &str) -> u16 {
9503 url::Url::parse(base_url)
9504 .expect("responder base URL parses")
9505 .port()
9506 .expect("responder base URL carries an explicit port")
9507 }
9508
9509 fn endpoint_with_shared_cache(
9514 base_url: &str,
9515 pinned_cache: &Arc<PinnedClientCache>,
9516 ) -> HttpEndpoint {
9517 let uri = format!("{base_url}?allowInternal=true");
9518 HttpEndpoint {
9519 uri: uri.clone(),
9520 config: HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses"),
9521 server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
9522 client: reqwest::Client::new(),
9523 pinned_cache: Arc::clone(pinned_cache),
9524 http_config: HttpConfig::default(),
9525 }
9526 }
9527
9528 #[tokio::test]
9529 async fn producers_share_endpoint_cache() {
9530 use tower::ServiceExt;
9531
9532 let (base_url, _handle) = spawn_multi_accept_200().await;
9533 let pinned_cache = Arc::new(PinnedClientCache::new(
9534 PINNED_CLIENT_TTL,
9535 PINNED_CLIENT_MAX_ENTRIES,
9536 ));
9537
9538 let ctx = test_producer_ctx();
9539 let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
9540 let producer_a = endpoint.create_producer(rt(), &ctx);
9541 let producer_b = endpoint.create_producer(rt(), &ctx);
9542
9543 for producer in [producer_a, producer_b] {
9546 let producer = producer.expect("create producer");
9547 let exchange = Exchange::new(Message::default());
9548 let reply = producer.oneshot(exchange).await;
9549 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
9550 }
9551
9552 assert_eq!(
9553 pinned_cache.build_count(),
9554 1,
9555 "both producers must hit the same shared cache entry; a second \
9556 build means sharing is broken"
9557 );
9558 }
9559
9560 #[tokio::test]
9561 async fn producer_repeated_hostname_requests_build_one_client() {
9562 use tower::ServiceExt;
9563
9564 let (base_url, _handle) = spawn_multi_accept_200().await;
9565 let pinned_cache = Arc::new(PinnedClientCache::new(
9566 PINNED_CLIENT_TTL,
9567 PINNED_CLIENT_MAX_ENTRIES,
9568 ));
9569 let ctx = test_producer_ctx();
9570 let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
9571 let producer = endpoint
9572 .create_producer(rt(), &ctx)
9573 .expect("create producer");
9574
9575 for i in 0..2 {
9578 let exchange = Exchange::new(Message::default());
9579 let reply = producer.clone().oneshot(exchange).await;
9580 assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
9581 }
9582
9583 assert_eq!(
9584 pinned_cache.build_count(),
9585 1,
9586 "repeated hostname requests must reuse the one pinned client; \
9587 0 builds means the producer bypassed the cache, more than 1 \
9588 means the entry was dropped"
9589 );
9590 }
9591
9592 #[tokio::test]
9593 async fn ip_literal_request_never_enters_cache() {
9594 use tower::ServiceExt;
9595
9596 let (base_url, _handle) = spawn_multi_accept_200().await;
9597 let pinned_cache = Arc::new(PinnedClientCache::new(
9598 PINNED_CLIENT_TTL,
9599 PINNED_CLIENT_MAX_ENTRIES,
9600 ));
9601
9602 let ctx = test_producer_ctx();
9603 let destination = format!("http://127.0.0.1:{}/ping", responder_port(&base_url));
9604 let endpoint = endpoint_with_shared_cache(&destination, &pinned_cache);
9605 let producer = endpoint
9606 .create_producer(rt(), &ctx)
9607 .expect("create producer");
9608
9609 let exchange = Exchange::new(Message::default());
9610 let reply = producer.oneshot(exchange).await;
9611 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
9612
9613 assert_eq!(
9614 pinned_cache.build_count(),
9615 0,
9616 "an IP-literal URL must use the shared unpinned client and \
9617 never enter the pinned cache"
9618 );
9619 }
9620
9621 #[tokio::test]
9622 async fn test_component_endpoints_share_pinned_cache() {
9623 use tower::ServiceExt;
9624
9625 let component = HttpComponent::new();
9626 let (base_url, _handle) = spawn_multi_accept_200().await;
9627 let baseline = component.pinned_cache.build_count();
9628
9629 let ctx = test_producer_ctx();
9630 let endpoint_ctx = NoOpComponentContext;
9631 for uri in [
9632 format!("{base_url}/a?allowInternal=true&k=a"),
9633 format!("{base_url}/b?allowInternal=true&k=b"),
9634 ] {
9635 let endpoint = component
9636 .create_endpoint(&uri, &endpoint_ctx)
9637 .expect("create endpoint");
9638 let producer = endpoint
9639 .create_producer(rt(), &ctx)
9640 .expect("create producer");
9641 let exchange = Exchange::new(Message::default());
9642 let reply = producer.oneshot(exchange).await;
9643 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
9644 }
9645
9646 assert_eq!(
9647 component.pinned_cache.build_count() - baseline,
9648 1,
9649 "endpoints created by one component must share its pinned cache; \
9650 0 builds means the endpoints bypassed it, more than 1 means \
9651 per-endpoint caches came back"
9652 );
9653 }
9654
9655 #[tokio::test]
9656 async fn test_dynamic_resolution_sequence_hits_shared_cache() {
9657 use tower::ServiceExt;
9658
9659 let component = HttpComponent::new();
9660 let (base_url, _handle) = spawn_multi_accept_200().await;
9661 let baseline = component.pinned_cache.build_count();
9662
9663 let ctx = test_producer_ctx();
9664 let endpoint_ctx = NoOpComponentContext;
9665 for i in 0..3 {
9666 let endpoint = component
9667 .create_endpoint(
9668 &format!("{base_url}/r{i}?allowInternal=true&k={i}"),
9669 &endpoint_ctx,
9670 )
9671 .expect("create endpoint");
9672 let producer = endpoint
9673 .create_producer(rt(), &ctx)
9674 .expect("create producer");
9675 let exchange = Exchange::new(Message::default());
9676 let reply = producer.oneshot(exchange).await;
9677 assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
9678 }
9679
9680 assert_eq!(
9681 component.pinned_cache.build_count() - baseline,
9682 1,
9683 "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
9684 must reuse the component's one pinned cache entry; 0 builds \
9685 means the endpoints bypassed it, more than 1 means \
9686 per-endpoint caches came back"
9687 );
9688 }
9689
9690 #[test]
9691 fn test_https_component_owns_distinct_cache() {
9692 let http = HttpComponent::new();
9693 let https = HttpsComponent::new();
9694
9695 assert!(
9696 !Arc::ptr_eq(&http.pinned_cache, &https.pinned_cache),
9697 "http and https components must each own their own pinned cache"
9698 );
9699
9700 let endpoint_ctx = NoOpComponentContext;
9701 let _ = http
9702 .create_endpoint("http://localhost:1/?allowInternal=true", &endpoint_ctx)
9703 .expect("http endpoint");
9704 let _ = https
9705 .create_endpoint("https://localhost:1/?allowInternal=true", &endpoint_ctx)
9706 .expect("https endpoint");
9707
9708 assert_eq!(
9709 http.pinned_cache.build_count(),
9710 0,
9711 "endpoint creation must not build a pinned client"
9712 );
9713 assert_eq!(
9714 https.pinned_cache.build_count(),
9715 0,
9716 "endpoint creation must not build a pinned client"
9717 );
9718 }
9719
9720 #[test]
9721 fn test_component_constructor_builds_one_unpinned_client() {
9722 let baseline = build_client_call_count();
9723
9724 let _http = HttpComponent::new();
9725 assert_eq!(
9726 build_client_call_count() - baseline,
9727 1,
9728 "HttpComponent::new() must build exactly one shared unpinned client"
9729 );
9730
9731 let _https = HttpsComponent::new();
9732 assert_eq!(
9733 build_client_call_count() - baseline,
9734 2,
9735 "HttpsComponent::new() must build exactly one more shared unpinned client"
9736 );
9737 }
9738
9739 #[test]
9740 fn test_component_endpoints_share_unpinned_client() {
9741 let component = HttpComponent::new();
9742 let baseline = build_client_call_count();
9743
9744 let endpoint_ctx = NoOpComponentContext;
9745 for uri in [
9746 "http://localhost:1/a?allowInternal=true",
9747 "http://localhost:1/b?allowInternal=true",
9748 ] {
9749 let _endpoint = component
9750 .create_endpoint(uri, &endpoint_ctx)
9751 .expect("create endpoint");
9752 }
9753
9754 assert_eq!(
9755 build_client_call_count() - baseline,
9756 0,
9757 "create_endpoint must clone the component's shared unpinned client, \
9758 never build a fresh one"
9759 );
9760 }
9761
9762 #[test]
9763 fn test_dynamic_resolution_adds_no_unpinned_client_builds() {
9764 let component = HttpComponent::new();
9765 let baseline = build_client_call_count();
9766
9767 let ctx = test_producer_ctx();
9768 let endpoint_ctx = NoOpComponentContext;
9769 for i in 0..3 {
9770 let endpoint = component
9771 .create_endpoint(
9772 &format!("http://localhost:1/r{i}?allowInternal=true&k={i}"),
9773 &endpoint_ctx,
9774 )
9775 .expect("create endpoint");
9776 let _producer = endpoint
9777 .create_producer(rt(), &ctx)
9778 .expect("create producer");
9779 }
9780
9781 assert_eq!(
9782 build_client_call_count() - baseline,
9783 0,
9784 "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
9785 must reuse the component's shared unpinned client and build \
9786 no additional clients"
9787 );
9788 }
9789}