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 max_request_body: usize,
779 max_response_body: usize,
780 max_inflight_requests: usize,
781 is_tls: bool,
782 tls_cert_path: Option<String>,
783 tls_key_path: Option<String>,
784 monitor_task: tokio::task::JoinHandle<()>,
787 tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
790 tls_source: Option<ServerTlsSource>,
791}
792
793pub struct ServerRegistry {
795 inner: Mutex<HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>>,
796}
797
798impl ServerRegistry {
799 pub fn global() -> &'static Self {
801 static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
802 INSTANCE.get_or_init(|| ServerRegistry {
803 inner: Mutex::new(HashMap::new()),
804 })
805 }
806
807 #[allow(clippy::too_many_arguments)]
810 pub async fn get_or_spawn(
811 &'static self,
812 host: &str,
813 port: u16,
814 max_request_body: usize,
815 max_response_body: usize,
816 max_inflight_requests: usize,
817 runtime: Arc<dyn RuntimeObservability>,
818 route_id: String,
819 tls_config: Option<crate::config::ServerTlsConfig>,
820 ) -> Result<HttpRouteRegistry, CamelError> {
821 let host_owned = host.to_string();
822
823 let cell = {
824 let mut guard = self.inner.lock().map_err(|_| {
825 CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
826 })?;
827 let key = (host.to_string(), port);
828 if let Some(existing) = guard.get(&key)
832 && let Some(handle) = existing.get()
833 && handle.monitor_task.is_finished()
834 {
835 if handle.is_tls {
838 let scheme = if handle.is_tls { "https" } else { "http" };
839 camel_component_api::tls_source::TlsReloadRegistry::global()
840 .unregister(scheme, host, port);
841 }
842 guard.remove(&key);
843 }
844 guard
845 .entry(key)
846 .or_insert_with(|| Arc::new(OnceCell::new()))
847 .clone()
848 };
849
850 if let Some(existing) = cell.get()
851 && existing.max_request_body != max_request_body
852 {
853 return Err(CamelError::EndpointCreationFailed(format!(
854 "incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
855 existing.max_request_body, max_request_body
856 )));
857 }
858
859 if let Some(existing) = cell.get()
860 && existing.max_response_body != max_response_body
861 {
862 return Err(CamelError::EndpointCreationFailed(format!(
863 "incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
864 existing.max_response_body, max_response_body
865 )));
866 }
867
868 if let Some(existing) = cell.get()
869 && existing.max_inflight_requests != max_inflight_requests
870 {
871 return Err(CamelError::EndpointCreationFailed(format!(
872 "incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
873 existing.max_inflight_requests, max_inflight_requests
874 )));
875 }
876
877 if let Some(existing) = cell.get()
879 && existing.is_tls != tls_config.is_some()
880 {
881 return Err(CamelError::EndpointCreationFailed(format!(
882 "incompatible TLS mode for shared server (host={host}, port={port}): existing is_tls={}, new has_tls={}",
883 existing.is_tls,
884 tls_config.is_some()
885 )));
886 }
887
888 if let (Some(existing), Some(new_tls)) = (cell.get(), &tls_config)
890 && (existing.tls_cert_path.as_deref() != Some(&new_tls.cert_path)
891 || existing.tls_key_path.as_deref() != Some(&new_tls.key_path))
892 {
893 return Err(CamelError::EndpointCreationFailed(format!(
894 "incompatible TLS cert/key for shared server (host={host}, port={port}): routes on the same TLS port must use the same cert and key"
895 )));
896 }
897
898 let handle = cell
899 .get_or_try_init(|| {
900 let rt = Arc::clone(&runtime);
901 let rid = route_id.clone();
902 async move {
903 let addr = format!("{host_owned}:{port}");
904 let listener = tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
905 CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
906 })?;
907 let registry = HttpRouteRegistry::new();
908 let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
909 let tls_rustls_cfg: Option<axum_server::tls_rustls::RustlsConfig>;
912 let tls_source: Option<ServerTlsSource>;
913 let server_task = if let Some(ref tls) = tls_config {
914 let rustls_config = load_tls_config(&tls.cert_path, &tls.key_path)?;
915 let source = ServerTlsSource {
916 cert_path: std::path::PathBuf::from(&tls.cert_path),
917 key_path: std::path::PathBuf::from(&tls.key_path),
918 client_ca_path: None,
919 };
920 let rustls_cfg = axum_server::tls_rustls::RustlsConfig::from_config(
924 std::sync::Arc::new(rustls_config),
925 );
926 tls_rustls_cfg = Some(rustls_cfg.clone());
927 tls_source = Some(source);
928 let std_listener = listener.into_std().map_err(|e| {
930 CamelError::EndpointCreationFailed(format!(
931 "TLS listener conversion: {e}"
932 ))
933 })?;
934 tokio::spawn(run_axum_server_tls(
935 std_listener,
936 rustls_cfg,
937 registry.clone(),
938 max_request_body,
939 max_response_body,
940 Arc::clone(&inflight),
941 Arc::clone(&rt),
942 rid.clone(),
943 ))
944 } else {
945 tls_rustls_cfg = None;
946 tls_source = None;
947 tokio::spawn(run_axum_server(
948 listener,
949 registry.clone(),
950 max_request_body,
951 max_response_body,
952 Arc::clone(&inflight),
953 Arc::clone(&rt),
954 rid.clone(),
955 ))
956 };
957 let addr_for_monitor = format!("{host_owned}:{port}");
958 let monitor_task = tokio::spawn(monitor_axum_task(
959 server_task,
960 addr_for_monitor,
961 Arc::clone(&rt),
962 rid,
963 ));
964 let handle = ServerHandle {
965 registry,
966 max_request_body,
967 max_response_body,
968 max_inflight_requests,
969 is_tls: tls_config.is_some(),
970 tls_cert_path: tls_config.as_ref().map(|t| t.cert_path.clone()),
971 tls_key_path: tls_config.as_ref().map(|t| t.key_path.clone()),
972 monitor_task,
973 tls_config: tls_rustls_cfg,
974 tls_source,
975 };
976 if let (Some(tls_cfg), Some(source)) =
981 (handle.tls_config.as_ref(), handle.tls_source.as_ref())
982 {
983 let handler = Arc::new(crate::tls_reload::HttpReloadHandler::new(
984 tls_cfg.clone(),
985 source.clone(),
986 host_owned.clone(),
987 port,
988 ));
989 camel_component_api::tls_source::TlsReloadRegistry::global()
990 .register(handler);
991 }
992 Ok::<ServerHandle, CamelError>(handle)
993 }
994 })
995 .await?;
996
997 Ok(handle.registry.clone())
998 }
999
1000 pub async fn unregister(&self, host: &str, port: u16) {
1004 debug!(
1005 host = host,
1006 port = port,
1007 "consumer unregistered from HTTP server"
1008 );
1009 }
1010
1011 #[cfg(test)]
1018 pub fn reset() {
1019 let instance = Self::global();
1020 let mut guard = instance
1021 .inner
1022 .lock()
1023 .expect("ServerRegistry lock poisoned during test reset");
1024 guard.clear();
1025 }
1026}
1027
1028use axum::{
1033 Router,
1034 body::Body as AxumBody,
1035 extract::{Request, State},
1036 http::{Response, StatusCode},
1037 response::IntoResponse,
1038};
1039
1040#[derive(Clone)]
1041pub(crate) struct AppState {
1042 registry: HttpRouteRegistry,
1043 max_request_body: usize,
1044 max_response_body: usize,
1045 inflight: Arc<tokio::sync::Semaphore>,
1046}
1047
1048const CONSUMER_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
1055
1056async fn run_axum_server(
1057 listener: tokio::net::TcpListener,
1058 registry: HttpRouteRegistry,
1059 max_request_body: usize,
1060 max_response_body: usize,
1061 inflight: Arc<tokio::sync::Semaphore>,
1062 runtime: Arc<dyn RuntimeObservability>,
1063 route_id: String,
1064) {
1065 let state = AppState {
1066 registry,
1067 max_request_body,
1068 max_response_body,
1069 inflight,
1070 };
1071 let app = Router::new()
1072 .fallback(dispatch_handler)
1073 .with_state(state)
1074 .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1075 StatusCode::REQUEST_TIMEOUT,
1076 CONSUMER_REQUEST_TIMEOUT,
1077 ));
1078
1079 axum::serve(listener, app).await.unwrap_or_else(|e| {
1080 runtime
1081 .metrics()
1082 .increment_errors(&route_id, "e:http:accept");
1083 tracing::error!(error = %e, "Axum server error");
1085 });
1086}
1087
1088#[allow(clippy::too_many_arguments)]
1089async fn run_axum_server_tls(
1090 listener: std::net::TcpListener,
1091 tls_cfg: axum_server::tls_rustls::RustlsConfig,
1092 registry: HttpRouteRegistry,
1093 max_request_body: usize,
1094 max_response_body: usize,
1095 inflight: Arc<tokio::sync::Semaphore>,
1096 runtime: Arc<dyn RuntimeObservability>,
1097 route_id: String,
1098) {
1099 let state = AppState {
1100 registry,
1101 max_request_body,
1102 max_response_body,
1103 inflight,
1104 };
1105 let app = Router::new()
1106 .fallback(dispatch_handler)
1107 .with_state(state)
1108 .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1109 StatusCode::REQUEST_TIMEOUT,
1110 CONSUMER_REQUEST_TIMEOUT,
1111 ));
1112
1113 let server = match axum_server::from_tcp_rustls(listener, tls_cfg) {
1118 Ok(server) => server,
1119 Err(e) => {
1120 runtime
1121 .metrics()
1122 .increment_errors(&route_id, "e:http:accept-tls");
1123 tracing::error!(error = %e, "Axum TLS server setup error");
1125 return;
1126 }
1127 };
1128
1129 server
1130 .serve(app.into_make_service())
1131 .await
1132 .unwrap_or_else(|e| {
1133 runtime
1134 .metrics()
1135 .increment_errors(&route_id, "e:http:accept-tls");
1136 tracing::error!(error = %e, "Axum TLS server error");
1138 });
1139}
1140
1141async fn monitor_axum_task(
1149 handle: tokio::task::JoinHandle<()>,
1150 addr: String,
1151 runtime: Arc<dyn RuntimeObservability>,
1152 route_id: String,
1153) {
1154 match handle.await {
1155 Ok(()) => {
1156 }
1158 Err(join_err) => {
1159 runtime
1160 .metrics()
1161 .increment_errors(&route_id, "e:http:server-task-exited");
1162 tracing::error!(
1164 addr = %addr,
1165 error = %join_err,
1166 "Axum server task exited unexpectedly — all routes on this port are now dead"
1167 );
1168 }
1169 }
1170}
1171
1172fn load_tls_config(
1175 cert_path: &str,
1176 key_path: &str,
1177) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
1178 use std::fs::File;
1179 use std::io::BufReader;
1180
1181 let cert_file = File::open(cert_path)
1182 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
1183 let key_file = File::open(key_path)
1184 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
1185
1186 let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
1187 .collect::<Result<Vec<_>, _>>()
1188 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
1189
1190 let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
1191 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
1192 .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
1193
1194 tokio_rustls::rustls::ServerConfig::builder()
1195 .with_no_client_auth()
1196 .with_single_cert(certs, key)
1197 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
1198}
1199
1200async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
1201 let path = req.uri().path().to_owned();
1202 let method = req.method().to_string();
1203
1204 let api_sender = {
1221 let inner = state.registry.inner.read().await;
1222 inner.api_routes.get(&path).cloned()
1223 }; let (rest_sender, path_params) = if api_sender.is_some() {
1226 (None, Default::default())
1228 } else {
1229 let inner = state.registry.inner.read().await;
1230 match rest_match::match_endpoint(&method, &path, &inner.rest_endpoints) {
1231 rest_match::MatchOutcome::Found(m) => (Some(m.payload), m.path_params),
1232 rest_match::MatchOutcome::Ambiguous => {
1233 tracing::warn!(
1239 method = %method,
1240 path = %path,
1241 "ambiguous REST template match — returning 500"
1242 );
1243 return Response::builder()
1244 .status(StatusCode::INTERNAL_SERVER_ERROR)
1245 .body(AxumBody::from("Internal Server Error"))
1246 .expect("infallible"); }
1248 rest_match::MatchOutcome::NotFound => (None, Default::default()),
1249 }
1250 }; let sender = api_sender.or(rest_sender);
1253
1254 if let Some(sender) = sender {
1255 let query = req.uri().query().unwrap_or("").to_string();
1256 let headers = req.headers().clone();
1257
1258 let content_length: Option<u64> = headers
1260 .get(http::header::CONTENT_LENGTH)
1261 .and_then(|v| v.to_str().ok())
1262 .and_then(|s| s.parse().ok());
1263
1264 if let Some(len) = content_length
1265 && len > state.max_request_body as u64
1266 {
1267 return Response::builder()
1268 .status(StatusCode::PAYLOAD_TOO_LARGE)
1269 .body(AxumBody::from("Request body exceeds configured limit"))
1270 .expect("infallible"); }
1272
1273 let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
1274 Ok(permit) => permit,
1275 Err(_) => {
1276 return Response::builder()
1277 .status(StatusCode::SERVICE_UNAVAILABLE)
1278 .body(AxumBody::from("Service Unavailable"))
1279 .expect("infallible"); }
1281 };
1282
1283 let content_type = headers
1289 .get(http::header::CONTENT_TYPE)
1290 .and_then(|v| v.to_str().ok())
1291 .map(|s| s.to_string());
1292
1293 let data_stream: BodyDataStream = req.into_body().into_data_stream();
1294 let max_body = state.max_request_body;
1295 let mut seen: u64 = 0;
1296 let capped_stream =
1297 data_stream
1298 .map_err(|e| CamelError::Io(e.to_string()))
1299 .map(move |chunk| match chunk {
1300 Ok(bytes) => {
1301 seen = seen.saturating_add(bytes.len() as u64);
1302 if seen > max_body as u64 {
1303 Err(CamelError::ProcessorError(format!(
1304 "Request body exceeds configured limit of {max_body} bytes"
1305 )))
1306 } else {
1307 Ok(bytes)
1308 }
1309 }
1310 Err(e) => Err(e),
1311 });
1312 let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(capped_stream);
1313
1314 let stream_body = StreamBody {
1315 stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
1316 metadata: StreamMetadata {
1317 size_hint: content_length,
1318 content_type,
1319 origin: None,
1320 },
1321 };
1322
1323 let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
1324 let envelope = RequestEnvelope {
1325 method,
1326 path,
1327 query,
1328 headers,
1329 body: stream_body,
1330 path_params,
1331 reply_tx,
1332 };
1333
1334 if sender.send(envelope).await.is_err() {
1335 return Response::builder()
1336 .status(StatusCode::SERVICE_UNAVAILABLE)
1337 .body(AxumBody::from("Consumer unavailable"))
1338 .expect("infallible"); }
1340
1341 match reply_rx.await {
1342 Ok(reply) => {
1343 let reply = match reply.body {
1344 HttpReplyBody::Bytes(b)
1345 if exceeds_max_response_body(b.len(), state.max_response_body) =>
1346 {
1347 HttpReply {
1348 status: 500,
1349 headers: vec![],
1350 body: HttpReplyBody::Bytes(bytes::Bytes::from(
1351 "Response body exceeds configured limit",
1352 )),
1353 }
1354 }
1355 _ => reply,
1356 };
1357
1358 let status =
1359 StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1360 let mut builder = Response::builder().status(status);
1361 for (k, v) in &reply.headers {
1362 builder = builder.header(k.as_str(), v.as_str());
1363 }
1364 match reply.body {
1365 HttpReplyBody::Bytes(b) => {
1366 builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
1367 Response::builder()
1368 .status(StatusCode::INTERNAL_SERVER_ERROR)
1369 .body(AxumBody::from("Invalid response headers from consumer"))
1370 .expect("infallible") })
1372 }
1373 HttpReplyBody::Stream(stream) => builder
1374 .body(AxumBody::from_stream(stream))
1375 .unwrap_or_else(|_| {
1376 Response::builder()
1377 .status(StatusCode::INTERNAL_SERVER_ERROR)
1378 .body(AxumBody::from("Invalid response headers from consumer"))
1379 .expect("infallible") }),
1381 }
1382 }
1383 Err(_) => Response::builder()
1384 .status(StatusCode::INTERNAL_SERVER_ERROR)
1385 .body(AxumBody::from("Pipeline error"))
1386 .expect("infallible"), }
1388 } else {
1389 static_dispatch::dispatch_static(&state, req, &path).await
1391 }
1392}
1393
1394fn exceeds_max_response_body(len: usize, max: usize) -> bool {
1395 len > max
1396}
1397
1398fn title_case_header(name: &str) -> String {
1399 name.split('-')
1400 .map(|part| {
1401 let mut chars = part.chars();
1402 match chars.next() {
1403 None => String::new(),
1404 Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
1405 }
1406 })
1407 .collect::<Vec<_>>()
1408 .join("-")
1409}
1410
1411pub(crate) struct HttpKernelAuth {
1426 pub(crate) plan: camel_api::security_policy::RouteSecurityPlan,
1427 pub(crate) providers: Arc<camel_auth::ProviderRegistry>,
1428}
1429
1430impl HttpKernelAuth {
1431 pub(crate) fn from_security_context(
1436 ctx: &camel_component_api::SecurityContext,
1437 ) -> Option<Self> {
1438 Some(Self {
1439 plan: ctx.plan.clone()?,
1440 providers: ctx.providers.clone()?,
1441 })
1442 }
1443}
1444
1445fn envelope_channel_capacity(max_inflight_requests: usize) -> usize {
1459 max_inflight_requests.max(1)
1460}
1461
1462pub struct HttpConsumer {
1463 config: HttpServerConfig,
1464 runtime: Arc<dyn RuntimeObservability>,
1466 kernel: Option<Arc<HttpKernelAuth>>,
1470}
1471
1472impl HttpConsumer {
1473 pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
1474 Self {
1475 config,
1476 runtime,
1477 kernel: None,
1478 }
1479 }
1480}
1481
1482#[async_trait::async_trait]
1483impl Consumer for HttpConsumer {
1484 async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
1485 use camel_component_api::{Body, Exchange, Message};
1486
1487 let registry = ServerRegistry::global()
1488 .get_or_spawn(
1489 &self.config.host,
1490 self.config.port,
1491 self.config.max_request_body,
1492 self.config.max_response_body,
1493 self.config.max_inflight_requests,
1494 self.runtime.clone(),
1495 ctx.route_id().to_string(),
1496 self.config.tls_config.clone(),
1497 )
1498 .await?;
1499
1500 let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(
1504 envelope_channel_capacity(self.config.max_inflight_requests),
1505 );
1506 if let Some(method) = self.config.method.clone() {
1513 let segments = rest_match::parse_path_template(&self.config.path);
1514 registry
1515 .register_rest_endpoint(method, segments, env_tx)
1516 .await;
1517 } else {
1518 registry
1519 .register_api_route(self.config.path.clone(), env_tx)
1520 .await;
1521 }
1522
1523 ctx.mark_ready();
1532
1533 let path = self.config.path.clone();
1534 let registry_for_cleanup = registry.clone();
1535 let cancel_token = ctx.cancel_token();
1536 let kernel = self.kernel.clone();
1537 loop {
1538 tokio::select! {
1539 _ = ctx.cancelled() => {
1540 break;
1541 }
1542 envelope = env_rx.recv() => {
1543 let Some(envelope) = envelope else { break; };
1544
1545 let mut msg = Message::default();
1547
1548 msg.set_header("CamelHttpMethod",
1550 serde_json::Value::String(envelope.method.clone()));
1551 msg.set_header("CamelHttpPath",
1552 serde_json::Value::String(envelope.path.clone()));
1553 msg.set_header("CamelHttpQuery",
1554 serde_json::Value::String(envelope.query.clone()));
1555
1556 for (param_name, param_value) in &envelope.path_params {
1563 msg.set_header(
1564 format!("CamelHttpPath_{param_name}"),
1565 serde_json::Value::String(param_value.clone()),
1566 );
1567 }
1568
1569 for (k, v) in &envelope.headers {
1571 if let Ok(val_str) = v.to_str() {
1572 msg.set_header(
1573 title_case_header(k.as_str()),
1574 serde_json::Value::String(val_str.to_string()),
1575 );
1576 }
1577 }
1578
1579 msg.body = Body::Stream(envelope.body);
1582
1583 #[allow(unused_mut)]
1584 let mut exchange = Exchange::new(msg);
1585
1586 #[cfg(feature = "otel")]
1588 {
1589 let headers: HashMap<String, String> = envelope
1590 .headers
1591 .iter()
1592 .filter_map(|(k, v)| {
1593 Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1594 })
1595 .collect();
1596 camel_otel::extract_into_exchange(&mut exchange, &headers);
1597 }
1598
1599 let reply_tx = envelope.reply_tx;
1600 let sender = ctx.sender().clone();
1601 let path_clone = path.clone();
1602 let cancel = cancel_token.clone();
1603 let auth_headers = envelope.headers.clone();
1607 let auth_uri: http::Uri = {
1608 let full = if envelope.query.is_empty() {
1609 envelope.path.clone()
1610 } else {
1611 format!("{}?{}", envelope.path, envelope.query)
1612 };
1613 full.parse().unwrap_or_default()
1617 };
1618 let kernel = kernel.clone();
1619
1620 tokio::spawn(async move {
1640 if cancel.is_cancelled() {
1648 let _ = reply_tx.send(HttpReply {
1649 status: 503,
1650 headers: vec![],
1651 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
1652 });
1653 return;
1654 }
1655
1656 if let Some(kernel) = kernel.as_ref()
1665 && !matches!(
1666 kernel.plan.access_mode,
1667 camel_api::security_policy::AccessMode::Public
1668 )
1669 {
1670 let principal = match camel_auth::extract_token_multi(
1671 &auth_headers,
1672 &auth_uri,
1673 &kernel.plan.credential_sources,
1674 ) {
1675 Some(extracted) => {
1676 match camel_auth::kernel_authenticate(
1677 &kernel.plan,
1678 &kernel.providers,
1679 &extracted,
1680 )
1681 .await
1682 {
1683 Ok(principal) => principal,
1684 Err(e) => {
1685 tracing::warn!(
1687 path = %path_clone,
1688 error = %e,
1689 "HTTP request authentication failed"
1690 );
1691 let _ = reply_tx.send(pipeline_error_to_reply(
1692 e,
1693 &path_clone,
1694 ));
1695 return;
1696 }
1697 }
1698 }
1699 None => {
1700 tracing::warn!(
1702 path = %path_clone,
1703 "HTTP request rejected: no credential found in any source"
1704 );
1705 let _ = reply_tx.send(pipeline_error_to_reply(
1706 CamelError::Unauthenticated(
1707 "no credential found in any source".to_string(),
1708 ),
1709 &path_clone,
1710 ));
1711 return;
1712 }
1713 };
1714 camel_auth::install_carrier(&mut exchange, &principal);
1715 }
1716
1717 let (tx, rx) = tokio::sync::oneshot::channel();
1719 let envelope = camel_component_api::consumer::ExchangeEnvelope {
1720 exchange,
1721 reply_tx: Some(tx),
1722 };
1723
1724 let result = match sender.send(envelope).await {
1725 Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
1726 Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
1727 }
1728 .and_then(|r| r);
1729
1730 let reply = match result {
1731 Ok(out) => {
1732 let status = out
1733 .input
1734 .header("CamelHttpResponseCode")
1735 .and_then(|v| {
1736 let raw = v.as_u64()
1737 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
1738 let code = raw as u16;
1739 (100..1000).contains(&code).then_some(code)
1740 })
1741 .unwrap_or(200);
1742
1743 let user_content_type = out
1744 .input
1745 .header("Content-Type")
1746 .and_then(|v| v.as_str().map(|s| s.to_string()));
1747
1748 let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
1749 Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
1750 Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
1751 Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
1752 Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
1753 v.to_string().into_bytes(),
1754 )), Some("application/json".to_string())),
1755 Body::Stream(s) => {
1756 let ct = s.metadata.content_type.clone();
1757 match s.stream.lock().await.take() {
1758 Some(stream) => (
1759 HttpReplyBody::Stream(stream),
1760 ct,
1761 ),
1762 None => {
1763 tracing::error!(
1765 "Body::Stream already consumed before HTTP reply — returning 500"
1766 );
1767 let error_reply = HttpReply {
1768 status: 500,
1769 headers: vec![],
1770 body: HttpReplyBody::Bytes(bytes::Bytes::new()),
1771 };
1772 if reply_tx.send(error_reply).is_err() {
1773 debug!("reply_tx dropped before error reply could be sent");
1774 }
1775 return;
1776 }
1777 }
1778 }
1779 _ => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
1781 };
1782
1783 let resp_headers = select_response_headers(
1784 &out.input.headers,
1785 user_content_type,
1786 inferred_content_type,
1787 );
1788
1789 HttpReply {
1790 status,
1791 headers: resp_headers,
1792 body: reply_body,
1793 }
1794 }
1795 Err(e) => {
1796 pipeline_error_to_reply(e, &path_clone)
1797 }
1798 };
1799
1800 let _ = reply_tx.send(reply);
1802 });
1803 }
1804 }
1805 }
1806
1807 if let Some(method) = &self.config.method {
1812 registry_for_cleanup
1813 .unregister_rest_endpoint(method, &path)
1814 .await;
1815 } else {
1816 registry_for_cleanup.unregister_api_route(&path).await;
1817 }
1818
1819 ServerRegistry::global()
1823 .unregister(&self.config.host, self.config.port)
1824 .await;
1825
1826 Ok(())
1827 }
1828
1829 async fn stop(&mut self) -> Result<(), CamelError> {
1830 Ok(())
1831 }
1832
1833 fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
1834 camel_component_api::ConcurrencyModel::Concurrent { max: None }
1835 }
1836
1837 fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
1844 camel_component_api::ConsumerStartupMode::Explicit
1845 }
1846
1847 fn set_security_context(&mut self, ctx: camel_component_api::SecurityContext) {
1850 self.kernel = HttpKernelAuth::from_security_context(&ctx).map(Arc::new);
1851 }
1852}
1853
1854pub struct HttpComponent {
1859 config: HttpConfig,
1860 pinned_cache: std::sync::Arc<PinnedClientCache>,
1861 client: reqwest::Client,
1862}
1863
1864#[cfg(test)]
1865thread_local! {
1866 static BUILD_CLIENT_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
1867}
1868
1869pub(crate) fn build_client(
1870 config: &HttpConfig,
1871 resolve_override: Option<(&str, &[std::net::SocketAddr])>,
1872) -> reqwest::Client {
1873 #[cfg(test)]
1874 BUILD_CLIENT_CALLS.with(|c| c.set(c.get() + 1));
1875
1876 let mut builder = reqwest::Client::builder()
1877 .no_proxy() .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
1879 .pool_max_idle_per_host(config.pool_max_idle_per_host)
1880 .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
1881
1882 builder = builder.redirect(reqwest::redirect::Policy::none());
1886
1887 if let Some((host, addrs)) = resolve_override {
1888 builder = builder.resolve_to_addrs(host, addrs);
1889 }
1890
1891 if let Some(tls) = &config.tls
1892 && tls.enabled
1893 {
1894 if tls.insecure || !tls.verify_peer {
1895 tracing::warn!("HTTP TLS verification disabled — connections are vulnerable to MitM");
1897 builder = builder.danger_accept_invalid_certs(true);
1898 }
1899
1900 if let Some(ca_path) = &tls.ca_cert_path {
1901 match std::fs::read(ca_path) {
1906 Ok(ca_bytes) => {
1907 match reqwest::Certificate::from_pem(&ca_bytes)
1908 .or_else(|_| reqwest::Certificate::from_der(&ca_bytes))
1909 {
1910 Ok(ca_cert) => {
1911 builder = builder.add_root_certificate(ca_cert);
1912 }
1913 Err(e) => {
1914 tracing::warn!(
1916 error = %e,
1917 "configured CA certificate failed to parse — falling back to system roots"
1918 );
1919 }
1920 }
1921 }
1922 Err(e) => {
1923 tracing::warn!(
1925 error = %e,
1926 "configured CA certificate file unreadable — falling back to system roots"
1927 );
1928 }
1929 }
1930 }
1931
1932 if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path) {
1936 match (std::fs::read(cert_path), std::fs::read(key_path)) {
1937 (Ok(cert_bytes), Ok(key_bytes)) => {
1938 let mut identity_pem = cert_bytes;
1939 identity_pem.extend_from_slice(&key_bytes);
1940 match reqwest::Identity::from_pem(&identity_pem) {
1941 Ok(identity) => {
1942 builder = builder.identity(identity);
1943 }
1944 Err(e) => {
1945 tracing::warn!(
1947 error = %e,
1948 "configured mTLS identity failed to parse — client certificate NOT used"
1949 );
1950 }
1951 }
1952 }
1953 (cert_r, key_r) => {
1954 tracing::warn!(
1956 cert_ok = cert_r.is_ok(),
1957 key_ok = key_r.is_ok(),
1958 "configured mTLS cert/key file unreadable — client certificate NOT used"
1959 );
1960 }
1961 }
1962 }
1963 }
1964
1965 builder
1966 .build()
1967 .expect("reqwest::Client::build() with valid config should not fail") }
1969
1970#[cfg(test)]
1971pub(crate) fn build_client_call_count() -> u64 {
1972 BUILD_CLIENT_CALLS.with(|c| c.get())
1973}
1974
1975impl HttpComponent {
1976 pub fn new() -> Self {
1977 let config = HttpConfig::default();
1978 Self {
1979 client: build_client(&config, None),
1980 config,
1981 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
1982 PINNED_CLIENT_TTL,
1983 PINNED_CLIENT_MAX_ENTRIES,
1984 )),
1985 }
1986 }
1987
1988 pub fn with_config(config: HttpConfig) -> Self {
1989 Self {
1990 client: build_client(&config, None),
1991 config,
1992 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
1993 PINNED_CLIENT_TTL,
1994 PINNED_CLIENT_MAX_ENTRIES,
1995 )),
1996 }
1997 }
1998
1999 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2000 match config {
2001 Some(cfg) => Self::with_config(cfg),
2002 None => Self::new(),
2003 }
2004 }
2005}
2006
2007impl Default for HttpComponent {
2008 fn default() -> Self {
2009 Self::new()
2010 }
2011}
2012
2013impl Component for HttpComponent {
2014 fn scheme(&self) -> &str {
2015 "http"
2016 }
2017
2018 fn metadata(&self) -> ComponentMetadata {
2019 HttpEndpointConfig::metadata()
2020 }
2021
2022 fn create_endpoint(
2023 &self,
2024 uri: &str,
2025 ctx: &dyn camel_component_api::ComponentContext,
2026 ) -> Result<Box<dyn Endpoint>, CamelError> {
2027 self.config.validate()?;
2028 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2029 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2030 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2031 server_config.host.clone(),
2032 server_config.port,
2033 )));
2034 self.pinned_cache
2035 .wire(HttpComponentKind::Http, ctx.metrics());
2036 Ok(Box::new(HttpEndpoint {
2037 uri: uri.to_string(),
2038 config,
2039 server_config,
2040 client: self.client.clone(),
2041 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2042 http_config: self.config.clone(),
2043 }))
2044 }
2045}
2046
2047pub struct HttpsComponent {
2048 config: HttpConfig,
2049 pinned_cache: std::sync::Arc<PinnedClientCache>,
2050 client: reqwest::Client,
2051}
2052
2053impl HttpsComponent {
2054 pub fn new() -> Self {
2055 let config = HttpConfig::default();
2056 Self {
2057 client: build_client(&config, None),
2058 config,
2059 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2060 PINNED_CLIENT_TTL,
2061 PINNED_CLIENT_MAX_ENTRIES,
2062 )),
2063 }
2064 }
2065
2066 pub fn with_config(config: HttpConfig) -> Self {
2067 Self {
2068 client: build_client(&config, None),
2069 config,
2070 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2071 PINNED_CLIENT_TTL,
2072 PINNED_CLIENT_MAX_ENTRIES,
2073 )),
2074 }
2075 }
2076
2077 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2078 match config {
2079 Some(cfg) => Self::with_config(cfg),
2080 None => Self::new(),
2081 }
2082 }
2083}
2084
2085impl Default for HttpsComponent {
2086 fn default() -> Self {
2087 Self::new()
2088 }
2089}
2090
2091impl Component for HttpsComponent {
2092 fn scheme(&self) -> &str {
2093 "https"
2094 }
2095
2096 fn metadata(&self) -> ComponentMetadata {
2097 let mut meta = HttpEndpointConfig::metadata();
2100 meta.scheme = "https".to_string();
2101 meta.description = "HTTPS client and server component (TLS over HTTP)".to_string();
2102 meta
2103 }
2104
2105 fn create_endpoint(
2106 &self,
2107 uri: &str,
2108 ctx: &dyn camel_component_api::ComponentContext,
2109 ) -> Result<Box<dyn Endpoint>, CamelError> {
2110 self.config.validate()?;
2111 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2112 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2113 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2114 server_config.host.clone(),
2115 server_config.port,
2116 )));
2117 self.pinned_cache
2118 .wire(HttpComponentKind::Https, ctx.metrics());
2119 Ok(Box::new(HttpEndpoint {
2120 uri: uri.to_string(),
2121 config,
2122 server_config,
2123 client: self.client.clone(),
2124 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2125 http_config: self.config.clone(),
2126 }))
2127 }
2128}
2129
2130struct HttpEndpoint {
2135 uri: String,
2136 config: HttpEndpointConfig,
2137 server_config: HttpServerConfig,
2138 client: reqwest::Client,
2139 pinned_cache: std::sync::Arc<PinnedClientCache>,
2140 http_config: HttpConfig,
2141}
2142
2143impl Endpoint for HttpEndpoint {
2144 fn uri(&self) -> &str {
2145 &self.uri
2146 }
2147
2148 fn create_consumer(
2149 &self,
2150 rt: Arc<dyn camel_component_api::RuntimeObservability>,
2151 ) -> Result<Box<dyn Consumer>, CamelError> {
2152 let scheme_is_https = self.server_config.scheme == "https";
2155 let has_tls = self.server_config.tls_config.is_some();
2156
2157 if scheme_is_https && !has_tls {
2158 return Err(CamelError::EndpointCreationFailed(
2159 "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
2160 ));
2161 }
2162 if !scheme_is_https && has_tls {
2163 return Err(CamelError::EndpointCreationFailed(
2164 "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
2165 ));
2166 }
2167 Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
2168 }
2169
2170 fn create_producer(
2171 &self,
2172 rt: Arc<dyn camel_component_api::RuntimeObservability>,
2173 _ctx: &ProducerContext,
2174 ) -> Result<BoxProcessor, CamelError> {
2175 let producer = HttpProducer {
2176 config: Arc::new(self.config.clone()),
2177 client: self.client.clone(),
2178 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2179 http_config: Arc::new(self.http_config.clone()),
2180 runtime: rt,
2181 };
2182 if let Some(ref provider) = self.config.token_provider {
2183 let layer = BearerTokenLayer::new(Arc::clone(provider));
2184 Ok(BoxProcessor::new(layer.layer(producer)))
2185 } else {
2186 Ok(BoxProcessor::new(producer))
2187 }
2188 }
2189}
2190
2191#[derive(Clone)]
2196struct HttpProducer {
2197 config: Arc<HttpEndpointConfig>,
2198 client: reqwest::Client,
2199 pinned_cache: std::sync::Arc<PinnedClientCache>,
2200 http_config: Arc<HttpConfig>,
2201 runtime: Arc<dyn RuntimeObservability>,
2207}
2208
2209impl HttpProducer {
2210 fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
2211 if let Some(ref method) = config.http_method {
2212 return method.to_uppercase();
2213 }
2214 if let Some(method) = exchange
2215 .input
2216 .header("CamelHttpMethod")
2217 .and_then(|v| v.as_str())
2218 {
2219 return method.to_uppercase();
2220 }
2221 if !exchange.input.body.is_empty() {
2222 return "POST".to_string();
2223 }
2224 "GET".to_string()
2225 }
2226
2227 fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
2228 if config.bridge_endpoint {
2234 let url = config.base_url.clone();
2235 if config.query_params.is_empty() {
2236 return url;
2237 }
2238 let mut parsed = url::Url::parse(&url).expect("base URL must be valid"); for (k, v) in &config.query_params {
2240 parsed.query_pairs_mut().append_pair(k, v);
2241 }
2242 return parsed.to_string();
2243 }
2244
2245 if let Some(uri) = exchange
2246 .input
2247 .header("CamelHttpUri")
2248 .and_then(|v| v.as_str())
2249 {
2250 let mut url = uri.to_string();
2251 if let Some(path) = exchange
2252 .input
2253 .header("CamelHttpPath")
2254 .and_then(|v| v.as_str())
2255 {
2256 if !url.ends_with('/') && !path.starts_with('/') {
2257 url.push('/');
2258 }
2259 url.push_str(path);
2260 }
2261 if let Some(query) = exchange
2262 .input
2263 .header("CamelHttpQuery")
2264 .and_then(|v| v.as_str())
2265 {
2266 url.push('?');
2267 url.push_str(query);
2268 }
2269 return url;
2270 }
2271
2272 let mut url = config.base_url.clone();
2273
2274 if let Some(path) = exchange
2275 .input
2276 .header("CamelHttpPath")
2277 .and_then(|v| v.as_str())
2278 {
2279 if !url.ends_with('/') && !path.starts_with('/') {
2280 url.push('/');
2281 }
2282 url.push_str(path);
2283 }
2284
2285 if let Some(query) = exchange
2286 .input
2287 .header("CamelHttpQuery")
2288 .and_then(|v| v.as_str())
2289 {
2290 url.push('?');
2291 url.push_str(query);
2292 } else if !config.query_params.is_empty() {
2293 let mut parsed = url::Url::parse(&url).expect("base URL must be valid"); for (k, v) in &config.query_params {
2295 parsed.query_pairs_mut().append_pair(k, v);
2296 }
2297 url = parsed.to_string();
2298 }
2299
2300 url
2301 }
2302
2303 fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
2304 status >= range.0 && status <= range.1
2305 }
2306}
2307
2308fn redact_url_for_diagnostics(raw: &str) -> String {
2314 const MAX_URL_LOG_LEN: usize = 256;
2315 match url::Url::parse(raw) {
2316 Ok(mut u) => {
2317 if !u.username().is_empty() {
2318 let _ = u.set_username("***");
2319 let _ = u.set_password(None);
2320 }
2321 if u.query().is_some() {
2322 u.set_query(None);
2323 let mut s = u.to_string();
2325 if let Some(stripped) = s.strip_suffix('?') {
2326 s = stripped.to_string();
2327 }
2328 s.push_str("?[redacted]");
2329 if s.len() > MAX_URL_LOG_LEN {
2330 s.truncate(MAX_URL_LOG_LEN);
2331 }
2332 return s;
2333 }
2334 let mut s = u.to_string();
2335 if s.len() > MAX_URL_LOG_LEN {
2336 s.truncate(MAX_URL_LOG_LEN);
2337 }
2338 s
2339 }
2340 Err(_) => {
2341 let mut s = raw.to_string();
2342 s.truncate(MAX_URL_LOG_LEN);
2343 s
2344 }
2345 }
2346}
2347
2348const MAX_ERROR_RESPONSE_BODY_BYTES: usize = 4096;
2353
2354fn truncate_error_body(body: &[u8]) -> String {
2355 if body.len() <= MAX_ERROR_RESPONSE_BODY_BYTES {
2356 String::from_utf8_lossy(body).into_owned()
2357 } else {
2358 let mut s = String::from_utf8_lossy(&body[..MAX_ERROR_RESPONSE_BODY_BYTES]).into_owned();
2359 s.push_str("...[truncated]");
2360 s
2361 }
2362}
2363
2364impl HttpProducer {
2365 fn is_entity_enclosing(method: &str) -> bool {
2370 matches!(method, "POST" | "PUT" | "PATCH")
2371 }
2372}
2373
2374impl Service<Exchange> for HttpProducer {
2375 type Response = Exchange;
2376 type Error = CamelError;
2377 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
2378
2379 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
2380 Poll::Ready(Ok(()))
2381 }
2382
2383 fn call(&mut self, exchange: Exchange) -> Self::Future {
2384 let config = self.config.clone();
2385 let shared_client = self.client.clone();
2386 let pinned_cache = std::sync::Arc::clone(&self.pinned_cache);
2387 let http_config = self.http_config.clone();
2388 let component_metrics = self.runtime.component_metrics();
2389
2390 Box::pin(async move {
2391 let mut exchange = exchange;
2392 let outcome = async {
2393 let method_str = HttpProducer::resolve_method(&exchange, &config);
2394 let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
2399 let url = HttpProducer::resolve_url(&exchange, &config);
2400
2401 ssrf::validate_url_for_ssrf(&url, &config)?;
2403
2404 let resolved =
2412 ssrf::resolve_initial_url_for_ssrf(&url, config.allow_internal).await?;
2413 let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
2414 pinned_cache
2415 .get_or_build(host.as_str(), addrs, || {
2416 build_client(&http_config, Some((host.as_str(), addrs)))
2417 })
2418 .await
2419 } else {
2420 shared_client.clone()
2421 };
2422
2423 debug!(
2424 correlation_id = %exchange.correlation_id(),
2425 method = %method_str,
2426 url = %redact_url_for_diagnostics(&url),
2427 "HTTP request"
2428 );
2429
2430 let method = method_str.parse::<reqwest::Method>().map_err(|e| {
2431 CamelError::ProcessorError(format!(
2432 "Invalid HTTP method '{}': {}",
2433 method_str, e
2434 ))
2435 })?;
2436
2437 let mut collected_headers: Vec<(
2439 reqwest::header::HeaderName,
2440 reqwest::header::HeaderValue,
2441 )> = Vec::new();
2442
2443 if let Some(user_agent) = &config.user_agent
2444 && !config.bridge_endpoint
2445 && let Ok(val) = reqwest::header::HeaderValue::from_str(user_agent)
2446 {
2447 collected_headers.push((reqwest::header::USER_AGENT, val));
2448 }
2449
2450 #[cfg(feature = "otel")]
2452 let should_inject_otel = !config.bridge_endpoint;
2453 #[cfg(feature = "otel")]
2454 if should_inject_otel {
2455 let mut otel_headers = HashMap::new();
2456 camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
2457 for (k, v) in otel_headers {
2458 if let (Ok(name), Ok(val)) = (
2459 reqwest::header::HeaderName::from_bytes(k.as_bytes()),
2460 reqwest::header::HeaderValue::from_str(&v),
2461 ) {
2462 collected_headers.push((name, val));
2463 }
2464 }
2465 }
2466
2467 let conn_tokens = header_policy::connection_tokens(
2468 exchange
2469 .input
2470 .headers
2471 .iter()
2472 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
2473 .filter_map(|(_, v)| v.as_str()),
2474 );
2475
2476 for (key, value) in &exchange.input.headers {
2477 if !key.starts_with("Camel")
2478 && !config
2479 .skip_request_headers
2480 .iter()
2481 .any(|h| h.eq_ignore_ascii_case(key))
2482 && !header_policy::excluded_outbound(key, &conn_tokens)
2483 && let Some(val_str) = value.as_str()
2484 && let (Ok(name), Ok(val)) = (
2485 reqwest::header::HeaderName::from_bytes(key.as_bytes()),
2486 reqwest::header::HeaderValue::from_str(val_str),
2487 )
2488 {
2489 collected_headers.push((name, val));
2490 }
2491 }
2492
2493 if !config.bridge_endpoint {
2495 match &config.auth {
2496 HttpAuth::None => {}
2497 HttpAuth::Basic { username, password } => {
2498 use base64::Engine;
2499 let credentials = format!("{username}:{password}");
2501 let encoded =
2502 base64::engine::general_purpose::STANDARD.encode(credentials);
2503 if let Ok(val) =
2504 reqwest::header::HeaderValue::from_str(&format!("Basic {encoded}"))
2505 {
2506 collected_headers.push((reqwest::header::AUTHORIZATION, val));
2507 }
2508 }
2509 HttpAuth::Bearer { token } => {
2510 let bearer = format!("Bearer {token}");
2512 if let Ok(val) = reqwest::header::HeaderValue::from_str(&bearer) {
2513 collected_headers.push((reqwest::header::AUTHORIZATION, val));
2514 }
2515 }
2516 }
2517
2518 if config.connection_close
2519 && let Ok(val) = reqwest::header::HeaderValue::from_str("close")
2520 {
2521 collected_headers.push((reqwest::header::CONNECTION, val));
2522 }
2523 }
2524
2525 let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
2527 let materialized_body: Option<Vec<u8>> = if is_stream_body {
2528 if suppress_body {
2529 std::mem::take(&mut exchange.input.body);
2537 tracing::warn!(
2539 correlation_id = %exchange.correlation_id(),
2540 method = %method_str,
2541 "dropping request body for non-entity-enclosing HTTP method"
2542 );
2543 }
2544 None } else {
2546 let body = std::mem::take(&mut exchange.input.body);
2547 let bytes = body.into_bytes(config.max_body_size).await?;
2548 if bytes.is_empty() {
2549 None
2551 } else if suppress_body {
2552 tracing::warn!(
2554 correlation_id = %exchange.correlation_id(),
2555 method = %method_str,
2556 "dropping request body for non-entity-enclosing HTTP method"
2557 );
2558 None
2559 } else {
2560 Some(bytes.to_vec())
2561 }
2562 };
2563
2564 let response = if config.follow_redirects && !is_stream_body {
2565 ssrf::send_with_ssrf_safe_redirects(
2571 &client,
2572 &shared_client,
2573 &pinned_cache,
2574 &http_config,
2575 &config,
2576 method,
2577 &url,
2578 collected_headers,
2579 materialized_body,
2580 config.max_redirects,
2581 config.response_timeout,
2582 )
2583 .await?
2584 } else {
2585 let mut request = client.request(method, &url);
2587
2588 if let Some(timeout) = config.response_timeout {
2589 request = request.timeout(timeout);
2590 }
2591
2592 for (name, value) in &collected_headers {
2593 request = request.header(name, value);
2594 }
2595
2596 if is_stream_body {
2597 if let Body::Stream(ref s) = exchange.input.body {
2598 let mut stream_lock = s.stream.lock().await;
2599 if let Some(stream) = stream_lock.take() {
2600 request = request.body(reqwest::Body::wrap_stream(stream));
2601 } else {
2602 return Err(CamelError::AlreadyConsumed);
2603 }
2604 }
2605 } else if let Some(ref body_bytes) = materialized_body {
2606 request = request.body(body_bytes.clone());
2607 }
2608
2609 request.send().await.map_err(|e| {
2610 CamelError::ProcessorError(format!("HTTP request failed: {e}"))
2611 })?
2612 };
2613
2614 let status_code = response.status().as_u16();
2615 let status_text = response
2616 .status()
2617 .canonical_reason()
2618 .unwrap_or("Unknown")
2619 .to_string();
2620
2621 for (key, value) in response.headers() {
2622 if config
2623 .skip_response_headers
2624 .iter()
2625 .any(|h| h.eq_ignore_ascii_case(key.as_str()))
2626 {
2627 continue;
2628 }
2629 if let Ok(val_str) = value.to_str() {
2630 exchange.input.set_header(
2631 title_case_header(key.as_str()),
2632 serde_json::Value::String(val_str.to_string()),
2633 );
2634 }
2635 }
2636
2637 exchange.input.set_header(
2638 "CamelHttpResponseCode",
2639 serde_json::Value::Number(status_code.into()),
2640 );
2641 exchange.input.set_header(
2642 "CamelHttpResponseText",
2643 serde_json::Value::String(status_text.clone()),
2644 );
2645
2646 let read_timeout = Duration::from_millis(config.read_timeout_ms);
2648 let response_body = tokio::time::timeout(read_timeout, async {
2649 if let Some(content_len) = response.content_length()
2651 && content_len > config.max_response_bytes as u64
2652 {
2653 return Err(CamelError::ProcessorError(format!(
2654 "Response body too large: {} bytes exceeds limit of {} bytes",
2655 content_len, config.max_response_bytes
2656 )));
2657 }
2658 use futures::TryStreamExt;
2660 let mut stream = response.bytes_stream();
2661 let mut total: usize = 0;
2662 let mut collected = Vec::new();
2663 while let Some(chunk) = stream.try_next().await.map_err(|e| {
2664 CamelError::ProcessorError(format!("Failed to read response body: {e}"))
2665 })? {
2666 total += chunk.len();
2667 if total > config.max_response_bytes {
2668 return Err(CamelError::ProcessorError(format!(
2669 "Response body too large: {} bytes exceeds limit of {} bytes",
2670 total, config.max_response_bytes
2671 )));
2672 }
2673 collected.push(chunk);
2674 }
2675 let mut result = bytes::BytesMut::with_capacity(total);
2676 for chunk in collected {
2677 result.extend_from_slice(&chunk);
2678 }
2679 Ok::<bytes::Bytes, CamelError>(result.freeze())
2680 })
2681 .await
2682 .map_err(|_| {
2683 CamelError::ProcessorError(format!(
2684 "Read timeout after {}ms",
2685 config.read_timeout_ms
2686 ))
2687 })??;
2688
2689 if config.throw_exception_on_failure
2690 && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
2691 {
2692 return Err(CamelError::HttpOperationFailed {
2693 method: method_str,
2694 url: redact_url_for_diagnostics(&url),
2697 status_code,
2698 status_text,
2699 response_body: Some(truncate_error_body(&response_body)),
2700 });
2701 }
2702
2703 if !response_body.is_empty() {
2704 exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
2705 }
2706
2707 debug!(
2708 correlation_id = %exchange.correlation_id(),
2709 status = status_code,
2710 url = %redact_url_for_diagnostics(&url),
2711 "HTTP response"
2712 );
2713 Ok(exchange)
2714 }
2715 .await;
2716 component_metrics.observe("http", "request", outcome.is_err());
2723 outcome
2724 })
2725 }
2726}
2727
2728#[cfg(test)]
2738pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
2739
2740fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
2748 match e {
2749 CamelError::Unauthenticated(msg) => {
2750 tracing::warn!(error = %msg, path = %path, "Authentication failed");
2751 HttpReply {
2752 status: 401,
2753 headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
2754 body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
2755 }
2756 }
2757 CamelError::Unauthorized(msg) => {
2758 tracing::warn!(error = %msg, path = %path, "Authorization failed");
2759 HttpReply {
2760 status: 403,
2761 headers: vec![],
2762 body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
2763 }
2764 }
2765 CamelError::TypeConversionFailed(msg) => {
2766 tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
2767 let body = serde_json::to_string(&serde_json::json!({
2768 "error": "bad_request",
2769 "message": msg,
2770 }))
2771 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
2773 status: 400,
2774 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
2775 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
2776 }
2777 }
2778 CamelError::ValidationError(msg) => {
2779 tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
2780 let body = serde_json::to_string(&serde_json::json!({
2781 "error": "validation_error",
2782 "message": msg,
2783 }))
2784 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
2786 status: 400,
2787 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
2788 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
2789 }
2790 }
2791 CamelError::ConsumerStopping => {
2792 tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
2793 HttpReply {
2794 status: 503,
2795 headers: vec![],
2796 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
2797 }
2798 }
2799 e => {
2800 tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
2802 HttpReply {
2803 status: 500,
2804 headers: vec![],
2805 body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
2806 }
2807 }
2808 }
2809}
2810
2811fn select_response_headers(
2821 headers: &HashMap<String, serde_json::Value>,
2822 user_content_type: Option<String>,
2823 inferred_content_type: Option<String>,
2824) -> Vec<(String, String)> {
2825 let conn_tokens = header_policy::connection_tokens(
2826 headers
2827 .iter()
2828 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
2829 .filter_map(|(_, v)| v.as_str()),
2830 );
2831 let mut selected: Vec<(String, String)> = headers
2832 .iter()
2833 .filter(|(k, _)| !k.starts_with("Camel"))
2834 .filter(|(k, _)| !header_policy::excluded_response(k, &conn_tokens))
2835 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
2836 .collect();
2837 if let Some(ct) = user_content_type.or(inferred_content_type) {
2838 selected.push(("Content-Type".to_string(), ct));
2839 }
2840 selected
2841}
2842
2843#[cfg(test)]
2844mod tests {
2845 use camel_component_api::test_support::NoopRuntimeObservability;
2846
2847 fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2851 std::sync::Arc::new(NoopRuntimeObservability)
2852 }
2853 fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2854 std::sync::Arc::new(NoopRuntimeObservability)
2855 }
2856 fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2857 std::sync::Arc::new(NoopRuntimeObservability)
2858 }
2859
2860 use super::*;
2861 use crate::rest_match::PathSegment;
2862 use camel_component_api::{Message, NoOpComponentContext};
2863 use std::sync::Arc;
2864 use std::time::Duration;
2865
2866 fn test_producer_ctx() -> ProducerContext {
2867 ProducerContext::new()
2868 }
2869
2870 #[test]
2875 fn redact_url_masks_userinfo_and_query() {
2876 let redacted =
2877 redact_url_for_diagnostics("http://user:secretpass@internal.example/api?token=abc123");
2878 assert!(
2879 !redacted.contains("secretpass"),
2880 "password must be masked: {redacted}"
2881 );
2882 assert!(
2883 !redacted.contains("token=abc123"),
2884 "query must be masked: {redacted}"
2885 );
2886 assert!(
2887 !redacted.contains("user@"),
2888 "username must be masked: {redacted}"
2889 );
2890 assert!(
2891 redacted.contains("internal.example"),
2892 "host stays visible: {redacted}"
2893 );
2894 assert!(redacted.contains("[redacted]"), "query marked: {redacted}");
2895 }
2896
2897 #[test]
2898 fn redact_url_keeps_clean_urls_visible() {
2899 let redacted = redact_url_for_diagnostics("https://api.example.com/v1/items");
2900 assert_eq!(redacted, "https://api.example.com/v1/items");
2901 }
2902
2903 #[test]
2904 fn redact_url_truncates_unparseable() {
2905 let long = "x".repeat(1000);
2906 let redacted = redact_url_for_diagnostics(&long);
2907 assert_eq!(redacted.len(), 256, "unparseable URL must be truncated");
2908 }
2909
2910 #[test]
2911 fn truncate_error_body_caps_attacker_body() {
2912 let big = vec![b'A'; 10 * 1024 * 1024];
2913 let truncated = truncate_error_body(&big);
2914 assert!(
2915 truncated.len() <= MAX_ERROR_RESPONSE_BODY_BYTES + 20,
2916 "body must be capped near {} bytes, got {}",
2917 MAX_ERROR_RESPONSE_BODY_BYTES,
2918 truncated.len()
2919 );
2920 assert!(truncated.ends_with("...[truncated]"));
2921 }
2922
2923 #[test]
2924 fn truncate_error_body_keeps_small_body() {
2925 assert_eq!(truncate_error_body(b"boom"), "boom");
2926 }
2927
2928 #[test]
2929 fn test_http_config_defaults() {
2930 let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
2931 assert_eq!(config.base_url, "http://localhost:8080/api");
2932 assert!(config.http_method.is_none());
2933 assert!(config.throw_exception_on_failure);
2934 assert_eq!(config.ok_status_code_range, (200, 299));
2935 assert!(config.response_timeout.is_none());
2936 assert!(matches!(config.auth, HttpAuth::None));
2937 assert!(!config.bridge_endpoint);
2938 assert!(!config.connection_close);
2939 }
2940
2941 #[test]
2942 fn test_http_config_scheme() {
2943 assert_eq!(HttpEndpointConfig::scheme(), "http");
2945 }
2946
2947 #[test]
2948 fn test_http_config_from_components() {
2949 let components = camel_component_api::UriComponents {
2951 scheme: "https".to_string(),
2952 path: "//api.example.com/v1".to_string(),
2953 params: std::collections::HashMap::from([(
2954 "httpMethod".to_string(),
2955 "POST".to_string(),
2956 )]),
2957 };
2958 let config = HttpEndpointConfig::from_components(components).unwrap();
2959 assert_eq!(config.base_url, "https://api.example.com/v1");
2960 assert_eq!(config.http_method, Some("POST".to_string()));
2961 }
2962
2963 #[test]
2964 fn test_http_config_with_options() {
2965 let config = HttpEndpointConfig::from_uri(
2966 "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
2967 ).unwrap();
2968 assert_eq!(config.base_url, "https://api.example.com/v1");
2969 assert_eq!(config.http_method, Some("PUT".to_string()));
2970 assert!(!config.throw_exception_on_failure);
2971 assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
2972 }
2973
2974 #[test]
2975 fn test_http_endpoint_config_auth_and_headers_options() {
2976 let config = HttpEndpointConfig::from_uri(
2977 "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
2978 )
2979 .unwrap();
2980
2981 assert!(matches!(
2982 config.auth,
2983 HttpAuth::Basic { username, password } if username == "u" && password == "p"
2984 ));
2985 assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
2986 assert!(config.bridge_endpoint);
2987 assert!(config.connection_close);
2988 assert_eq!(
2989 config.skip_request_headers,
2990 vec!["authorization".to_string(), "x-secret".to_string()]
2991 );
2992 assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
2993 }
2994
2995 #[test]
2996 fn test_http_endpoint_config_bearer_auth() {
2997 let config = HttpEndpointConfig::from_uri(
2998 "http://localhost/api?authMethod=Bearer&authBearerToken=t",
2999 )
3000 .unwrap();
3001 assert!(matches!(
3002 config.auth,
3003 HttpAuth::Bearer { token } if token == "t"
3004 ));
3005 }
3006
3007 #[test]
3008 fn rejects_cookie_handling_inmemory() {
3009 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
3010 match result {
3011 Err(CamelError::InvalidUri(msg)) => {
3012 assert!(
3013 msg.contains("cookieHandling is not supported"),
3014 "expected rejection message, got: {msg}"
3015 );
3016 }
3017 other => panic!("expected InvalidUri error, got: {other:?}"),
3018 }
3019 }
3020
3021 #[test]
3022 fn rejects_cookie_handling_disabled() {
3023 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
3024 match result {
3025 Err(CamelError::InvalidUri(msg)) => {
3026 assert!(
3027 msg.contains("cookieHandling is not supported"),
3028 "expected rejection message, got: {msg}"
3029 );
3030 }
3031 other => panic!("expected InvalidUri error, got: {other:?}"),
3032 }
3033 }
3034
3035 #[test]
3036 fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
3037 let config = HttpConfig::default()
3038 .with_response_timeout_ms(999)
3039 .with_allow_internal(true)
3040 .with_blocked_hosts(vec!["evil.com".to_string()])
3041 .with_max_body_size(12345);
3042 let endpoint =
3043 HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
3044 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
3045 assert!(endpoint.allow_internal);
3046 assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
3047 assert_eq!(endpoint.max_body_size, 12345);
3048 }
3049
3050 #[test]
3051 fn test_from_uri_with_defaults_uri_overrides_config() {
3052 let config = HttpConfig::default()
3053 .with_response_timeout_ms(999)
3054 .with_allow_internal(true)
3055 .with_blocked_hosts(vec!["evil.com".to_string()])
3056 .with_max_body_size(12345);
3057 let endpoint = HttpEndpointConfig::from_uri_with_defaults(
3058 "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
3059 &config,
3060 )
3061 .unwrap();
3062 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
3063 assert!(!endpoint.allow_internal);
3064 assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
3065 assert_eq!(endpoint.max_body_size, 99);
3066 }
3067
3068 #[test]
3069 fn test_http_config_ok_status_range() {
3070 let config =
3071 HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
3072 assert_eq!(config.ok_status_code_range, (200, 204));
3073 }
3074
3075 #[test]
3076 fn test_http_config_wrong_scheme() {
3077 let result = HttpEndpointConfig::from_uri("file:/tmp");
3078 assert!(result.is_err());
3079 }
3080
3081 #[test]
3082 fn test_http_component_scheme() {
3083 let component = HttpComponent::new();
3084 assert_eq!(component.scheme(), "http");
3085 }
3086
3087 #[test]
3088 fn test_https_component_scheme() {
3089 let component = HttpsComponent::new();
3090 assert_eq!(component.scheme(), "https");
3091 }
3092
3093 #[test]
3094 fn test_http_endpoint_creates_consumer() {
3095 let component = HttpComponent::new();
3096 let ctx = NoOpComponentContext;
3097 let endpoint = component
3098 .create_endpoint("http://0.0.0.0:19100/test", &ctx)
3099 .unwrap();
3100 assert!(endpoint.create_consumer(rt()).is_ok());
3101 }
3102
3103 #[test]
3104 fn test_https_endpoint_creates_consumer_errors_without_tls() {
3105 let component = HttpsComponent::new();
3106 let ctx = NoOpComponentContext;
3107 let endpoint = component
3108 .create_endpoint("https://0.0.0.0:8443/test", &ctx)
3109 .unwrap();
3110 assert!(endpoint.create_consumer(rt()).is_err());
3112 }
3113
3114 #[test]
3115 fn test_http_endpoint_creates_producer() {
3116 let ctx = test_producer_ctx();
3117 let component = HttpComponent::new();
3118 let endpoint_ctx = NoOpComponentContext;
3119 let endpoint = component
3120 .create_endpoint("http://localhost/api", &endpoint_ctx)
3121 .unwrap();
3122 assert!(endpoint.create_producer(rt(), &ctx).is_ok());
3123 }
3124
3125 #[tokio::test]
3130 async fn test_producer_with_token_provider() {
3131 use camel_auth::oauth2::TokenProvider;
3132 use tower::ServiceExt;
3133
3134 let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
3135 Arc::new(std::sync::Mutex::new(None));
3136 let captured_clone = Arc::clone(&captured_auth);
3137
3138 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3139 let port = listener.local_addr().unwrap().port();
3140
3141 let _handle = tokio::spawn(async move {
3142 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3143 if let Ok((mut stream, _)) = listener.accept().await {
3144 let mut buf = vec![0u8; 8192];
3145 let n = stream.read(&mut buf).await.unwrap_or(0);
3146 let request = String::from_utf8_lossy(&buf[..n]).to_string();
3147 let auth = request
3148 .lines()
3149 .find(|l| l.to_lowercase().starts_with("authorization:"))
3150 .map(|l| {
3151 l.split(':')
3152 .nth(1)
3153 .map(|s| s.trim().to_string())
3154 .unwrap_or_default()
3155 });
3156 *captured_clone.lock().unwrap() = auth;
3157 let body = r#"{"echo":"ok"}"#;
3158 let resp = format!(
3159 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
3160 body.len(),
3161 body
3162 );
3163 let _ = stream.write_all(resp.as_bytes()).await;
3164 }
3165 });
3166
3167 #[derive(Debug)]
3168 struct StaticProvider;
3169 #[async_trait::async_trait]
3170 impl TokenProvider for StaticProvider {
3171 async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
3172 Ok("injected-token".into())
3173 }
3174 }
3175
3176 let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
3177 let ctx = test_producer_ctx();
3178 let component = HttpComponent::new();
3179 let endpoint_ctx = NoOpComponentContext;
3180 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
3181 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3182
3183 let exchange = Exchange::new(Message::new("hello"));
3184
3185 let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
3186 let mut layered = layer.layer(producer);
3187 let result = layered.ready().await.unwrap().call(exchange).await;
3188 assert!(result.is_ok(), "producer call failed: {:?}", result);
3189
3190 tokio::time::sleep(Duration::from_millis(100)).await;
3191 let auth = captured_auth.lock().unwrap().take();
3192 assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
3193 }
3194
3195 async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
3196 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3197 let addr = listener.local_addr().unwrap();
3198 let url = format!("http://127.0.0.1:{}", addr.port());
3199
3200 let handle = tokio::spawn(async move {
3201 loop {
3202 if let Ok((mut stream, _)) = listener.accept().await {
3203 tokio::spawn(async move {
3204 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3205 let mut buf = vec![0u8; 4096];
3206 let n = stream.read(&mut buf).await.unwrap_or(0);
3207 let request = String::from_utf8_lossy(&buf[..n]).to_string();
3208
3209 let method = request.split_whitespace().next().unwrap_or("GET");
3210
3211 let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
3212 let response = format!(
3213 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
3214 body.len(),
3215 body
3216 );
3217 let _ = stream.write_all(response.as_bytes()).await;
3218 });
3219 }
3220 }
3221 });
3222
3223 (url, handle)
3224 }
3225
3226 async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
3227 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3228 let addr = listener.local_addr().unwrap();
3229 let url = format!("http://127.0.0.1:{}", addr.port());
3230
3231 let handle = tokio::spawn(async move {
3232 loop {
3233 if let Ok((mut stream, _)) = listener.accept().await {
3234 let status = status;
3235 tokio::spawn(async move {
3236 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3237 let mut buf = vec![0u8; 4096];
3238 let _ = stream.read(&mut buf).await;
3239
3240 let status_text = match status {
3241 404 => "Not Found",
3242 500 => "Internal Server Error",
3243 _ => "Error",
3244 };
3245 let body = "error body";
3246 let response = format!(
3247 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
3248 status,
3249 status_text,
3250 body.len(),
3251 body
3252 );
3253 let _ = stream.write_all(response.as_bytes()).await;
3254 });
3255 }
3256 }
3257 });
3258
3259 (url, handle)
3260 }
3261
3262 async fn start_request_capturing_server() -> (
3263 String,
3264 Arc<std::sync::Mutex<Option<String>>>,
3265 tokio::task::JoinHandle<()>,
3266 ) {
3267 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3268 let port = listener.local_addr().unwrap().port();
3269 let url = format!("http://127.0.0.1:{port}");
3270 let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
3271 let captured_clone = Arc::clone(&captured);
3272 let handle = tokio::spawn(async move {
3273 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3274 if let Ok((mut stream, _)) = listener.accept().await {
3275 let mut buf = vec![0u8; 16384];
3276 let n = stream.read(&mut buf).await.unwrap_or(0);
3277 let request = String::from_utf8_lossy(&buf[..n]).to_string();
3278 if request.contains("\r\n\r\n") {
3279 *captured_clone.lock().unwrap() = Some(request);
3280 }
3281 let body = r#"{"echo":"ok"}"#;
3282 let resp = format!(
3283 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
3284 body.len(),
3285 body
3286 );
3287 let _ = stream.write_all(resp.as_bytes()).await;
3288 }
3289 });
3290 (url, captured, handle)
3291 }
3292
3293 #[tokio::test]
3294 async fn test_http_producer_get_request() {
3295 use tower::ServiceExt;
3296
3297 let (url, _handle) = start_test_server().await;
3298 let ctx = test_producer_ctx();
3299
3300 let component = HttpComponent::new();
3301 let endpoint_ctx = NoOpComponentContext;
3302 let endpoint = component
3303 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
3304 .unwrap();
3305 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3306
3307 let exchange = Exchange::new(Message::default());
3308 let result = producer.oneshot(exchange).await.unwrap();
3309
3310 let status = result
3311 .input
3312 .header("CamelHttpResponseCode")
3313 .and_then(|v| v.as_u64())
3314 .unwrap();
3315 assert_eq!(status, 200);
3316
3317 assert!(!result.input.body.is_empty());
3318 }
3319
3320 #[tokio::test]
3321 async fn producer_excludes_host_and_framing() {
3322 use tower::ServiceExt;
3323
3324 let (url, captured, _handle) = start_request_capturing_server().await;
3325 let ctx = test_producer_ctx();
3326 let component = HttpComponent::new();
3327 let endpoint_ctx = NoOpComponentContext;
3328 let endpoint = component
3329 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
3330 .unwrap();
3331 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3332
3333 let mut exchange = Exchange::new(Message::default());
3334 exchange.input.set_header("Host", "localhost");
3335 exchange.input.set_header("Content-Length", "42");
3336 exchange.input.set_header("Connection", "keep-alive");
3337 exchange.input.set_header("Upgrade", "h2c");
3338
3339 let result = producer.oneshot(exchange).await;
3340 assert!(result.is_ok(), "producer call failed: {:?}", result);
3341
3342 tokio::time::sleep(Duration::from_millis(100)).await;
3343 let request = captured
3344 .lock()
3345 .unwrap()
3346 .take()
3347 .expect("no outbound request captured");
3348 let lower = request.to_ascii_lowercase();
3349 assert!(
3350 !lower.contains("\r\nhost: localhost"),
3351 "forwarded Host: localhost must be stripped\n{request}"
3352 );
3353 assert!(
3354 !lower.contains("content-length: 42"),
3355 "exchange Content-Length must not be copied\n{request}"
3356 );
3357 assert!(
3358 !lower.lines().any(|l| l.starts_with("connection:")),
3359 "Connection header must not be forwarded\n{request}"
3360 );
3361 assert!(
3362 !lower.lines().any(|l| l.starts_with("upgrade:")),
3363 "Upgrade header must not be forwarded\n{request}"
3364 );
3365 let host_header = lower
3366 .lines()
3367 .find(|l| l.starts_with("host:"))
3368 .map(|l| l.split_once(':').map(|(_, v)| v).unwrap_or("").trim())
3369 .expect("outbound Host header must be set by reqwest");
3370 assert!(
3371 host_header.starts_with("127.0.0.1:"),
3372 "outbound Host '{host_header}' must match the capture-server address"
3373 );
3374 }
3375
3376 #[tokio::test]
3377 async fn producer_forwards_request_only_headers() {
3378 use tower::ServiceExt;
3379
3380 let (url, captured, _handle) = start_request_capturing_server().await;
3381 let ctx = test_producer_ctx();
3382 let component = HttpComponent::new();
3383 let endpoint_ctx = NoOpComponentContext;
3384 let endpoint = component
3385 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
3386 .unwrap();
3387 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3388
3389 let mut exchange = Exchange::new(Message::default());
3390 exchange.input.set_header("Accept", "application/json");
3391 exchange.input.set_header("User-Agent", "myclient/1.0");
3392
3393 let result = producer.oneshot(exchange).await;
3394 assert!(result.is_ok(), "producer call failed: {:?}", result);
3395
3396 tokio::time::sleep(Duration::from_millis(100)).await;
3397 let request = captured
3398 .lock()
3399 .unwrap()
3400 .take()
3401 .expect("no outbound request captured");
3402 let lower = request.to_ascii_lowercase();
3403 assert!(
3404 lower.contains("accept: application/json"),
3405 "request-only Accept header must be forwarded\n{request}"
3406 );
3407 assert!(
3408 lower.contains("user-agent: myclient/1.0"),
3409 "request-only User-Agent header must be forwarded\n{request}"
3410 );
3411 }
3412
3413 #[tokio::test]
3414 async fn producer_honours_skip_request_headers() {
3415 use tower::ServiceExt;
3416
3417 let (url, captured, _handle) = start_request_capturing_server().await;
3418 let ctx = test_producer_ctx();
3419 let component = HttpComponent::new();
3420 let endpoint_ctx = NoOpComponentContext;
3421 let endpoint = component
3422 .create_endpoint(
3423 &format!("{url}/api/test?allowInternal=true&skipRequestHeaders=Authorization"),
3424 &endpoint_ctx,
3425 )
3426 .unwrap();
3427 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3428
3429 let mut exchange = Exchange::new(Message::default());
3430 exchange.input.set_header("Authorization", "Bearer x");
3431
3432 let result = producer.oneshot(exchange).await;
3433 assert!(result.is_ok(), "producer call failed: {:?}", result);
3434
3435 tokio::time::sleep(Duration::from_millis(100)).await;
3436 let request = captured
3437 .lock()
3438 .unwrap()
3439 .take()
3440 .expect("no outbound request captured");
3441 assert!(
3442 !request.to_ascii_lowercase().contains("authorization"),
3443 "Authorization must be stripped by skipRequestHeaders\n{request}"
3444 );
3445 }
3446
3447 #[tokio::test]
3448 async fn test_http_producer_post_with_body() {
3449 use tower::ServiceExt;
3450
3451 let (url, _handle) = start_test_server().await;
3452 let ctx = test_producer_ctx();
3453
3454 let component = HttpComponent::new();
3455 let endpoint_ctx = NoOpComponentContext;
3456 let endpoint = component
3457 .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
3458 .unwrap();
3459 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3460
3461 let exchange = Exchange::new(Message::new("request body"));
3462 let result = producer.oneshot(exchange).await.unwrap();
3463
3464 let status = result
3465 .input
3466 .header("CamelHttpResponseCode")
3467 .and_then(|v| v.as_u64())
3468 .unwrap();
3469 assert_eq!(status, 200);
3470 }
3471
3472 #[tokio::test]
3473 async fn test_http_producer_method_from_header() {
3474 use tower::ServiceExt;
3475
3476 let (url, _handle) = start_test_server().await;
3477 let ctx = test_producer_ctx();
3478
3479 let component = HttpComponent::new();
3480 let endpoint_ctx = NoOpComponentContext;
3481 let endpoint = component
3482 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
3483 .unwrap();
3484 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3485
3486 let mut exchange = Exchange::new(Message::default());
3487 exchange.input.set_header(
3488 "CamelHttpMethod",
3489 serde_json::Value::String("DELETE".to_string()),
3490 );
3491
3492 let result = producer.oneshot(exchange).await.unwrap();
3493 let status = result
3494 .input
3495 .header("CamelHttpResponseCode")
3496 .and_then(|v| v.as_u64())
3497 .unwrap();
3498 assert_eq!(status, 200);
3499 }
3500
3501 #[tokio::test]
3502 async fn test_http_producer_forced_method() {
3503 use tower::ServiceExt;
3504
3505 let (url, _handle) = start_test_server().await;
3506 let ctx = test_producer_ctx();
3507
3508 let component = HttpComponent::new();
3509 let endpoint_ctx = NoOpComponentContext;
3510 let endpoint = component
3511 .create_endpoint(
3512 &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
3513 &endpoint_ctx,
3514 )
3515 .unwrap();
3516 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3517
3518 let exchange = Exchange::new(Message::default());
3519 let result = producer.oneshot(exchange).await.unwrap();
3520
3521 let status = result
3522 .input
3523 .header("CamelHttpResponseCode")
3524 .and_then(|v| v.as_u64())
3525 .unwrap();
3526 assert_eq!(status, 200);
3527 }
3528
3529 #[tokio::test]
3530 async fn test_http_producer_throw_exception_on_failure() {
3531 use tower::ServiceExt;
3532
3533 let (url, _handle) = start_status_server(404).await;
3534 let ctx = test_producer_ctx();
3535
3536 let component = HttpComponent::new();
3537 let endpoint_ctx = NoOpComponentContext;
3538 let endpoint = component
3539 .create_endpoint(
3540 &format!("{url}/not-found?allowInternal=true"),
3541 &endpoint_ctx,
3542 )
3543 .unwrap();
3544 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3545
3546 let exchange = Exchange::new(Message::default());
3547 let result = producer.oneshot(exchange).await;
3548 assert!(result.is_err());
3549
3550 match result.unwrap_err() {
3551 CamelError::HttpOperationFailed { status_code, .. } => {
3552 assert_eq!(status_code, 404);
3553 }
3554 e => panic!("Expected HttpOperationFailed, got: {e}"),
3555 }
3556 }
3557
3558 #[tokio::test]
3559 async fn test_http_producer_no_throw_on_failure() {
3560 use tower::ServiceExt;
3561
3562 let (url, _handle) = start_status_server(500).await;
3563 let ctx = test_producer_ctx();
3564
3565 let component = HttpComponent::new();
3566 let endpoint_ctx = NoOpComponentContext;
3567 let endpoint = component
3568 .create_endpoint(
3569 &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
3570 &endpoint_ctx,
3571 )
3572 .unwrap();
3573 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3574
3575 let exchange = Exchange::new(Message::default());
3576 let result = producer.oneshot(exchange).await.unwrap();
3577
3578 let status = result
3579 .input
3580 .header("CamelHttpResponseCode")
3581 .and_then(|v| v.as_u64())
3582 .unwrap();
3583 assert_eq!(status, 500);
3584 }
3585
3586 #[tokio::test]
3587 async fn test_http_producer_uri_override() {
3588 use tower::ServiceExt;
3589
3590 let (url, _handle) = start_test_server().await;
3591 let ctx = test_producer_ctx();
3592
3593 let component = HttpComponent::new();
3594 let endpoint_ctx = NoOpComponentContext;
3595 let endpoint = component
3596 .create_endpoint(
3597 "http://localhost:1/does-not-exist?allowInternal=true",
3598 &endpoint_ctx,
3599 )
3600 .unwrap();
3601 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3602
3603 let mut exchange = Exchange::new(Message::default());
3604 exchange.input.set_header(
3605 "CamelHttpUri",
3606 serde_json::Value::String(format!("{url}/api")),
3607 );
3608
3609 let result = producer.oneshot(exchange).await.unwrap();
3610 let status = result
3611 .input
3612 .header("CamelHttpResponseCode")
3613 .and_then(|v| v.as_u64())
3614 .unwrap();
3615 assert_eq!(status, 200);
3616 }
3617
3618 #[tokio::test]
3619 async fn test_http_producer_response_headers_mapped() {
3620 use tower::ServiceExt;
3621
3622 let (url, _handle) = start_test_server().await;
3623 let ctx = test_producer_ctx();
3624
3625 let component = HttpComponent::new();
3626 let endpoint_ctx = NoOpComponentContext;
3627 let endpoint = component
3628 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
3629 .unwrap();
3630 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3631
3632 let exchange = Exchange::new(Message::default());
3633 let result = producer.oneshot(exchange).await.unwrap();
3634
3635 assert!(
3636 result.input.header("Content-Type").is_some(),
3637 "Response should have Content-Type header"
3638 );
3639 assert!(result.input.header("CamelHttpResponseText").is_some());
3640 }
3641
3642 async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
3647 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3648 let addr = listener.local_addr().unwrap();
3649 let url = format!("http://127.0.0.1:{}", addr.port());
3650
3651 let handle = tokio::spawn(async move {
3652 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3653 loop {
3654 if let Ok((mut stream, _)) = listener.accept().await {
3655 tokio::spawn(async move {
3656 let mut buf = vec![0u8; 4096];
3657 let n = stream.read(&mut buf).await.unwrap_or(0);
3658 let request = String::from_utf8_lossy(&buf[..n]).to_string();
3659
3660 if request.contains("GET /final") {
3662 let body = r#"{"status":"final"}"#;
3663 let response = format!(
3664 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
3665 body.len(),
3666 body
3667 );
3668 let _ = stream.write_all(response.as_bytes()).await;
3669 } else {
3670 let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\n\r\n";
3672 let _ = stream.write_all(response.as_bytes()).await;
3673 }
3674 });
3675 }
3676 }
3677 });
3678
3679 (url, handle)
3680 }
3681
3682 struct CapturedRequest {
3683 method: String,
3684 path: String,
3685 body: Vec<u8>,
3686 content_length: Option<String>,
3687 transfer_encoding: Option<String>,
3688 }
3689
3690 async fn capture_request(stream: &mut tokio::net::TcpStream) -> Option<CapturedRequest> {
3696 use tokio::io::AsyncReadExt;
3697
3698 let mut buf: Vec<u8> = Vec::new();
3700 let mut chunk = [0u8; 4096];
3701 let head_end: usize;
3702 loop {
3703 let n = stream.read(&mut chunk).await.unwrap_or(0);
3704 if n == 0 {
3705 return None;
3706 }
3707 buf.extend_from_slice(&chunk[..n]);
3708 if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
3709 head_end = pos + 4;
3710 break;
3711 }
3712 }
3713
3714 let head = String::from_utf8_lossy(&buf[..head_end]);
3716 let mut lines = head.split("\r\n");
3717 let request_line = lines.next().unwrap_or("");
3718 let mut parts = request_line.split_whitespace();
3719 let method = parts.next().unwrap_or("").to_string();
3720 let path = parts.next().unwrap_or("").to_string();
3721
3722 let mut content_length: Option<String> = None;
3723 let mut transfer_encoding: Option<String> = None;
3724 for line in lines {
3725 if let Some((name, value)) = line.split_once(':') {
3726 let name = name.trim().to_ascii_lowercase();
3727 let value = value.trim().to_string();
3728 if name == "content-length" {
3729 content_length = Some(value);
3730 } else if name == "transfer-encoding" {
3731 transfer_encoding = Some(value);
3732 }
3733 }
3734 }
3735
3736 let body_len: usize = content_length
3738 .as_deref()
3739 .and_then(|v| v.parse::<usize>().ok())
3740 .unwrap_or(0);
3741
3742 let mut body: Vec<u8> = buf[head_end..].to_vec();
3743 while body.len() < body_len {
3744 let n = stream.read(&mut chunk).await.unwrap_or(0);
3745 if n == 0 {
3746 break;
3747 }
3748 body.extend_from_slice(&chunk[..n]);
3749 }
3750 body.truncate(body_len);
3751
3752 Some(CapturedRequest {
3753 method,
3754 path,
3755 body,
3756 content_length,
3757 transfer_encoding,
3758 })
3759 }
3760
3761 async fn start_capture_server() -> (
3766 String,
3767 tokio::task::JoinHandle<()>,
3768 Arc<Mutex<Vec<CapturedRequest>>>,
3769 ) {
3770 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3771 let addr = listener.local_addr().unwrap();
3772 let url = format!("http://127.0.0.1:{}", addr.port());
3773
3774 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
3775 let captured_for_return = Arc::clone(&captured);
3776
3777 let handle = tokio::spawn(async move {
3778 use tokio::io::AsyncWriteExt;
3779 loop {
3780 if let Ok((mut stream, _)) = listener.accept().await {
3781 let captured = Arc::clone(&captured);
3782 tokio::spawn(async move {
3783 let Some(req) = capture_request(&mut stream).await else {
3784 return;
3785 };
3786 captured.lock().unwrap().push(req);
3787
3788 let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
3791 let _ = stream.write_all(response.as_bytes()).await;
3792 });
3793 }
3794 }
3795 });
3796
3797 (url, handle, captured_for_return)
3798 }
3799
3800 async fn start_redirect_capture_server() -> (
3806 String,
3807 tokio::task::JoinHandle<()>,
3808 Arc<Mutex<Vec<CapturedRequest>>>,
3809 ) {
3810 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3811 let addr = listener.local_addr().unwrap();
3812 let url = format!("http://127.0.0.1:{}", addr.port());
3813
3814 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
3815 let captured_for_return = Arc::clone(&captured);
3816
3817 let handle = tokio::spawn(async move {
3818 use tokio::io::AsyncWriteExt;
3819 loop {
3820 if let Ok((mut stream, _)) = listener.accept().await {
3821 let captured = Arc::clone(&captured);
3822 tokio::spawn(async move {
3823 let Some(req) = capture_request(&mut stream).await else {
3824 return;
3825 };
3826 let path = req.path.clone();
3827 captured.lock().unwrap().push(req);
3828
3829 let (status_line, location) = match path.as_str() {
3830 "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
3831 "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
3832 "/final" => ("HTTP/1.1 200 OK", None),
3833 _ => ("HTTP/1.1 404 Not Found", None),
3834 };
3835
3836 let response = match location {
3837 Some(loc) => format!(
3838 "{status_line}\r\nLocation: {loc}\r\nContent-Length: 0\r\n\r\n"
3839 ),
3840 None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
3841 };
3842 let _ = stream.write_all(response.as_bytes()).await;
3843 });
3844 }
3845 }
3846 });
3847
3848 (url, handle, captured_for_return)
3849 }
3850
3851 #[tokio::test]
3852 async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
3853 use tower::ServiceExt;
3854
3855 let (url, _handle, captured) = start_capture_server().await;
3856 let ctx = test_producer_ctx();
3857
3858 let component = HttpComponent::with_config(HttpConfig::default());
3859 let endpoint_ctx = NoOpComponentContext;
3860 let endpoint = component
3861 .create_endpoint(
3862 &format!("{url}?httpMethod=GET&allowInternal=true"),
3863 &endpoint_ctx,
3864 )
3865 .unwrap();
3866 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3867
3868 let mut exchange = Exchange::new(Message::default());
3869 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3870
3871 let result = producer.oneshot(exchange).await.unwrap();
3872
3873 let status = result
3874 .input
3875 .header("CamelHttpResponseCode")
3876 .and_then(|v| v.as_u64())
3877 .unwrap();
3878 assert_eq!(status, 200);
3879
3880 let captured = captured.lock().unwrap();
3881 assert_eq!(captured.len(), 1, "expected exactly one captured request");
3882 let req = &captured[0];
3883 assert_eq!(req.method, "GET");
3884 assert_eq!(req.path, "/");
3887 assert!(req.body.is_empty(), "GET must not carry a body");
3888 assert!(
3889 req.content_length.is_none(),
3890 "suppressed request must not carry Content-Length"
3891 );
3892 assert!(
3893 req.transfer_encoding.is_none(),
3894 "suppressed request must not carry Transfer-Encoding"
3895 );
3896
3897 assert!(
3899 result.input.body.is_empty(),
3900 "exchange body must be consumed"
3901 );
3902 }
3903
3904 #[tokio::test]
3905 async fn test_head_with_body_suppressed_via_header() {
3906 use tower::ServiceExt;
3907
3908 let (url, _handle, captured) = start_capture_server().await;
3909 let ctx = test_producer_ctx();
3910
3911 let component = HttpComponent::with_config(HttpConfig::default());
3912 let endpoint_ctx = NoOpComponentContext;
3913 let endpoint = component
3914 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
3915 .unwrap();
3916 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3917
3918 let mut exchange = Exchange::new(Message::default());
3919 exchange.input.set_header(
3920 "CamelHttpMethod",
3921 serde_json::Value::String("HEAD".to_string()),
3922 );
3923 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3924
3925 let result = producer.oneshot(exchange).await.unwrap();
3926 let status = result
3927 .input
3928 .header("CamelHttpResponseCode")
3929 .and_then(|v| v.as_u64())
3930 .unwrap();
3931 assert_eq!(status, 200);
3932
3933 let captured = captured.lock().unwrap();
3934 assert_eq!(captured.len(), 1);
3935 let req = &captured[0];
3936 assert_eq!(req.method, "HEAD");
3937 assert!(req.body.is_empty(), "HEAD must not carry a body");
3938 }
3939
3940 #[tokio::test]
3941 async fn test_delete_options_trace_with_body_suppressed() {
3942 use tower::ServiceExt;
3943
3944 let (url, _handle, captured) = start_capture_server().await;
3945 let ctx = test_producer_ctx();
3946 let component = HttpComponent::with_config(HttpConfig::default());
3947 let endpoint_ctx = NoOpComponentContext;
3948
3949 for method in ["DELETE", "OPTIONS", "TRACE"] {
3950 let endpoint = component
3951 .create_endpoint(
3952 &format!("{url}?httpMethod={method}&allowInternal=true"),
3953 &endpoint_ctx,
3954 )
3955 .unwrap();
3956 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3957
3958 let mut exchange = Exchange::new(Message::default());
3959 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3960
3961 let result = producer.oneshot(exchange).await.unwrap();
3962 let status = result
3963 .input
3964 .header("CamelHttpResponseCode")
3965 .and_then(|v| v.as_u64())
3966 .unwrap();
3967 assert_eq!(status, 200, "method {method} should succeed");
3968 }
3969
3970 let captured = captured.lock().unwrap();
3971 assert_eq!(captured.len(), 3, "expected three captured requests");
3972 for method in ["DELETE", "OPTIONS", "TRACE"] {
3973 let req = captured
3974 .iter()
3975 .find(|r| r.method == method)
3976 .unwrap_or_else(|| panic!("missing captured request for {method}"));
3977 assert!(req.body.is_empty(), "{} must not carry a body", req.method);
3978 }
3979 }
3980
3981 #[tokio::test]
3982 async fn test_post_put_patch_with_body_still_sent() {
3983 use tower::ServiceExt;
3984
3985 let (url, _handle, captured) = start_capture_server().await;
3986 let ctx = test_producer_ctx();
3987 let component = HttpComponent::with_config(HttpConfig::default());
3988 let endpoint_ctx = NoOpComponentContext;
3989
3990 for method in ["POST", "PUT", "PATCH"] {
3991 let endpoint = component
3992 .create_endpoint(
3993 &format!("{url}?httpMethod={method}&allowInternal=true"),
3994 &endpoint_ctx,
3995 )
3996 .unwrap();
3997 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3998
3999 let payload = format!("body-for-{method}");
4000 let mut exchange = Exchange::new(Message::default());
4001 exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
4002
4003 let result = producer.oneshot(exchange).await.unwrap();
4004 let status = result
4005 .input
4006 .header("CamelHttpResponseCode")
4007 .and_then(|v| v.as_u64())
4008 .unwrap();
4009 assert_eq!(status, 200, "method {method} should succeed");
4010 }
4011
4012 let captured = captured.lock().unwrap();
4013 assert_eq!(captured.len(), 3, "expected three captured requests");
4014 for method in ["POST", "PUT", "PATCH"] {
4015 let req = captured
4016 .iter()
4017 .find(|r| r.method == method)
4018 .unwrap_or_else(|| panic!("missing captured request for {method}"));
4019 let expected = format!("body-for-{method}");
4020 assert!(!req.body.is_empty(), "{method} must still carry its body");
4021 assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
4022 }
4023 }
4024
4025 #[tokio::test]
4029 async fn test_stream_body_under_get_not_attached() {
4030 use tower::ServiceExt;
4031
4032 let (url, _handle, captured) = start_capture_server().await;
4033 let ctx = test_producer_ctx();
4034
4035 let component = HttpComponent::with_config(HttpConfig::default());
4036 let endpoint_ctx = NoOpComponentContext;
4037 let endpoint = component
4038 .create_endpoint(
4039 &format!("{url}?httpMethod=GET&allowInternal=true"),
4040 &endpoint_ctx,
4041 )
4042 .unwrap();
4043 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4044
4045 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
4046 vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
4047 let stream = Box::pin(futures::stream::iter(chunks));
4048 let mut exchange = Exchange::new(Message::default());
4049 exchange.input.body = Body::Stream(StreamBody {
4050 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
4051 metadata: StreamMetadata::default(),
4052 });
4053
4054 let result = producer.oneshot(exchange).await.unwrap();
4055
4056 let status = result
4057 .input
4058 .header("CamelHttpResponseCode")
4059 .and_then(|v| v.as_u64())
4060 .unwrap();
4061 assert_eq!(status, 200);
4062
4063 let captured = captured.lock().unwrap();
4064 assert_eq!(captured.len(), 1, "expected exactly one captured request");
4065 assert!(
4066 captured[0].body.is_empty(),
4067 "GET must not carry a stream body"
4068 );
4069 assert!(
4070 captured[0].transfer_encoding.is_none(),
4071 "suppressed request must not carry Transfer-Encoding"
4072 );
4073 assert!(
4074 captured[0].content_length.is_none(),
4075 "suppressed request must not carry Content-Length"
4076 );
4077 assert!(
4078 result.input.body.is_empty(),
4079 "exchange body must be consumed to Empty, not left as a stream"
4080 );
4081 }
4082
4083 #[tokio::test]
4087 async fn test_redirect_hops_never_replay_suppressed_body() {
4088 use tower::ServiceExt;
4089
4090 let (url, _handle, captured) = start_redirect_capture_server().await;
4091 let ctx = test_producer_ctx();
4092
4093 let component =
4094 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4095 let endpoint_ctx = NoOpComponentContext;
4096
4097 for path in ["/hop307", "/hop308"] {
4098 let endpoint = component
4099 .create_endpoint(
4100 &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
4101 &endpoint_ctx,
4102 )
4103 .unwrap();
4104 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4105
4106 let mut exchange = Exchange::new(Message::default());
4107 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
4108
4109 let result = producer.oneshot(exchange).await.unwrap();
4110 let status = result
4111 .input
4112 .header("CamelHttpResponseCode")
4113 .and_then(|v| v.as_u64())
4114 .unwrap();
4115 assert_eq!(
4116 status, 200,
4117 "redirect chain for {path} should end at /final"
4118 );
4119 }
4120
4121 let captured = captured.lock().unwrap();
4123 assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
4124 for req in captured.iter() {
4125 assert!(
4126 req.body.is_empty(),
4127 "hop {} {} must not carry a body",
4128 req.method,
4129 req.path
4130 );
4131 }
4132 }
4133
4134 #[tracing_test::traced_test]
4143 #[tokio::test]
4144 async fn test_suppressed_body_logs_exactly_one_warn() {
4145 use tower::ServiceExt;
4146
4147 let (url, _handle, _captured) = start_capture_server().await;
4148 let ctx = test_producer_ctx();
4149
4150 let component = HttpComponent::with_config(HttpConfig::default());
4151 let endpoint_ctx = NoOpComponentContext;
4152 let endpoint = component
4153 .create_endpoint(
4154 &format!("{url}?httpMethod=GET&allowInternal=true"),
4155 &endpoint_ctx,
4156 )
4157 .unwrap();
4158 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4159
4160 let mut exchange = Exchange::new(Message::default());
4161 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
4162 let correlation_id = exchange.correlation_id().to_string();
4163
4164 let result = producer.oneshot(exchange).await.unwrap();
4165 let status = result
4166 .input
4167 .header("CamelHttpResponseCode")
4168 .and_then(|v| v.as_u64())
4169 .unwrap();
4170 assert_eq!(status, 200);
4171
4172 logs_assert(|lines: &[&str]| {
4173 let hits = lines
4174 .iter()
4175 .filter(|l| {
4176 l.contains("dropping request body")
4177 && l.contains("method=GET")
4178 && l.contains(&format!("correlation_id={correlation_id}"))
4179 })
4180 .count();
4181 match hits {
4182 1 => Ok(()),
4183 n => Err(format!("expected exactly one body-drop warn, found {n}")),
4184 }
4185 });
4186 }
4187
4188 #[tracing_test::traced_test]
4189 #[tokio::test]
4190 async fn test_empty_body_get_emits_no_warn() {
4191 use tower::ServiceExt;
4192
4193 let (url, _handle, _captured) = start_capture_server().await;
4194 let ctx = test_producer_ctx();
4195
4196 let component = HttpComponent::with_config(HttpConfig::default());
4197 let endpoint_ctx = NoOpComponentContext;
4198 let endpoint = component
4199 .create_endpoint(
4200 &format!("{url}?httpMethod=GET&allowInternal=true"),
4201 &endpoint_ctx,
4202 )
4203 .unwrap();
4204 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4205
4206 let exchange = Exchange::new(Message::default());
4207 let result = producer.oneshot(exchange).await.unwrap();
4208 let status = result
4209 .input
4210 .header("CamelHttpResponseCode")
4211 .and_then(|v| v.as_u64())
4212 .unwrap();
4213 assert_eq!(status, 200);
4214
4215 logs_assert(|lines: &[&str]| {
4216 let hits = lines
4217 .iter()
4218 .filter(|l| l.contains("dropping request body"))
4219 .count();
4220 match hits {
4221 0 => Ok(()),
4222 n => Err(format!("expected no body-drop warn, found {n}")),
4223 }
4224 });
4225 }
4226
4227 #[tokio::test]
4228 async fn test_follow_redirects_false_does_not_follow() {
4229 use tower::ServiceExt;
4230
4231 let (url, _handle) = start_redirect_server().await;
4232 let ctx = test_producer_ctx();
4233
4234 let component =
4235 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
4236 let endpoint_ctx = NoOpComponentContext;
4237 let endpoint = component
4238 .create_endpoint(
4239 &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
4240 &endpoint_ctx,
4241 )
4242 .unwrap();
4243 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4244
4245 let exchange = Exchange::new(Message::default());
4246 let result = producer.oneshot(exchange).await.unwrap();
4247
4248 let status = result
4250 .input
4251 .header("CamelHttpResponseCode")
4252 .and_then(|v| v.as_u64())
4253 .unwrap();
4254 assert_eq!(
4255 status, 302,
4256 "Should NOT follow redirect when followRedirects=false"
4257 );
4258 }
4259
4260 #[tokio::test]
4261 async fn test_follow_redirects_true_follows_redirect() {
4262 use tower::ServiceExt;
4263
4264 let (url, _handle) = start_redirect_server().await;
4265 let ctx = test_producer_ctx();
4266
4267 let component =
4268 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4269 let endpoint_ctx = NoOpComponentContext;
4270 let endpoint = component
4271 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
4272 .unwrap();
4273 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4274
4275 let exchange = Exchange::new(Message::default());
4276 let result = producer.oneshot(exchange).await.unwrap();
4277
4278 let status = result
4280 .input
4281 .header("CamelHttpResponseCode")
4282 .and_then(|v| v.as_u64())
4283 .unwrap();
4284 assert_eq!(
4285 status, 200,
4286 "Should follow redirect when followRedirects=true"
4287 );
4288 }
4289
4290 #[tokio::test]
4293 async fn test_redirect_to_private_ip_is_ssrf_blocked() {
4294 use tower::ServiceExt;
4295
4296 let (url, _handle) = start_redirect_server().await;
4298 let ctx = test_producer_ctx();
4299
4300 let component =
4301 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4302 let endpoint_ctx = NoOpComponentContext;
4303 let endpoint = component
4304 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
4305 .unwrap();
4306 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4307
4308 let exchange = Exchange::new(Message::default());
4309 let result = producer.oneshot(exchange).await;
4310
4311 assert!(
4313 result.is_ok(),
4314 "Redirect should succeed with allowInternal=true, got: {:?}",
4315 result
4316 );
4317 let exchange = result.unwrap();
4318 let status = exchange
4319 .input
4320 .header("CamelHttpResponseCode")
4321 .and_then(|v| v.as_u64())
4322 .unwrap();
4323 assert_eq!(status, 200, "Should follow redirect to /final");
4324 }
4325
4326 #[tokio::test]
4328 async fn test_redirect_to_private_ip_allowed_when_configured() {
4329 use tower::ServiceExt;
4330
4331 let (url, _handle) = start_redirect_server().await;
4333 let ctx = test_producer_ctx();
4334
4335 let component =
4336 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4337 let endpoint_ctx = NoOpComponentContext;
4338 let endpoint = component
4339 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
4340 .unwrap();
4341 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4342
4343 let exchange = Exchange::new(Message::default());
4344 let result = producer.oneshot(exchange).await.unwrap();
4345
4346 let status = result
4347 .input
4348 .header("CamelHttpResponseCode")
4349 .and_then(|v| v.as_u64())
4350 .unwrap();
4351 assert_eq!(
4352 status, 200,
4353 "Should follow redirect to private IP when allowInternal=true"
4354 );
4355 }
4356
4357 #[tokio::test]
4360 async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
4361 use tower::ServiceExt;
4362
4363 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4365 let addr = listener.local_addr().unwrap();
4366 let url = format!("http://127.0.0.1:{}", addr.port());
4367
4368 let handle = tokio::spawn(async move {
4369 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4370 loop {
4371 if let Ok((mut stream, _)) = listener.accept().await {
4372 tokio::spawn(async move {
4373 let mut buf = vec![0u8; 4096];
4374 let _ = stream.read(&mut buf).await;
4375 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";
4377 let _ = stream.write_all(response.as_bytes()).await;
4378 });
4379 }
4380 }
4381 });
4382
4383 let ctx = test_producer_ctx();
4384 let component =
4385 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4386 let endpoint_ctx = NoOpComponentContext;
4387 let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
4389 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4390
4391 let exchange = Exchange::new(Message::default());
4392 let result = producer.oneshot(exchange).await;
4393
4394 assert!(
4396 result.is_err(),
4397 "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
4398 );
4399 let err = result.unwrap_err().to_string();
4400 assert!(
4401 err.contains("blocked IP")
4402 || err.contains("private IP")
4403 || err.contains("SSRF")
4404 || err.contains("not allowed"),
4405 "Error should mention SSRF/IP blocking, got: {err}"
4406 );
4407
4408 handle.abort();
4409 }
4410
4411 #[tokio::test]
4413 async fn test_too_many_redirects_returns_error() {
4414 use tower::ServiceExt;
4415
4416 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4418 let addr = listener.local_addr().unwrap();
4419 let url = format!("http://127.0.0.1:{}", addr.port());
4420
4421 let handle = tokio::spawn(async move {
4422 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4423 loop {
4424 if let Ok((mut stream, _)) = listener.accept().await {
4425 tokio::spawn(async move {
4426 let mut buf = vec![0u8; 4096];
4427 let _ = stream.read(&mut buf).await;
4428 let response =
4430 "HTTP/1.1 302 Found\r\nLocation: /loop\r\nContent-Length: 0\r\n\r\n";
4431 let _ = stream.write_all(response.as_bytes()).await;
4432 });
4433 }
4434 }
4435 });
4436
4437 let ctx = test_producer_ctx();
4438 let component =
4439 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4440 let endpoint_ctx = NoOpComponentContext;
4441 let endpoint = component
4442 .create_endpoint(
4443 &format!("{url}?allowInternal=true&maxRedirects=2"),
4444 &endpoint_ctx,
4445 )
4446 .unwrap();
4447 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4448
4449 let exchange = Exchange::new(Message::default());
4450 let result = producer.oneshot(exchange).await;
4451
4452 match result {
4460 Err(e) => {
4461 let msg = e.to_string();
4463 assert!(
4464 msg.contains("HTTP operation failed") || msg.contains("302"),
4465 "expected redirect-after-exhaustion error, got: {msg}"
4466 );
4467 }
4468 Ok(ex) => {
4469 let response_code = ex
4470 .input
4471 .header("CamelHttpResponseCode")
4472 .and_then(|v| v.as_u64());
4473 assert_eq!(
4474 response_code,
4475 Some(302),
4476 "expected 302 after exhausting redirects"
4477 );
4478 }
4479 }
4480
4481 handle.abort();
4482 }
4483
4484 #[tokio::test]
4485 async fn test_query_params_forwarded_to_http_request() {
4486 use tower::ServiceExt;
4487
4488 let (url, _handle) = start_test_server().await;
4489 let ctx = test_producer_ctx();
4490
4491 let component = HttpComponent::new();
4492 let endpoint_ctx = NoOpComponentContext;
4493 let endpoint = component
4495 .create_endpoint(
4496 &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
4497 &endpoint_ctx,
4498 )
4499 .unwrap();
4500 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4501
4502 let exchange = Exchange::new(Message::default());
4503 let result = producer.oneshot(exchange).await.unwrap();
4504
4505 let status = result
4508 .input
4509 .header("CamelHttpResponseCode")
4510 .and_then(|v| v.as_u64())
4511 .unwrap();
4512 assert_eq!(status, 200);
4513 }
4514
4515 #[tokio::test]
4516 async fn test_non_camel_query_params_are_forwarded() {
4517 let config = HttpEndpointConfig::from_uri(
4520 "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
4521 )
4522 .unwrap();
4523
4524 assert!(
4526 config.query_params.contains_key("apiKey"),
4527 "apiKey should be preserved"
4528 );
4529 assert!(
4530 config.query_params.contains_key("token"),
4531 "token should be preserved"
4532 );
4533 assert_eq!(config.query_params.get("apiKey").unwrap(), "secret123");
4534 assert_eq!(config.query_params.get("token").unwrap(), "abc456");
4535
4536 assert!(
4538 !config.query_params.contains_key("httpMethod"),
4539 "httpMethod should not be forwarded"
4540 );
4541 }
4542
4543 #[test]
4544 fn test_query_params_are_url_encoded_when_resolving_url() {
4545 let config =
4546 HttpEndpointConfig::from_uri("http://example.com/api?q=hello world&tag=a+b").unwrap();
4547 let exchange = Exchange::new(Message::default());
4548
4549 let url = HttpProducer::resolve_url(&exchange, &config);
4550
4551 assert!(url.contains("q=hello+world"), "url was: {url}");
4552 assert!(url.contains("tag=a%2Bb"), "url was: {url}");
4553 }
4554
4555 async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
4560 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4561 let addr = listener.local_addr().unwrap();
4562 let url = format!("http://127.0.0.1:{}", addr.port());
4563
4564 let handle = tokio::spawn(async move {
4565 loop {
4566 if let Ok((mut stream, _)) = listener.accept().await {
4567 let delay = delay_ms;
4568 tokio::spawn(async move {
4569 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4570 let mut buf = vec![0u8; 4096];
4571 let _ = stream.read(&mut buf).await;
4572 let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
4574 let _ = stream.write_all(headers.as_bytes()).await;
4575 tokio::time::sleep(Duration::from_millis(delay)).await;
4577 let body = r#"{"status":"slow"}"#;
4578 let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
4579 let _ = stream.write_all(chunk.as_bytes()).await;
4580 });
4581 }
4582 }
4583 });
4584
4585 (url, handle)
4586 }
4587
4588 #[tokio::test]
4589 async fn test_http_producer_timeout() {
4590 use tower::ServiceExt;
4591
4592 let (url, _handle) = start_slow_server(500).await;
4594 let ctx = test_producer_ctx();
4595
4596 let component = HttpComponent::with_config(
4597 HttpConfig::default()
4598 .with_read_timeout_ms(100)
4599 .with_response_timeout_ms(30_000), );
4601 let endpoint_ctx = NoOpComponentContext;
4602 let endpoint = component
4603 .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
4604 .unwrap();
4605 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4606
4607 let exchange = Exchange::new(Message::default());
4608 let result = producer.oneshot(exchange).await;
4609
4610 assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
4611 let err = result.unwrap_err().to_string();
4612 assert!(
4613 err.contains("Read timeout") || err.contains("timeout"),
4614 "Error should mention timeout, got: {}",
4615 err
4616 );
4617 }
4618
4619 #[tokio::test]
4620 async fn test_http_producer_no_timeout_when_fast() {
4621 use tower::ServiceExt;
4622
4623 let (url, _handle) = start_test_server().await;
4624 let ctx = test_producer_ctx();
4625
4626 let component =
4627 HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
4628 let endpoint_ctx = NoOpComponentContext;
4629 let endpoint = component
4630 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4631 .unwrap();
4632 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4633
4634 let exchange = Exchange::new(Message::default());
4635 let result = producer.oneshot(exchange).await.unwrap();
4636
4637 let status = result
4638 .input
4639 .header("CamelHttpResponseCode")
4640 .and_then(|v| v.as_u64())
4641 .unwrap();
4642 assert_eq!(status, 200);
4643 }
4644
4645 #[tokio::test]
4650 async fn test_http_producer_blocks_metadata_endpoint() {
4651 use tower::ServiceExt;
4652
4653 let ctx = test_producer_ctx();
4654 let component = HttpComponent::new();
4655 let endpoint_ctx = NoOpComponentContext;
4656 let endpoint = component
4657 .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
4658 .unwrap();
4659 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4660
4661 let mut exchange = Exchange::new(Message::default());
4662 exchange.input.set_header(
4663 "CamelHttpUri",
4664 serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
4665 );
4666
4667 let result = producer.oneshot(exchange).await;
4668 assert!(result.is_err(), "Should block AWS metadata endpoint");
4669
4670 let err = result.unwrap_err();
4671 assert!(
4672 err.to_string().contains("Private IP"),
4673 "Error should mention private IP blocking, got: {}",
4674 err
4675 );
4676 }
4677
4678 #[test]
4679 fn test_ssrf_config_defaults() {
4680 let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
4681 assert!(
4682 !config.allow_internal,
4683 "Private IPs should be blocked by default"
4684 );
4685 assert!(
4686 config.blocked_hosts.is_empty(),
4687 "Blocked hosts should be empty by default"
4688 );
4689 }
4690
4691 #[test]
4692 fn test_ssrf_config_allow_internal() {
4693 let config =
4694 HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
4695 assert!(
4696 config.allow_internal,
4697 "Private IPs should be allowed when explicitly set"
4698 );
4699 }
4700
4701 #[test]
4702 fn test_ssrf_config_blocked_hosts() {
4703 let config = HttpEndpointConfig::from_uri(
4704 "http://example.com/api?blockedHosts=evil.com,malware.net",
4705 )
4706 .unwrap();
4707 assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
4708 }
4709
4710 #[tokio::test]
4711 async fn test_http_producer_blocks_localhost() {
4712 use tower::ServiceExt;
4713
4714 let ctx = test_producer_ctx();
4715 let component = HttpComponent::new();
4716 let endpoint_ctx = NoOpComponentContext;
4717 let endpoint = component
4718 .create_endpoint("http://example.com/api", &endpoint_ctx)
4719 .unwrap();
4720 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4721
4722 let mut exchange = Exchange::new(Message::default());
4723 exchange.input.set_header(
4724 "CamelHttpUri",
4725 serde_json::Value::String("http://localhost:8080/internal".to_string()),
4726 );
4727
4728 let result = producer.oneshot(exchange).await;
4729 assert!(result.is_err(), "Should block localhost");
4730 }
4731
4732 #[tokio::test]
4733 async fn test_http_producer_blocks_loopback_ip() {
4734 use tower::ServiceExt;
4735
4736 let ctx = test_producer_ctx();
4737 let component = HttpComponent::new();
4738 let endpoint_ctx = NoOpComponentContext;
4739 let endpoint = component
4740 .create_endpoint("http://example.com/api", &endpoint_ctx)
4741 .unwrap();
4742 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4743
4744 let mut exchange = Exchange::new(Message::default());
4745 exchange.input.set_header(
4746 "CamelHttpUri",
4747 serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
4748 );
4749
4750 let result = producer.oneshot(exchange).await;
4751 assert!(result.is_err(), "Should block loopback IP");
4752 }
4753
4754 #[tokio::test]
4755 async fn test_http_producer_allows_private_ip_when_enabled() {
4756 use tower::ServiceExt;
4757
4758 let ctx = test_producer_ctx();
4759 let component = HttpComponent::new();
4760 let endpoint_ctx = NoOpComponentContext;
4761 let endpoint = component
4764 .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
4765 .unwrap();
4766 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4767
4768 let exchange = Exchange::new(Message::default());
4769
4770 let result = producer.oneshot(exchange).await;
4773 if let Err(ref e) = result {
4775 let err_str = e.to_string();
4776 assert!(
4777 !err_str.contains("Private IP") && !err_str.contains("not allowed"),
4778 "Should not be SSRF error, got: {}",
4779 err_str
4780 );
4781 }
4782 }
4783
4784 #[test]
4789 fn test_http_server_config_parse() {
4790 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
4791 assert_eq!(cfg.host, "0.0.0.0");
4792 assert_eq!(cfg.port, 8080);
4793 assert_eq!(cfg.path, "/orders");
4794 assert_eq!(cfg.max_inflight_requests, 1024);
4795 }
4796
4797 #[test]
4798 fn test_http_server_config_scheme() {
4799 assert_eq!(HttpServerConfig::scheme(), "http");
4801 }
4802
4803 #[test]
4804 fn test_http_server_config_from_components() {
4805 let components = camel_component_api::UriComponents {
4807 scheme: "https".to_string(),
4808 path: "//0.0.0.0:8443/api".to_string(),
4809 params: std::collections::HashMap::from([
4810 ("maxRequestBody".to_string(), "5242880".to_string()),
4811 ("maxInflightRequests".to_string(), "7".to_string()),
4812 ]),
4813 };
4814 let cfg = HttpServerConfig::from_components(components).unwrap();
4815 assert_eq!(cfg.host, "0.0.0.0");
4816 assert_eq!(cfg.port, 8443);
4817 assert_eq!(cfg.path, "/api");
4818 assert_eq!(cfg.max_request_body, 5242880);
4819 assert_eq!(cfg.max_inflight_requests, 7);
4820 }
4821
4822 #[test]
4823 fn test_http_server_config_default_path() {
4824 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
4825 assert_eq!(cfg.path, "/");
4826 }
4827
4828 #[test]
4829 fn test_http_server_config_wrong_scheme() {
4830 assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
4831 }
4832
4833 #[test]
4834 fn test_http_server_config_invalid_port() {
4835 assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
4836 }
4837
4838 #[test]
4839 fn test_http_server_config_default_port_by_scheme() {
4840 let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
4842 assert_eq!(cfg_http.port, 80);
4843
4844 let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
4846 assert_eq!(cfg_https.port, 443);
4847 }
4848
4849 #[test]
4850 fn test_request_envelope_and_reply_are_send() {
4851 fn assert_send<T: Send>() {}
4852 assert_send::<RequestEnvelope>();
4853 assert_send::<HttpReply>();
4854 }
4855
4856 #[test]
4861 fn test_server_registry_global_is_singleton() {
4862 let r1 = ServerRegistry::global();
4863 let r2 = ServerRegistry::global();
4864 assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
4865 }
4866
4867 #[allow(clippy::await_holding_lock)]
4868 #[tokio::test]
4869 async fn test_concurrent_get_or_spawn_returns_same_registry() {
4870 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4871 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4872 let port = listener.local_addr().unwrap().port();
4873 drop(listener);
4874
4875 let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
4876 Arc::new(std::sync::Mutex::new(Vec::new()));
4877
4878 let mut handles = Vec::new();
4879 for _ in 0..4 {
4880 let results = results.clone();
4881 handles.push(tokio::spawn(async move {
4882 let registry = ServerRegistry::global()
4883 .get_or_spawn(
4884 "127.0.0.1",
4885 port,
4886 2 * 1024 * 1024,
4887 10 * 1024 * 1024,
4888 1024,
4889 test_rt(),
4890 "test-route".into(),
4891 None,
4892 )
4893 .await
4894 .unwrap();
4895 results.lock().unwrap().push(registry);
4896 }));
4897 }
4898
4899 for h in handles {
4900 h.await.unwrap();
4901 }
4902
4903 let registries = results.lock().unwrap();
4904 assert_eq!(registries.len(), 4);
4905 for i in 1..registries.len() {
4906 assert!(
4907 Arc::ptr_eq(®istries[0].inner, ®istries[i].inner),
4908 "all concurrent callers should get same route registry"
4909 );
4910 }
4911 }
4912
4913 #[test]
4914 fn test_server_registry_distinguishes_host_and_port() {
4915 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4916 let rt = tokio::runtime::Runtime::new().expect("runtime");
4917 rt.block_on(async {
4918 let registry = ServerRegistry::global();
4919 let d1 = registry
4923 .get_or_spawn(
4924 "127.0.0.1",
4925 0,
4926 1024 * 1024,
4927 10 * 1024 * 1024,
4928 1024,
4929 test_rt(),
4930 "test-route-1".into(),
4931 None,
4932 )
4933 .await;
4934 let d2 = registry
4935 .get_or_spawn(
4936 "0.0.0.0",
4937 0,
4938 1024 * 1024,
4939 10 * 1024 * 1024,
4940 1024,
4941 test_rt(),
4942 "test-route-2".into(),
4943 None,
4944 )
4945 .await;
4946 assert!(d1.is_ok());
4947 assert!(d2.is_ok());
4948 assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
4949 });
4950 }
4951
4952 #[allow(clippy::await_holding_lock)]
4953 #[tokio::test]
4954 async fn test_shared_server_max_request_body_policy_is_deterministic() {
4955 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4956 let registry = ServerRegistry::global();
4957 let d1 = registry
4959 .get_or_spawn(
4960 "127.0.0.1",
4961 9991,
4962 1024 * 1024,
4963 10 * 1024 * 1024,
4964 1024,
4965 test_rt(),
4966 "test-route".into(),
4967 None,
4968 )
4969 .await;
4970 assert!(d1.is_ok());
4971
4972 let d2 = registry
4975 .get_or_spawn(
4976 "127.0.0.1",
4977 9991,
4978 2 * 1024 * 1024,
4979 10 * 1024 * 1024,
4980 1024,
4981 test_rt(),
4982 "test-route-2".into(),
4983 None,
4984 )
4985 .await;
4986 assert!(d2.is_err());
4987 let err = d2.unwrap_err();
4988 assert!(
4989 err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
4990 "Expected incompatible maxRequestBody error, got: {}",
4991 err
4992 );
4993 }
4994
4995 #[test]
4996 fn test_server_registry_reset_clears_entries() {
4997 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4998 let rt = tokio::runtime::Runtime::new().expect("runtime");
4999 rt.block_on(async {
5000 let d1 = ServerRegistry::global()
5002 .get_or_spawn(
5003 "127.0.0.1",
5004 9992,
5005 1024 * 1024,
5006 10 * 1024 * 1024,
5007 1024,
5008 test_rt(),
5009 "test-route".into(),
5010 None,
5011 )
5012 .await;
5013 assert!(d1.is_ok());
5014
5015 let guard = ServerRegistry::global().inner.lock().expect("lock");
5017 assert!(guard.contains_key(&("127.0.0.1".to_string(), 9992)));
5018 drop(guard);
5019
5020 ServerRegistry::reset();
5022
5023 let guard = ServerRegistry::global().inner.lock().expect("lock");
5025 assert!(
5026 guard.is_empty(),
5027 "registry should be empty after reset, has {} entries",
5028 guard.len()
5029 );
5030 });
5031 }
5032
5033 #[tokio::test]
5034 async fn registry_rejects_tls_on_plain_port() {
5035 ServerRegistry::reset();
5036 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
5037
5038 let _r1 = ServerRegistry::global()
5040 .get_or_spawn(
5041 "127.0.0.1",
5042 0,
5043 1024,
5044 1024,
5045 16,
5046 Arc::clone(&rt),
5047 "route-1".into(),
5048 None, )
5050 .await;
5051
5052 let result = ServerRegistry::global()
5054 .get_or_spawn(
5055 "127.0.0.1",
5056 0,
5057 1024,
5058 1024,
5059 16,
5060 Arc::clone(&rt),
5061 "route-2".into(),
5062 Some(crate::config::ServerTlsConfig {
5063 cert_path: "/x.pem".into(),
5064 key_path: "/y.pem".into(),
5065 }),
5066 )
5067 .await;
5068 assert!(result.is_err(), "must reject TLS on plain port");
5069 }
5070
5071 #[allow(clippy::await_holding_lock)]
5076 #[tokio::test]
5077 async fn test_unregister_last_http_route_keeps_server_alive() {
5078 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5079 ServerRegistry::reset();
5080 let registry = ServerRegistry::global();
5081
5082 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5083 let port = listener.local_addr().unwrap().port();
5084 drop(listener); let rt = test_rt();
5086
5087 let _r1 = registry
5090 .get_or_spawn(
5091 "127.0.0.1",
5092 port,
5093 1024 * 1024,
5094 10 * 1024 * 1024,
5095 16,
5096 rt.clone(),
5097 "test-route-1".into(),
5098 None,
5099 )
5100 .await
5101 .unwrap();
5102 let _r2 = registry
5103 .get_or_spawn(
5104 "127.0.0.1",
5105 port,
5106 1024 * 1024,
5107 10 * 1024 * 1024,
5108 16,
5109 rt,
5110 "test-route-2".into(),
5111 None,
5112 )
5113 .await
5114 .unwrap();
5115
5116 let key = ("127.0.0.1".to_string(), port);
5117 let cell = {
5118 let guard = registry.inner.lock().expect("lock");
5119 guard.get(&key).expect("entry should exist").clone()
5120 };
5121
5122 registry.unregister("127.0.0.1", port).await;
5124 {
5125 let handle = cell
5126 .get()
5127 .expect("handle should still exist after first unregister");
5128 assert!(
5129 !handle.monitor_task.is_finished(),
5130 "monitor task should still be alive after first unregister"
5131 );
5132 }
5133
5134 registry.unregister("127.0.0.1", port).await;
5136 tokio::time::sleep(Duration::from_millis(20)).await;
5137 {
5138 let handle = cell
5139 .get()
5140 .expect("handle should still exist after last unregister");
5141 assert!(
5142 !handle.monitor_task.is_finished(),
5143 "monitor task should still be alive — server is process-lifetime"
5144 );
5145 }
5146
5147 {
5149 let guard = registry.inner.lock().expect("lock");
5150 assert!(
5151 guard.get(&key).is_some(),
5152 "entry should remain in registry — server kept alive for restart"
5153 );
5154 }
5155 }
5156
5157 #[tokio::test]
5162 async fn test_dispatch_handler_returns_404_for_unknown_path() {
5163 let registry = HttpRouteRegistry::new();
5164 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5166 let port = listener.local_addr().unwrap().port();
5167 tokio::spawn(run_axum_server(
5168 listener,
5169 registry,
5170 2 * 1024 * 1024,
5171 10 * 1024 * 1024,
5172 Arc::new(tokio::sync::Semaphore::new(1024)),
5173 test_rt(),
5174 "test-route".into(),
5175 ));
5176
5177 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
5179
5180 let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
5181 .await
5182 .unwrap();
5183 assert_eq!(resp.status().as_u16(), 404);
5184 }
5185
5186 #[tokio::test]
5191 async fn test_http_consumer_start_registers_path() {
5192 use camel_component_api::ConsumerContext;
5193
5194 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5196 let port = listener.local_addr().unwrap().port();
5197 drop(listener); let consumer_cfg = HttpServerConfig {
5200 scheme: "http".to_string(),
5201 host: "127.0.0.1".to_string(),
5202 port,
5203 path: "/ping".to_string(),
5204 max_request_body: 2 * 1024 * 1024,
5205 max_response_body: 10 * 1024 * 1024,
5206 max_inflight_requests: 1024,
5207 method: None,
5208 tls_config: None,
5209 };
5210 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5211
5212 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
5213 let token = tokio_util::sync::CancellationToken::new();
5214 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5215
5216 tokio::spawn(async move {
5217 consumer.start(ctx).await.unwrap();
5218 });
5219
5220 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5221
5222 let client = reqwest::Client::new();
5223 let resp_future = client
5224 .post(format!("http://127.0.0.1:{port}/ping"))
5225 .body("hello world")
5226 .send();
5227
5228 let (http_result, _) = tokio::join!(resp_future, async {
5229 if let Some(mut envelope) = rx.recv().await {
5230 envelope.exchange.input.set_header(
5232 "CamelHttpResponseCode",
5233 serde_json::Value::Number(201.into()),
5234 );
5235 if let Some(reply_tx) = envelope.reply_tx {
5236 let _ = reply_tx.send(Ok(envelope.exchange));
5237 }
5238 }
5239 });
5240
5241 let resp = http_result.unwrap();
5242 assert_eq!(resp.status().as_u16(), 201);
5243
5244 token.cancel();
5245 }
5246
5247 #[test]
5251 fn test_envelope_channel_capacity_follows_max_inflight() {
5252 assert_eq!(envelope_channel_capacity(0), 1);
5253 assert_eq!(envelope_channel_capacity(1), 1);
5254 assert_eq!(envelope_channel_capacity(7), 7);
5255 assert_eq!(envelope_channel_capacity(64), 64);
5256 assert_eq!(envelope_channel_capacity(1024), 1024);
5257 }
5258
5259 #[tokio::test]
5263 async fn test_http_consumer_start_with_zero_max_inflight_rejects_503() {
5264 use camel_component_api::ConsumerContext;
5265
5266 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5267 let port = listener.local_addr().unwrap().port();
5268 drop(listener);
5269
5270 let consumer_cfg = HttpServerConfig {
5271 scheme: "http".to_string(),
5272 host: "127.0.0.1".to_string(),
5273 port,
5274 path: "/ping".to_string(),
5275 max_request_body: 2 * 1024 * 1024,
5276 max_response_body: 10 * 1024 * 1024,
5277 max_inflight_requests: 0,
5278 method: None,
5279 tls_config: None,
5280 };
5281 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5282
5283 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
5284 let token = tokio_util::sync::CancellationToken::new();
5285 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5286
5287 let start_handle = tokio::spawn(async move {
5288 consumer.start(ctx).await.unwrap();
5289 });
5290
5291 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5292
5293 let client = reqwest::Client::new();
5294 let resp = client
5295 .post(format!("http://127.0.0.1:{port}/ping"))
5296 .body("hello world")
5297 .send()
5298 .await
5299 .unwrap();
5300 assert_eq!(resp.status().as_u16(), 503);
5301
5302 token.cancel();
5303 let _ = start_handle.await;
5304 }
5305
5306 #[test]
5309 fn test_http_consumer_startup_mode_is_explicit() {
5310 use camel_component_api::ConsumerStartupMode;
5311 let consumer_cfg = HttpServerConfig {
5312 scheme: "http".to_string(),
5313 host: "127.0.0.1".to_string(),
5314 port: 0,
5315 path: "/x".to_string(),
5316 max_request_body: 2 * 1024 * 1024,
5317 max_response_body: 10 * 1024 * 1024,
5318 max_inflight_requests: 1024,
5319 method: None,
5320 tls_config: None,
5321 };
5322 let consumer = HttpConsumer::new(consumer_cfg, test_rt());
5323 assert_eq!(
5324 consumer.startup_mode(),
5325 ConsumerStartupMode::Explicit,
5326 "HttpConsumer must opt into Explicit startup"
5327 );
5328 }
5329
5330 #[allow(clippy::await_holding_lock)]
5336 #[tokio::test]
5337 async fn test_http_consumer_emits_mark_ready_after_bind() {
5338 use camel_component_api::{ConsumerContext, StartupSignal};
5339
5340 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5341
5342 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5343 let port = listener.local_addr().unwrap().port();
5344 drop(listener);
5345
5346 let consumer_cfg = HttpServerConfig {
5347 scheme: "http".to_string(),
5348 host: "127.0.0.1".to_string(),
5349 port,
5350 path: "/ready-probe".to_string(),
5351 max_request_body: 2 * 1024 * 1024,
5352 max_response_body: 10 * 1024 * 1024,
5353 max_inflight_requests: 1024,
5354 method: None,
5355 tls_config: None,
5356 };
5357 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5358
5359 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
5360 let token = tokio_util::sync::CancellationToken::new();
5361 let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
5362
5363 let (signal, startup_rx) = StartupSignal::pair();
5365 let ctx = ctx.with_startup(signal);
5366
5367 tokio::spawn(async move {
5370 let _ = consumer.start(ctx).await;
5371 });
5372
5373 let result =
5378 tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
5379 .await
5380 .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
5381 assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
5382
5383 token.cancel();
5385 }
5386
5387 #[tokio::test]
5388 async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
5389 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5390
5391 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5392 let port = listener.local_addr().unwrap().port();
5393 drop(listener);
5394
5395 let consumer_cfg = HttpServerConfig {
5396 scheme: "http".to_string(),
5397 host: "127.0.0.1".to_string(),
5398 port,
5399 path: "/saturation".to_string(),
5400 max_request_body: 2 * 1024 * 1024,
5401 max_response_body: 10 * 1024 * 1024,
5402 max_inflight_requests: 1,
5403 method: None,
5404 tls_config: None,
5405 };
5406 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5407
5408 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5409 let token = tokio_util::sync::CancellationToken::new();
5410 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5411 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5412 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5413
5414 let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
5415 let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
5416
5417 tokio::spawn(async move {
5418 let mut first_seen_tx = Some(first_seen_tx);
5419 let mut unblock_first_rx = Some(unblock_first_rx);
5420
5421 while let Some(envelope) = rx.recv().await {
5422 if let Some(tx) = first_seen_tx.take() {
5423 let _ = tx.send(());
5424 if let Some(rx_unblock) = unblock_first_rx.take() {
5425 let _ = rx_unblock.await;
5426 }
5427 }
5428
5429 if let Some(reply_tx) = envelope.reply_tx {
5430 let _ = reply_tx.send(Ok(envelope.exchange));
5431 }
5432 }
5433 });
5434
5435 let client = reqwest::Client::new();
5436 let first_req = {
5437 let client = client.clone();
5438 async move {
5439 client
5440 .get(format!("http://127.0.0.1:{port}/saturation"))
5441 .send()
5442 .await
5443 .unwrap()
5444 }
5445 };
5446
5447 let first_handle = tokio::spawn(first_req);
5448 first_seen_rx.await.unwrap();
5449
5450 let second_resp = client
5451 .get(format!("http://127.0.0.1:{port}/saturation"))
5452 .send()
5453 .await
5454 .unwrap();
5455
5456 assert_eq!(second_resp.status().as_u16(), 503);
5457
5458 let _ = unblock_first_tx.send(());
5459 let first_resp = first_handle.await.unwrap();
5460 assert_eq!(first_resp.status().as_u16(), 200);
5461
5462 token.cancel();
5463 }
5464
5465 #[tokio::test]
5469 async fn test_http_consumer_chunked_body_is_capped() {
5470 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5471
5472 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5473 let port = listener.local_addr().unwrap().port();
5474 drop(listener);
5475
5476 let consumer_cfg = HttpServerConfig {
5477 scheme: "http".to_string(),
5478 host: "127.0.0.1".to_string(),
5479 port,
5480 path: "/chunked-cap".to_string(),
5481 max_request_body: 1024, max_response_body: 10 * 1024 * 1024,
5483 max_inflight_requests: 16,
5484 method: None,
5485 tls_config: None,
5486 };
5487 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5488
5489 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5490 let token = tokio_util::sync::CancellationToken::new();
5491 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5492 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5493 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5494
5495 let chunks: Vec<Result<bytes::Bytes, std::io::Error>> = (0..8)
5497 .map(|_| Ok(bytes::Bytes::from(vec![b'A'; 512])))
5498 .collect();
5499 let stream_body = reqwest::Body::wrap_stream(futures::stream::iter(chunks));
5500
5501 let client = reqwest::Client::new();
5502 let send_fut = client
5503 .post(format!("http://127.0.0.1:{port}/chunked-cap"))
5504 .body(stream_body)
5505 .send();
5506
5507 let (http_result, _) = tokio::join!(send_fut, async {
5508 if let Some(mut envelope) = rx.recv().await {
5509 let materialized = envelope
5511 .exchange
5512 .input
5513 .body
5514 .clone()
5515 .into_bytes(64 * 1024)
5516 .await;
5517 assert!(
5518 materialized.is_err(),
5519 "materializing a 4 KiB chunked body under a 1 KiB cap must fail"
5520 );
5521 let err = materialized.unwrap_err().to_string();
5522 assert!(
5523 err.contains("limit") || err.contains("exceeds"),
5524 "error should mention the limit: {err}"
5525 );
5526 if let Some(reply_tx) = envelope.reply_tx {
5527 envelope.exchange.input.body =
5528 camel_component_api::Body::Text("handled".to_string());
5529 let _ = reply_tx.send(Ok(envelope.exchange));
5530 }
5531 }
5532 });
5533
5534 let resp = http_result.unwrap();
5535 assert_eq!(resp.status().as_u16(), 200);
5536
5537 token.cancel();
5538 }
5539
5540 #[tokio::test]
5541 #[allow(clippy::await_holding_lock)]
5542 async fn test_http_consumer_enforces_max_response_body_for_bytes() {
5543 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5544
5545 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5546
5547 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5548 let port = listener.local_addr().unwrap().port();
5549 drop(listener);
5550
5551 let consumer_cfg = HttpServerConfig {
5552 scheme: "http".to_string(),
5553 host: "127.0.0.1".to_string(),
5554 port,
5555 path: "/limit-bytes".to_string(),
5556 max_request_body: 2 * 1024 * 1024,
5557 max_response_body: 16,
5558 max_inflight_requests: 1024,
5559 method: None,
5560 tls_config: None,
5561 };
5562 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5563
5564 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5565 let token = tokio_util::sync::CancellationToken::new();
5566 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5567 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5568 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5569
5570 let client = reqwest::Client::new();
5571 let send_fut = client
5572 .get(format!("http://127.0.0.1:{port}/limit-bytes"))
5573 .send();
5574
5575 let (http_result, _) = tokio::join!(send_fut, async {
5576 if let Some(mut envelope) = rx.recv().await {
5577 envelope.exchange.input.body =
5578 camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
5579 if let Some(reply_tx) = envelope.reply_tx {
5580 let _ = reply_tx.send(Ok(envelope.exchange));
5581 }
5582 }
5583 });
5584
5585 let resp = http_result.unwrap();
5586 assert_eq!(resp.status().as_u16(), 500);
5587 let body = resp.text().await.unwrap();
5588 assert_eq!(body, "Response body exceeds configured limit");
5589 token.cancel();
5590 }
5591
5592 #[tokio::test]
5593 #[allow(clippy::await_holding_lock)]
5594 async fn test_http_consumer_enforces_max_response_body_for_json() {
5595 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5596
5597 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5598
5599 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5600 let port = listener.local_addr().unwrap().port();
5601 drop(listener);
5602
5603 let consumer_cfg = HttpServerConfig {
5604 scheme: "http".to_string(),
5605 host: "127.0.0.1".to_string(),
5606 port,
5607 path: "/limit-json".to_string(),
5608 max_request_body: 2 * 1024 * 1024,
5609 max_response_body: 16,
5610 max_inflight_requests: 1024,
5611 method: None,
5612 tls_config: None,
5613 };
5614 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5615
5616 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5617 let token = tokio_util::sync::CancellationToken::new();
5618 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5619 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5620 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5621
5622 let client = reqwest::Client::new();
5623 let send_fut = client
5624 .get(format!("http://127.0.0.1:{port}/limit-json"))
5625 .send();
5626
5627 let (http_result, _) = tokio::join!(send_fut, async {
5628 if let Some(mut envelope) = rx.recv().await {
5629 envelope.exchange.input.body = camel_component_api::Body::Json(
5630 serde_json::json!({"message":"this response is bigger than sixteen"}),
5631 );
5632 if let Some(reply_tx) = envelope.reply_tx {
5633 let _ = reply_tx.send(Ok(envelope.exchange));
5634 }
5635 }
5636 });
5637
5638 let resp = http_result.unwrap();
5639 assert_eq!(resp.status().as_u16(), 500);
5640 let body = resp.text().await.unwrap();
5641 assert_eq!(body, "Response body exceeds configured limit");
5642 token.cancel();
5643 }
5644
5645 #[tokio::test]
5646 #[allow(clippy::await_holding_lock)]
5647 async fn test_http_consumer_enforces_max_response_body_for_xml() {
5648 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5649
5650 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5651
5652 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5653 let port = listener.local_addr().unwrap().port();
5654 drop(listener);
5655
5656 let consumer_cfg = HttpServerConfig {
5657 scheme: "http".to_string(),
5658 host: "127.0.0.1".to_string(),
5659 port,
5660 path: "/limit-xml".to_string(),
5661 max_request_body: 2 * 1024 * 1024,
5662 max_response_body: 16,
5663 max_inflight_requests: 1024,
5664 method: None,
5665 tls_config: None,
5666 };
5667 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5668
5669 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5670 let token = tokio_util::sync::CancellationToken::new();
5671 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5672 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5673 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5674
5675 let client = reqwest::Client::new();
5676 let send_fut = client
5677 .get(format!("http://127.0.0.1:{port}/limit-xml"))
5678 .send();
5679
5680 let (http_result, _) = tokio::join!(send_fut, async {
5681 if let Some(mut envelope) = rx.recv().await {
5682 envelope.exchange.input.body = camel_component_api::Body::Xml(
5683 "<root><value>way-too-large</value></root>".into(),
5684 );
5685 if let Some(reply_tx) = envelope.reply_tx {
5686 let _ = reply_tx.send(Ok(envelope.exchange));
5687 }
5688 }
5689 });
5690
5691 let resp = http_result.unwrap();
5692 assert_eq!(resp.status().as_u16(), 500);
5693 let body = resp.text().await.unwrap();
5694 assert_eq!(body, "Response body exceeds configured limit");
5695 token.cancel();
5696 }
5697
5698 #[tokio::test]
5699 #[allow(clippy::await_holding_lock)]
5700 async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
5701 use camel_component_api::{
5702 CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
5703 };
5704 use futures::stream;
5705
5706 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5707
5708 let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
5709 let port = listener.local_addr().unwrap().port();
5710 drop(listener);
5711
5712 let consumer_cfg = HttpServerConfig {
5713 scheme: "http".to_string(),
5714 host: "0.0.0.0".to_string(),
5715 port,
5716 path: "/limit-stream".to_string(),
5717 max_request_body: 2 * 1024 * 1024,
5718 max_response_body: 16,
5719 max_inflight_requests: 1024,
5720 method: None,
5721 tls_config: None,
5722 };
5723 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5724
5725 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5726 let token = tokio_util::sync::CancellationToken::new();
5727 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5728 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5729 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5730
5731 let client = reqwest::Client::new();
5732 let send_fut = client
5733 .get(format!("http://127.0.0.1:{port}/limit-stream"))
5734 .send();
5735
5736 let (http_result, _) = tokio::join!(send_fut, async {
5737 if let Some(mut envelope) = rx.recv().await {
5738 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
5739 vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
5740 let stream = Box::pin(stream::iter(chunks));
5741 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
5742 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
5743 metadata: StreamMetadata {
5744 size_hint: Some(32),
5745 content_type: Some("application/octet-stream".into()),
5746 origin: None,
5747 },
5748 });
5749 if let Some(reply_tx) = envelope.reply_tx {
5750 let _ = reply_tx.send(Ok(envelope.exchange));
5751 }
5752 }
5753 });
5754
5755 let resp = http_result.unwrap();
5756 assert_eq!(resp.status().as_u16(), 200);
5757 let body = resp.bytes().await.unwrap();
5758 assert_eq!(body.len(), 32);
5759 token.cancel();
5760 }
5761
5762 #[tokio::test]
5767 #[allow(clippy::await_holding_lock)]
5768 async fn test_integration_single_consumer_round_trip() {
5769 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5770
5771 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5775
5776 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5778 let port = listener.local_addr().unwrap().port();
5779 drop(listener); let component = HttpComponent::new();
5782 let endpoint_ctx = NoOpComponentContext;
5783 let endpoint = component
5784 .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
5785 .unwrap();
5786 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5787
5788 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5789 let token = tokio_util::sync::CancellationToken::new();
5790 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5791
5792 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5793 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5794
5795 let client = reqwest::Client::new();
5796 let send_fut = client
5797 .post(format!("http://127.0.0.1:{port}/echo"))
5798 .header("Content-Type", "text/plain")
5799 .body("ping")
5800 .send();
5801
5802 let (http_result, _) = tokio::join!(send_fut, async {
5803 if let Some(mut envelope) = rx.recv().await {
5804 assert_eq!(
5805 envelope.exchange.input.header("CamelHttpMethod"),
5806 Some(&serde_json::Value::String("POST".into()))
5807 );
5808 assert_eq!(
5809 envelope.exchange.input.header("CamelHttpPath"),
5810 Some(&serde_json::Value::String("/echo".into()))
5811 );
5812 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
5813 if let Some(reply_tx) = envelope.reply_tx {
5814 let _ = reply_tx.send(Ok(envelope.exchange));
5815 }
5816 }
5817 });
5818
5819 let resp = http_result.unwrap();
5820 assert_eq!(resp.status().as_u16(), 200);
5821 let body = resp.text().await.unwrap();
5822 assert_eq!(body, "pong");
5823
5824 token.cancel();
5825 }
5826
5827 #[tokio::test]
5828 #[allow(clippy::await_holding_lock)]
5829 async fn test_integration_two_consumers_shared_port() {
5830 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5831
5832 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5833
5834 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5836 let port = listener.local_addr().unwrap().port();
5837 drop(listener);
5838
5839 let component = HttpComponent::new();
5840 let endpoint_ctx = NoOpComponentContext;
5841
5842 let endpoint_a = component
5844 .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
5845 .unwrap();
5846 let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
5847
5848 let endpoint_b = component
5850 .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
5851 .unwrap();
5852 let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
5853
5854 let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5855 let token_a = tokio_util::sync::CancellationToken::new();
5856 let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
5857
5858 let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5859 let token_b = tokio_util::sync::CancellationToken::new();
5860 let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
5861
5862 tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
5863 tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
5864 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5865
5866 let client = reqwest::Client::new();
5867
5868 let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
5870 let (resp_hello, _) = tokio::join!(fut_hello, async {
5871 if let Some(mut envelope) = rx_a.recv().await {
5872 envelope.exchange.input.body =
5873 camel_component_api::Body::Text("hello-response".to_string());
5874 if let Some(reply_tx) = envelope.reply_tx {
5875 let _ = reply_tx.send(Ok(envelope.exchange));
5876 }
5877 }
5878 });
5879
5880 let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
5882 let (resp_world, _) = tokio::join!(fut_world, async {
5883 if let Some(mut envelope) = rx_b.recv().await {
5884 envelope.exchange.input.body =
5885 camel_component_api::Body::Text("world-response".to_string());
5886 if let Some(reply_tx) = envelope.reply_tx {
5887 let _ = reply_tx.send(Ok(envelope.exchange));
5888 }
5889 }
5890 });
5891
5892 let body_a = resp_hello.unwrap().text().await.unwrap();
5893 let body_b = resp_world.unwrap().text().await.unwrap();
5894
5895 assert_eq!(body_a, "hello-response");
5896 assert_eq!(body_b, "world-response");
5897
5898 token_a.cancel();
5899 token_b.cancel();
5900 }
5901
5902 #[tokio::test]
5903 #[allow(clippy::await_holding_lock)]
5904 async fn test_integration_unregistered_path_returns_404() {
5905 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5906
5907 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5908
5909 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5911 let port = listener.local_addr().unwrap().port();
5912 drop(listener);
5913
5914 let component = HttpComponent::new();
5915 let endpoint_ctx = NoOpComponentContext;
5916 let endpoint = component
5917 .create_endpoint(
5918 &format!("http://127.0.0.1:{port}/registered"),
5919 &endpoint_ctx,
5920 )
5921 .unwrap();
5922 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5923
5924 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5925 let token = tokio_util::sync::CancellationToken::new();
5926 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5927
5928 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5929
5930 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
5932 loop {
5933 if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
5934 .await
5935 .is_ok()
5936 {
5937 break;
5938 }
5939 if std::time::Instant::now() >= deadline {
5940 panic!("HTTP server did not start within 5s on port {port}");
5941 }
5942 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
5943 }
5944
5945 let client = reqwest::Client::new();
5946 let resp = client
5947 .get(format!("http://127.0.0.1:{port}/not-there"))
5948 .send()
5949 .await
5950 .unwrap();
5951 assert_eq!(resp.status().as_u16(), 404);
5952
5953 token.cancel();
5954 }
5955
5956 #[test]
5957 fn test_http_consumer_declares_concurrent() {
5958 use camel_component_api::ConcurrencyModel;
5959
5960 let config = HttpServerConfig {
5961 scheme: "http".to_string(),
5962 host: "127.0.0.1".to_string(),
5963 port: 19999,
5964 path: "/test".to_string(),
5965 max_request_body: 2 * 1024 * 1024,
5966 max_response_body: 10 * 1024 * 1024,
5967 max_inflight_requests: 1024,
5968 method: None,
5969 tls_config: None,
5970 };
5971 let consumer = HttpConsumer::new(config, test_rt());
5972 assert_eq!(
5973 consumer.concurrency_model(),
5974 ConcurrencyModel::Concurrent { max: None }
5975 );
5976 }
5977
5978 #[test]
5979 fn server_config_parses_tls_cert_and_key() {
5980 let cfg = HttpServerConfig::from_uri(
5981 "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
5982 )
5983 .unwrap();
5984 assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
5985 assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
5986 }
5987
5988 #[test]
5989 fn server_config_no_tls_when_params_absent() {
5990 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
5991 assert!(cfg.tls_config.is_none());
5992 }
5993
5994 #[tokio::test]
5999 async fn test_http_reply_body_stream_variant_exists() {
6000 use bytes::Bytes;
6001 use camel_component_api::CamelError;
6002 use futures::stream;
6003
6004 let chunks: Vec<Result<Bytes, CamelError>> =
6005 vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
6006 let stream = Box::pin(stream::iter(chunks));
6007 let reply_body = HttpReplyBody::Stream(stream);
6008 match reply_body {
6010 HttpReplyBody::Stream(_) => {}
6011 HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
6012 }
6013 }
6014
6015 #[cfg(feature = "otel")]
6020 mod otel_tests {
6021 use super::*;
6022 use camel_component_api::Message;
6023 use tower::ServiceExt;
6024
6025 #[tokio::test]
6026 async fn test_producer_injects_traceparent_header() {
6027 let (url, _handle) = start_test_server_with_header_capture().await;
6028 let ctx = test_producer_ctx();
6029
6030 let component = HttpComponent::new();
6031 let endpoint_ctx = NoOpComponentContext;
6032 let endpoint = component
6033 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
6034 .unwrap();
6035 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6036
6037 let mut exchange = Exchange::new(Message::default());
6039 let mut headers = std::collections::HashMap::new();
6040 headers.insert(
6041 "traceparent".to_string(),
6042 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
6043 );
6044 camel_otel::extract_into_exchange(&mut exchange, &headers);
6045
6046 let result = producer.oneshot(exchange).await.unwrap();
6047
6048 let status = result
6050 .input
6051 .header("CamelHttpResponseCode")
6052 .and_then(|v| v.as_u64())
6053 .unwrap();
6054 assert_eq!(status, 200);
6055
6056 let traceparent = result.input.header("X-Received-Traceparent");
6058 assert!(
6059 traceparent.is_some(),
6060 "traceparent header should have been sent"
6061 );
6062
6063 let traceparent_str = traceparent.unwrap().as_str().unwrap();
6064 let parts: Vec<&str> = traceparent_str.split('-').collect();
6066 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
6067 assert_eq!(parts[0], "00", "version should be 00");
6068 assert_eq!(
6069 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
6070 "trace-id should match"
6071 );
6072 assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
6073 assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
6074 }
6075
6076 #[tokio::test]
6077 async fn test_consumer_extracts_traceparent_header() {
6078 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6079
6080 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6082 let port = listener.local_addr().unwrap().port();
6083 drop(listener);
6084
6085 let component = HttpComponent::new();
6086 let endpoint_ctx = NoOpComponentContext;
6087 let endpoint = component
6088 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
6089 .unwrap();
6090 let mut consumer = endpoint.create_consumer(rt()).unwrap();
6091
6092 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6093 let token = tokio_util::sync::CancellationToken::new();
6094 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6095
6096 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6097 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6098
6099 let client = reqwest::Client::new();
6101 let send_fut = client
6102 .post(format!("http://127.0.0.1:{port}/trace"))
6103 .header(
6104 "traceparent",
6105 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
6106 )
6107 .body("test")
6108 .send();
6109
6110 let (http_result, _) = tokio::join!(send_fut, async {
6111 if let Some(envelope) = rx.recv().await {
6112 let mut injected_headers = std::collections::HashMap::new();
6115 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
6116
6117 assert!(
6118 injected_headers.contains_key("traceparent"),
6119 "Exchange should have traceparent after extraction"
6120 );
6121
6122 let traceparent = injected_headers.get("traceparent").unwrap();
6123 let parts: Vec<&str> = traceparent.split('-').collect();
6124 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
6125 assert_eq!(
6126 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
6127 "Trace ID should match the original traceparent header"
6128 );
6129
6130 if let Some(reply_tx) = envelope.reply_tx {
6131 let _ = reply_tx.send(Ok(envelope.exchange));
6132 }
6133 }
6134 });
6135
6136 let resp = http_result.unwrap();
6137 assert_eq!(resp.status().as_u16(), 200);
6138
6139 token.cancel();
6140 }
6141
6142 #[tokio::test]
6143 async fn test_consumer_extracts_mixed_case_traceparent_header() {
6144 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6145
6146 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6148 let port = listener.local_addr().unwrap().port();
6149 drop(listener);
6150
6151 let component = HttpComponent::new();
6152 let endpoint_ctx = NoOpComponentContext;
6153 let endpoint = component
6154 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
6155 .unwrap();
6156 let mut consumer = endpoint.create_consumer(rt()).unwrap();
6157
6158 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6159 let token = tokio_util::sync::CancellationToken::new();
6160 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6161
6162 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6163 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6164
6165 let client = reqwest::Client::new();
6167 let send_fut = client
6168 .post(format!("http://127.0.0.1:{port}/trace"))
6169 .header(
6170 "TraceParent",
6171 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
6172 )
6173 .body("test")
6174 .send();
6175
6176 let (http_result, _) = tokio::join!(send_fut, async {
6177 if let Some(envelope) = rx.recv().await {
6178 let mut injected_headers = HashMap::new();
6181 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
6182
6183 assert!(
6184 injected_headers.contains_key("traceparent"),
6185 "Exchange should have traceparent after extraction from mixed-case header"
6186 );
6187
6188 let traceparent = injected_headers.get("traceparent").unwrap();
6189 let parts: Vec<&str> = traceparent.split('-').collect();
6190 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
6191 assert_eq!(
6192 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
6193 "Trace ID should match the original mixed-case TraceParent header"
6194 );
6195
6196 if let Some(reply_tx) = envelope.reply_tx {
6197 let _ = reply_tx.send(Ok(envelope.exchange));
6198 }
6199 }
6200 });
6201
6202 let resp = http_result.unwrap();
6203 assert_eq!(resp.status().as_u16(), 200);
6204
6205 token.cancel();
6206 }
6207
6208 #[tokio::test]
6209 async fn test_producer_no_trace_context_no_crash() {
6210 let (url, _handle) = start_test_server().await;
6211 let ctx = test_producer_ctx();
6212
6213 let component = HttpComponent::new();
6214 let endpoint_ctx = NoOpComponentContext;
6215 let endpoint = component
6216 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
6217 .unwrap();
6218 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
6219
6220 let exchange = Exchange::new(Message::default());
6222
6223 let result = producer.oneshot(exchange).await.unwrap();
6225
6226 let status = result
6228 .input
6229 .header("CamelHttpResponseCode")
6230 .and_then(|v| v.as_u64())
6231 .unwrap();
6232 assert_eq!(status, 200);
6233 }
6234
6235 async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
6237 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6238 let addr = listener.local_addr().unwrap();
6239 let url = format!("http://127.0.0.1:{}", addr.port());
6240
6241 let handle = tokio::spawn(async move {
6242 loop {
6243 if let Ok((mut stream, _)) = listener.accept().await {
6244 tokio::spawn(async move {
6245 use tokio::io::{AsyncReadExt, AsyncWriteExt};
6246 let mut buf = vec![0u8; 8192];
6247 let n = stream.read(&mut buf).await.unwrap_or(0);
6248 let request = String::from_utf8_lossy(&buf[..n]).to_string();
6249
6250 let traceparent = request
6252 .lines()
6253 .find(|line| line.to_lowercase().starts_with("traceparent:"))
6254 .map(|line| {
6255 line.split(':')
6256 .nth(1)
6257 .map(|s| s.trim().to_string())
6258 .unwrap_or_default()
6259 })
6260 .unwrap_or_default();
6261
6262 let body =
6263 format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
6264 let response = format!(
6265 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
6266 body.len(),
6267 traceparent,
6268 body
6269 );
6270 let _ = stream.write_all(response.as_bytes()).await;
6271 });
6272 }
6273 }
6274 });
6275
6276 (url, handle)
6277 }
6278 }
6279
6280 #[tokio::test]
6289 async fn test_request_body_arrives_as_stream() {
6290 use camel_component_api::Body;
6291 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
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 component = HttpComponent::new();
6298 let endpoint_ctx = NoOpComponentContext;
6299 let endpoint = component
6300 .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
6301 .unwrap();
6302 let mut consumer = endpoint.create_consumer(rt()).unwrap();
6303
6304 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6305 let token = tokio_util::sync::CancellationToken::new();
6306 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6307
6308 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6309 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6310
6311 let client = reqwest::Client::new();
6312 let send_fut = client
6313 .post(format!("http://127.0.0.1:{port}/upload"))
6314 .body("hello streaming world")
6315 .send();
6316
6317 let (http_result, _) = tokio::join!(send_fut, async {
6318 if let Some(mut envelope) = rx.recv().await {
6319 assert!(
6321 matches!(envelope.exchange.input.body, Body::Stream(_)),
6322 "expected Body::Stream, got discriminant {:?}",
6323 std::mem::discriminant(&envelope.exchange.input.body)
6324 );
6325 let bytes = envelope
6327 .exchange
6328 .input
6329 .body
6330 .into_bytes(1024 * 1024)
6331 .await
6332 .unwrap();
6333 assert_eq!(&bytes[..], b"hello streaming world");
6334
6335 envelope.exchange.input.body = camel_component_api::Body::Empty;
6336 if let Some(reply_tx) = envelope.reply_tx {
6337 let _ = reply_tx.send(Ok(envelope.exchange));
6338 }
6339 }
6340 });
6341
6342 let resp = http_result.unwrap();
6343 assert_eq!(resp.status().as_u16(), 200);
6344
6345 token.cancel();
6346 }
6347
6348 #[tokio::test]
6353 async fn test_streaming_response_chunked() {
6354 use bytes::Bytes;
6355 use camel_component_api::Body;
6356 use camel_component_api::CamelError;
6357 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6358 use camel_component_api::{StreamBody, StreamMetadata};
6359 use futures::stream;
6360 use std::sync::Arc;
6361 use tokio::sync::Mutex;
6362
6363 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6364 let port = listener.local_addr().unwrap().port();
6365 drop(listener);
6366
6367 let component = HttpComponent::new();
6368 let endpoint_ctx = NoOpComponentContext;
6369 let endpoint = component
6370 .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
6371 .unwrap();
6372 let mut consumer = endpoint.create_consumer(rt()).unwrap();
6373
6374 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6375 let token = tokio_util::sync::CancellationToken::new();
6376 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6377
6378 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6379 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6380
6381 let client = reqwest::Client::new();
6382 let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
6383
6384 let (http_result, _) = tokio::join!(send_fut, async {
6385 if let Some(mut envelope) = rx.recv().await {
6386 let chunks: Vec<Result<Bytes, CamelError>> =
6388 vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
6389 let stream = Box::pin(stream::iter(chunks));
6390 envelope.exchange.input.body = Body::Stream(StreamBody {
6391 stream: Arc::new(Mutex::new(Some(stream))),
6392 metadata: StreamMetadata::default(),
6393 });
6394 if let Some(reply_tx) = envelope.reply_tx {
6395 let _ = reply_tx.send(Ok(envelope.exchange));
6396 }
6397 }
6398 });
6399
6400 let resp = http_result.unwrap();
6401 assert_eq!(resp.status().as_u16(), 200);
6402 let body = resp.text().await.unwrap();
6403 assert_eq!(body, "chunk1chunk2");
6404
6405 token.cancel();
6406 }
6407
6408 #[tokio::test]
6413 async fn test_413_when_content_length_exceeds_limit() {
6414 use camel_component_api::ConsumerContext;
6415
6416 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6417 let port = listener.local_addr().unwrap().port();
6418 drop(listener);
6419
6420 let component = HttpComponent::new();
6422 let endpoint_ctx = NoOpComponentContext;
6423 let endpoint = component
6424 .create_endpoint(
6425 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
6426 &endpoint_ctx,
6427 )
6428 .unwrap();
6429 let mut consumer = endpoint.create_consumer(rt()).unwrap();
6430
6431 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6432 let token = tokio_util::sync::CancellationToken::new();
6433 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6434
6435 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6436 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6437
6438 let client = reqwest::Client::new();
6439 let resp = client
6440 .post(format!("http://127.0.0.1:{port}/upload"))
6441 .header("Content-Length", "1000") .body("x".repeat(1000))
6443 .send()
6444 .await
6445 .unwrap();
6446
6447 assert_eq!(resp.status().as_u16(), 413);
6448
6449 token.cancel();
6450 }
6451
6452 #[tokio::test]
6456 async fn test_chunked_upload_without_content_length_bypasses_limit() {
6457 use bytes::Bytes;
6458 use camel_component_api::Body;
6459 use camel_component_api::ConsumerContext;
6460 use futures::stream;
6461
6462 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6463 let port = listener.local_addr().unwrap().port();
6464 drop(listener);
6465
6466 let component = HttpComponent::new();
6468 let endpoint_ctx = NoOpComponentContext;
6469 let endpoint = component
6470 .create_endpoint(
6471 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
6472 &endpoint_ctx,
6473 )
6474 .unwrap();
6475 let mut consumer = endpoint.create_consumer(rt()).unwrap();
6476
6477 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6478 let token = tokio_util::sync::CancellationToken::new();
6479 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6480
6481 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6482 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6483
6484 let client = reqwest::Client::new();
6485
6486 let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
6490 Ok(Bytes::from("y".repeat(50))),
6491 Ok(Bytes::from("y".repeat(50))),
6492 ];
6493 let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
6494 let send_fut = client
6495 .post(format!("http://127.0.0.1:{port}/upload"))
6496 .body(stream_body)
6497 .send();
6498
6499 let consumer_fut = async {
6500 match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
6502 Ok(Some(mut envelope)) => {
6503 assert!(
6504 matches!(envelope.exchange.input.body, Body::Stream(_)),
6505 "expected Body::Stream"
6506 );
6507 envelope.exchange.input.body = camel_component_api::Body::Empty;
6508 if let Some(reply_tx) = envelope.reply_tx {
6509 let _ = reply_tx.send(Ok(envelope.exchange));
6510 }
6511 }
6512 Ok(None) => panic!("consumer channel closed unexpectedly"),
6513 Err(_) => {
6514 }
6517 }
6518 };
6519
6520 let (http_result, _) = tokio::join!(send_fut, consumer_fut);
6521
6522 let resp = http_result.unwrap();
6523 assert_ne!(
6530 resp.status().as_u16(),
6531 413,
6532 "chunked upload has no Content-Length to pre-check"
6533 );
6534 assert_eq!(resp.status().as_u16(), 200);
6535
6536 token.cancel();
6537 }
6538
6539 #[test]
6540 fn test_is_private_ip_ranges() {
6541 use camel_api::is_ssrf_blocked_ip;
6542 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(
6561 &"2001:4860:4860::8888".parse().unwrap()
6562 )); }
6564
6565 #[test]
6566 fn test_title_case_header() {
6567 assert_eq!(title_case_header("content-type"), "Content-Type");
6568 assert_eq!(title_case_header("authorization"), "Authorization");
6569 assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
6570 assert_eq!(title_case_header("host"), "Host");
6571 assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
6572 assert_eq!(title_case_header("single"), "Single");
6573 assert_eq!(title_case_header(""), "");
6574 }
6575
6576 #[test]
6577 fn test_resolve_url_combines_path_and_query_sources() {
6578 let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
6579 let mut exchange = Exchange::new(Message::default());
6580 exchange.input.set_header(
6581 "CamelHttpPath",
6582 serde_json::Value::String("next".to_string()),
6583 );
6584 let url = HttpProducer::resolve_url(&exchange, &cfg);
6585 assert!(url.starts_with("http://example.com/base/next?"));
6586 assert!(url.contains("foo=bar"));
6587
6588 exchange.input.set_header(
6589 "CamelHttpUri",
6590 serde_json::Value::String("http://other.test/root".to_string()),
6591 );
6592 exchange.input.set_header(
6593 "CamelHttpQuery",
6594 serde_json::Value::String("a=1&b=2".to_string()),
6595 );
6596
6597 let override_url = HttpProducer::resolve_url(&exchange, &cfg);
6598 assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
6599 }
6600
6601 fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
6602 let mut exchange = Exchange::new(Message::default());
6603 exchange
6604 .input
6605 .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
6606 exchange.input.set_header(
6607 "CamelHttpQuery",
6608 serde_json::Value::String(query.to_string()),
6609 );
6610 exchange
6611 }
6612
6613 #[test]
6614 fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
6615 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6616 cfg.bridge_endpoint = true;
6617 cfg.query_params
6618 .insert("token".to_string(), "secret".to_string());
6619 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
6620 let url = HttpProducer::resolve_url(&exchange, &cfg);
6621 assert_eq!(url, "http://x/?token=secret");
6622 assert!(!url.contains("/foo"));
6623 assert!(!url.contains("dropme"));
6624 }
6625
6626 #[test]
6627 fn resolve_url_bridge_endpoint_false_merges_path() {
6628 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6629 cfg.bridge_endpoint = false;
6630 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
6631 let url = HttpProducer::resolve_url(&exchange, &cfg);
6632 assert!(url.contains("/foo"), "url should contain /foo: {url}");
6633 assert!(
6634 url.contains("dropme=1"),
6635 "url should contain dropme=1: {url}"
6636 );
6637 }
6638
6639 #[test]
6640 fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
6641 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6642 cfg.bridge_endpoint = true;
6643 let mut exchange = Exchange::new(Message::default());
6644 exchange.input.set_header(
6645 "CamelHttpPath",
6646 serde_json::Value::String("/foo".to_string()),
6647 );
6648 let url = HttpProducer::resolve_url(&exchange, &cfg);
6649 assert_eq!(url, "http://x");
6650 assert!(!url.contains("/foo"));
6651 }
6652
6653 #[test]
6654 fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
6655 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6656 cfg.bridge_endpoint = true;
6657 let mut exchange = Exchange::new(Message::default());
6659 exchange.input.set_header(
6660 "CamelHttpUri",
6661 serde_json::Value::String("http://dest/explicit".to_string()),
6662 );
6663 exchange.input.set_header(
6664 "CamelHttpPath",
6665 serde_json::Value::String("/foo".to_string()),
6666 );
6667 exchange.input.set_header(
6668 "CamelHttpQuery",
6669 serde_json::Value::String("x=1".to_string()),
6670 );
6671 let url = HttpProducer::resolve_url(&exchange, &cfg);
6672 assert_eq!(url, "http://x");
6676 }
6677
6678 #[test]
6679 fn test_http_producer_helpers_status_and_size_boundaries() {
6680 assert!(HttpProducer::is_ok_status(200, (200, 299)));
6681 assert!(HttpProducer::is_ok_status(299, (200, 299)));
6682 assert!(!HttpProducer::is_ok_status(199, (200, 299)));
6683 assert!(!HttpProducer::is_ok_status(300, (200, 299)));
6684
6685 assert!(!exceeds_max_response_body(10, 10));
6686 assert!(exceeds_max_response_body(11, 10));
6687 }
6688
6689 async fn setup_consumer_on_free_port(
6694 path: &str,
6695 ) -> (
6696 u16,
6697 tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
6698 tokio_util::sync::CancellationToken,
6699 ) {
6700 use camel_component_api::ConsumerContext;
6701
6702 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6703 let port = listener.local_addr().unwrap().port();
6704 drop(listener);
6705
6706 let consumer_cfg = HttpServerConfig {
6707 scheme: "http".to_string(),
6708 host: "127.0.0.1".to_string(),
6709 port,
6710 path: path.to_string(),
6711 max_request_body: 2 * 1024 * 1024,
6712 max_response_body: 10 * 1024 * 1024,
6713 max_inflight_requests: 1024,
6714 method: None,
6715 tls_config: None,
6716 };
6717 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6718
6719 let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6720 let token = tokio_util::sync::CancellationToken::new();
6721 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6722
6723 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6724 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6725
6726 (port, rx, token)
6727 }
6728
6729 #[tokio::test]
6730 async fn test_content_type_inferred_for_json_body() {
6731 let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
6732
6733 let client = reqwest::Client::new();
6734 let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
6735
6736 let (http_result, _) = tokio::join!(send_fut, async {
6737 if let Some(mut envelope) = rx.recv().await {
6738 envelope.exchange.input.body =
6739 camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
6740 if let Some(reply_tx) = envelope.reply_tx {
6741 let _ = reply_tx.send(Ok(envelope.exchange));
6742 }
6743 }
6744 });
6745
6746 let resp = http_result.unwrap();
6747 assert_eq!(resp.status().as_u16(), 200);
6748 let ct = resp
6749 .headers()
6750 .get("content-type")
6751 .expect("Content-Type header should be present");
6752 assert_eq!(ct, "application/json");
6753 let body = resp.text().await.unwrap();
6754 assert_eq!(body, r#"{"message":"hello"}"#);
6755
6756 token.cancel();
6757 }
6758
6759 #[tokio::test]
6760 async fn test_content_type_inferred_for_text_body() {
6761 let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
6762
6763 let client = reqwest::Client::new();
6764 let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
6765
6766 let (http_result, _) = tokio::join!(send_fut, async {
6767 if let Some(mut envelope) = rx.recv().await {
6768 envelope.exchange.input.body =
6769 camel_component_api::Body::Text("plain text response".to_string());
6770 if let Some(reply_tx) = envelope.reply_tx {
6771 let _ = reply_tx.send(Ok(envelope.exchange));
6772 }
6773 }
6774 });
6775
6776 let resp = http_result.unwrap();
6777 assert_eq!(resp.status().as_u16(), 200);
6778 let ct = resp
6779 .headers()
6780 .get("content-type")
6781 .expect("Content-Type header should be present");
6782 assert_eq!(ct, "text/plain; charset=utf-8");
6783 let body = resp.text().await.unwrap();
6784 assert_eq!(body, "plain text response");
6785
6786 token.cancel();
6787 }
6788
6789 #[tokio::test]
6790 async fn test_content_type_inferred_for_xml_body() {
6791 let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
6792
6793 let client = reqwest::Client::new();
6794 let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
6795
6796 let (http_result, _) = tokio::join!(send_fut, async {
6797 if let Some(mut envelope) = rx.recv().await {
6798 envelope.exchange.input.body =
6799 camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
6800 if let Some(reply_tx) = envelope.reply_tx {
6801 let _ = reply_tx.send(Ok(envelope.exchange));
6802 }
6803 }
6804 });
6805
6806 let resp = http_result.unwrap();
6807 assert_eq!(resp.status().as_u16(), 200);
6808 let ct = resp
6809 .headers()
6810 .get("content-type")
6811 .expect("Content-Type header should be present");
6812 assert_eq!(ct, "application/xml");
6813 let body = resp.text().await.unwrap();
6814 assert_eq!(body, "<root><item>value</item></root>");
6815
6816 token.cancel();
6817 }
6818
6819 #[tokio::test]
6820 async fn test_no_content_type_for_empty_body() {
6821 let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
6822
6823 let client = reqwest::Client::new();
6824 let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
6825
6826 let (http_result, _) = tokio::join!(send_fut, async {
6827 if let Some(mut envelope) = rx.recv().await {
6828 envelope.exchange.input.body = camel_component_api::Body::Empty;
6829 if let Some(reply_tx) = envelope.reply_tx {
6830 let _ = reply_tx.send(Ok(envelope.exchange));
6831 }
6832 }
6833 });
6834
6835 let resp = http_result.unwrap();
6836 assert_eq!(resp.status().as_u16(), 200);
6837 assert!(
6838 resp.headers().get("content-type").is_none(),
6839 "Empty body should not set Content-Type"
6840 );
6841
6842 token.cancel();
6843 }
6844
6845 #[tokio::test]
6846 async fn test_no_content_type_for_raw_bytes_body() {
6847 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
6848
6849 let client = reqwest::Client::new();
6850 let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
6851
6852 let (http_result, _) = tokio::join!(send_fut, async {
6853 if let Some(mut envelope) = rx.recv().await {
6854 envelope.exchange.input.body =
6855 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
6856 if let Some(reply_tx) = envelope.reply_tx {
6857 let _ = reply_tx.send(Ok(envelope.exchange));
6858 }
6859 }
6860 });
6861
6862 let resp = http_result.unwrap();
6863 assert_eq!(resp.status().as_u16(), 200);
6864 assert!(
6865 resp.headers().get("content-type").is_none(),
6866 "Raw Bytes body should not set Content-Type"
6867 );
6868
6869 token.cancel();
6870 }
6871
6872 #[tokio::test]
6873 async fn test_content_type_from_stream_metadata() {
6874 use camel_component_api::{StreamBody, StreamMetadata};
6875 use futures::stream;
6876
6877 let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
6878
6879 let client = reqwest::Client::new();
6880 let send_fut = client
6881 .get(format!("http://127.0.0.1:{port}/stream-ct"))
6882 .send();
6883
6884 let (http_result, _) = tokio::join!(send_fut, async {
6885 if let Some(mut envelope) = rx.recv().await {
6886 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
6887 vec![Ok(bytes::Bytes::from("audio data"))];
6888 let stream = Box::pin(stream::iter(chunks));
6889 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
6890 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
6891 metadata: StreamMetadata {
6892 size_hint: None,
6893 content_type: Some("audio/mpeg".to_string()),
6894 origin: None,
6895 },
6896 });
6897 if let Some(reply_tx) = envelope.reply_tx {
6898 let _ = reply_tx.send(Ok(envelope.exchange));
6899 }
6900 }
6901 });
6902
6903 let resp = http_result.unwrap();
6904 assert_eq!(resp.status().as_u16(), 200);
6905 let ct = resp
6906 .headers()
6907 .get("content-type")
6908 .expect("Content-Type header should be present");
6909 assert_eq!(ct, "audio/mpeg");
6910 let body = resp.text().await.unwrap();
6911 assert_eq!(body, "audio data");
6912
6913 token.cancel();
6914 }
6915
6916 #[tokio::test]
6917 async fn test_user_content_type_overrides_inferred() {
6918 let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
6919
6920 let client = reqwest::Client::new();
6921 let send_fut = client
6922 .get(format!("http://127.0.0.1:{port}/override-ct"))
6923 .send();
6924
6925 let (http_result, _) = tokio::join!(send_fut, async {
6926 if let Some(mut envelope) = rx.recv().await {
6927 envelope.exchange.input.body =
6928 camel_component_api::Body::Json(serde_json::json!({"ok": true}));
6929 envelope.exchange.input.set_header(
6930 "Content-Type",
6931 serde_json::Value::String("text/html".to_string()),
6932 );
6933 if let Some(reply_tx) = envelope.reply_tx {
6934 let _ = reply_tx.send(Ok(envelope.exchange));
6935 }
6936 }
6937 });
6938
6939 let resp = http_result.unwrap();
6940 assert_eq!(resp.status().as_u16(), 200);
6941 let ct = resp
6942 .headers()
6943 .get("content-type")
6944 .expect("Content-Type header should be present");
6945 assert_eq!(
6946 ct, "text/html",
6947 "User-set Content-Type should take precedence over inferred type"
6948 );
6949
6950 token.cancel();
6951 }
6952
6953 #[tokio::test]
6954 async fn test_user_content_type_with_bytes_body() {
6955 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
6956
6957 let client = reqwest::Client::new();
6958 let send_fut = client
6959 .get(format!("http://127.0.0.1:{port}/bytes-ct"))
6960 .send();
6961
6962 let (http_result, _) = tokio::join!(send_fut, async {
6963 if let Some(mut envelope) = rx.recv().await {
6964 envelope.exchange.input.body =
6965 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
6966 envelope.exchange.input.set_header(
6967 "Content-Type",
6968 serde_json::Value::String("application/json".to_string()),
6969 );
6970 if let Some(reply_tx) = envelope.reply_tx {
6971 let _ = reply_tx.send(Ok(envelope.exchange));
6972 }
6973 }
6974 });
6975
6976 let resp = http_result.unwrap();
6977 assert_eq!(resp.status().as_u16(), 200);
6978 let ct = resp
6979 .headers()
6980 .get("content-type")
6981 .expect("Content-Type header should be present for Bytes body with user header");
6982 assert_eq!(
6983 ct, "application/json",
6984 "User Content-Type should be sent for Bytes body"
6985 );
6986
6987 token.cancel();
6988 }
6989
6990 #[tokio::test]
6995 async fn monitor_task_silent_on_clean_exit() {
6996 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
6997 monitor_axum_task(
6999 handle,
7000 "127.0.0.1:0".to_string(),
7001 noop_rt(),
7002 "test-monitor".into(),
7003 )
7004 .await;
7005 }
7006
7007 #[tokio::test]
7008 async fn monitor_task_handles_panicked_task() {
7009 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
7010 panic!("simulated server crash");
7011 });
7012 monitor_axum_task(
7014 handle,
7015 "127.0.0.1:9999".to_string(),
7016 noop_rt(),
7017 "test-monitor".into(),
7018 )
7019 .await;
7020 }
7021
7022 #[test]
7027 fn http_auth_basic_debug_redacts_password() {
7028 let auth = HttpAuth::Basic {
7029 username: "admin".to_string(),
7030 password: "hunter2".to_string(),
7031 };
7032 let debug = format!("{:?}", auth);
7033 assert!(
7034 !debug.contains("hunter2"),
7035 "password must be redacted: {debug}"
7036 );
7037 assert!(debug.contains("admin"), "username should appear: {debug}");
7038 }
7039
7040 #[test]
7041 fn http_auth_bearer_debug_redacts_token() {
7042 let auth = HttpAuth::Bearer {
7043 token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
7044 };
7045 let debug = format!("{:?}", auth);
7046 assert!(
7047 !debug.contains("eyJhbGci"),
7048 "token must be redacted: {debug}"
7049 );
7050 }
7051
7052 #[test]
7053 fn http_auth_none_debug_shows_variant() {
7054 let debug = format!("{:?}", HttpAuth::None);
7055 assert!(
7056 debug.contains("None"),
7057 "None variant should appear: {debug}"
7058 );
7059 }
7060
7061 #[test]
7062 fn http_endpoint_config_debug_redacts_auth_credentials() {
7063 let config = HttpEndpointConfig::from_uri(
7064 "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
7065 )
7066 .unwrap();
7067 let debug = format!("{:?}", config);
7068 assert!(
7069 !debug.contains("secret123"),
7070 "password must be redacted in HttpEndpointConfig debug: {debug}"
7071 );
7072 }
7073
7074 use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
7079 use tower_http::services::ServeDir;
7080
7081 fn make_test_registry() -> HttpRouteRegistry {
7082 HttpRouteRegistry::new()
7083 }
7084
7085 fn make_test_state(registry: HttpRouteRegistry) -> AppState {
7086 AppState {
7087 registry,
7088 max_request_body: 2 * 1024 * 1024,
7089 max_response_body: 10 * 1024 * 1024,
7090 inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
7091 }
7092 }
7093
7094 #[allow(clippy::await_holding_lock)]
7095 #[tokio::test]
7096 async fn test_static_file_serving_serves_file_contents() {
7097 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7098 ServerRegistry::reset();
7099
7100 let temp_dir =
7102 std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
7103 std::fs::create_dir_all(&temp_dir).unwrap();
7104 std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
7105 std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
7106
7107 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
7108
7109 let registry = make_test_registry();
7110 let serve_dir = ServeDir::new(&canonical_dir)
7111 .precompressed_gzip()
7112 .precompressed_br()
7113 .append_index_html_on_directories(true);
7114
7115 let mount = StaticMount {
7116 mount_path: "/".to_string(),
7117 mode: MountMode::Static,
7118 dir: canonical_dir.clone(),
7119 cache_control: "public, max-age=3600".to_string(),
7120 error_pages: std::collections::HashMap::new(),
7121 serve_dir,
7122 };
7123 registry.register_static_mount(mount).await.unwrap();
7124
7125 let state = make_test_state(registry);
7126
7127 let req = Request::builder()
7129 .uri("/hello.txt")
7130 .body(AxumBody::empty())
7131 .unwrap();
7132 let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
7133 assert_eq!(resp.status(), StatusCode::OK);
7134 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7135 .await
7136 .unwrap();
7137 assert_eq!(&body[..], b"Hello, static world!");
7138
7139 let req = Request::builder()
7141 .uri("/style.css")
7142 .body(AxumBody::empty())
7143 .unwrap();
7144 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
7145 assert_eq!(resp.status(), StatusCode::OK);
7146 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7147 .await
7148 .unwrap();
7149 assert_eq!(&body[..], b"body { color: red; }");
7150
7151 let req = Request::builder()
7153 .uri("/missing.txt")
7154 .body(AxumBody::empty())
7155 .unwrap();
7156 let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
7157 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7158
7159 std::fs::remove_dir_all(&temp_dir).ok();
7161 }
7162
7163 #[allow(clippy::await_holding_lock)]
7164 #[tokio::test]
7165 async fn test_spa_fallback_serves_index_for_unknown_paths() {
7166 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7167 ServerRegistry::reset();
7168
7169 let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
7170 std::fs::create_dir_all(&temp_dir).unwrap();
7171 std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
7172 std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
7173
7174 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
7175
7176 let registry = make_test_registry();
7177 let serve_dir = ServeDir::new(&canonical_dir)
7178 .precompressed_gzip()
7179 .precompressed_br()
7180 .append_index_html_on_directories(true);
7181
7182 let mount = StaticMount {
7183 mount_path: "/".to_string(),
7184 mode: MountMode::Spa,
7185 dir: canonical_dir.clone(),
7186 cache_control: "public, max-age=0".to_string(),
7187 error_pages: std::collections::HashMap::new(),
7188 serve_dir,
7189 };
7190 registry.register_static_mount(mount).await.unwrap();
7192
7193 let state = make_test_state(registry);
7194
7195 let req = Request::builder()
7197 .method("GET")
7198 .uri("/dashboard")
7199 .header("Accept", "text/html")
7200 .body(AxumBody::empty())
7201 .unwrap();
7202 let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
7203 assert_eq!(resp.status(), StatusCode::OK);
7204 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7205 .await
7206 .unwrap();
7207 assert_eq!(&body[..], b"<h1>SPA App</h1>");
7208
7209 let req = Request::builder()
7211 .method("GET")
7212 .uri("/app.js")
7213 .body(AxumBody::empty())
7214 .unwrap();
7215 let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
7216 assert_eq!(resp.status(), StatusCode::OK);
7217 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7218 .await
7219 .unwrap();
7220 assert_eq!(&body[..], b"console.log('app')");
7221
7222 let req = Request::builder()
7224 .method("GET")
7225 .uri("/api/data")
7226 .header("Accept", "application/json")
7227 .body(AxumBody::empty())
7228 .unwrap();
7229 let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
7230 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7231
7232 let req = Request::builder()
7234 .method("GET")
7235 .uri("/style.css")
7236 .header("Accept", "text/html")
7237 .body(AxumBody::empty())
7238 .unwrap();
7239 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
7240 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7241
7242 std::fs::remove_dir_all(&temp_dir).ok();
7244 }
7245
7246 #[allow(clippy::await_holding_lock)]
7253 async fn run_conditional_get_returns_304(mode: MountMode) {
7254 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7255 ServerRegistry::reset();
7256
7257 let temp_dir = std::env::temp_dir().join(format!(
7258 "http_cond_get_{}_{}",
7259 if mode == MountMode::Spa {
7260 "spa"
7261 } else {
7262 "static"
7263 },
7264 std::process::id()
7265 ));
7266 std::fs::create_dir_all(&temp_dir).unwrap();
7267 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
7268
7269 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
7270
7271 let registry = make_test_registry();
7272 let serve_dir = ServeDir::new(&canonical_dir)
7273 .precompressed_gzip()
7274 .precompressed_br()
7275 .append_index_html_on_directories(true);
7276
7277 let mount = StaticMount {
7278 mount_path: "/".to_string(),
7279 mode,
7280 dir: canonical_dir.clone(),
7281 cache_control: "public, max-age=3600".to_string(),
7282 error_pages: std::collections::HashMap::new(),
7283 serve_dir,
7284 };
7285 registry.register_static_mount(mount).await.unwrap();
7286
7287 let state = make_test_state(registry);
7288
7289 let req = Request::builder()
7291 .method("GET")
7292 .uri("/index.html")
7293 .body(AxumBody::empty())
7294 .unwrap();
7295 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
7296 assert_eq!(
7297 resp.status(),
7298 StatusCode::OK,
7299 "first GET should return 200, got {}",
7300 resp.status()
7301 );
7302 assert!(
7304 resp.headers().contains_key(http::header::CACHE_CONTROL),
7305 "200 response missing Cache-Control"
7306 );
7307 let etag = resp
7308 .headers()
7309 .get(http::header::ETAG)
7310 .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
7311 .clone();
7312 let last_modified = resp
7313 .headers()
7314 .get(http::header::LAST_MODIFIED)
7315 .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
7316 .clone();
7317 let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
7319 .await
7320 .unwrap();
7321
7322 let req = Request::builder()
7326 .method("GET")
7327 .uri("/index.html")
7328 .header(http::header::IF_NONE_MATCH, etag.clone())
7329 .body(AxumBody::empty())
7330 .unwrap();
7331 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
7332 assert_eq!(
7333 resp.status(),
7334 StatusCode::NOT_MODIFIED,
7335 "If-None-Match with matching ETag should return 304, got {}",
7336 resp.status()
7337 );
7338 assert!(
7340 resp.headers().contains_key(http::header::CACHE_CONTROL),
7341 "304 (If-None-Match) missing Cache-Control"
7342 );
7343 assert_eq!(
7346 resp.headers().get(http::header::ETAG),
7347 Some(&etag),
7348 "304 (If-None-Match) must echo the ETag validator"
7349 );
7350 assert_eq!(
7351 resp.headers().get(http::header::LAST_MODIFIED),
7352 Some(&last_modified),
7353 "304 (If-None-Match) must carry Last-Modified"
7354 );
7355
7356 let req = Request::builder()
7358 .method("GET")
7359 .uri("/index.html")
7360 .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
7361 .body(AxumBody::empty())
7362 .unwrap();
7363 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
7364 assert_eq!(
7365 resp.status(),
7366 StatusCode::NOT_MODIFIED,
7367 "If-Modified-Since with matching timestamp should return 304, got {}",
7368 resp.status()
7369 );
7370 assert!(
7371 resp.headers().contains_key(http::header::CACHE_CONTROL),
7372 "304 (If-Modified-Since) missing Cache-Control"
7373 );
7374 assert_eq!(
7375 resp.headers().get(http::header::ETAG),
7376 Some(&etag),
7377 "304 (If-Modified-Since) must carry the ETag validator"
7378 );
7379 assert_eq!(
7380 resp.headers().get(http::header::LAST_MODIFIED),
7381 Some(&last_modified),
7382 "304 (If-Modified-Since) must echo Last-Modified"
7383 );
7384
7385 let req = Request::builder()
7391 .method("GET")
7392 .uri("/index.html")
7393 .header(
7394 http::header::IF_MODIFIED_SINCE,
7395 "Wed, 21 Oct 2000 07:28:00 GMT",
7396 )
7397 .body(AxumBody::empty())
7398 .unwrap();
7399 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
7400 assert_eq!(
7401 resp.status(),
7402 StatusCode::OK,
7403 "past If-Modified-Since should return 200 (file modified after it), got {}",
7404 resp.status()
7405 );
7406
7407 std::fs::remove_dir_all(&temp_dir).ok();
7409 }
7410
7411 #[tokio::test]
7412 async fn test_conditional_get_returns_304_static_mode() {
7413 run_conditional_get_returns_304(MountMode::Static).await;
7414 }
7415
7416 #[tokio::test]
7417 async fn test_conditional_get_returns_304_spa_mode() {
7418 run_conditional_get_returns_304(MountMode::Spa).await;
7419 }
7420
7421 #[allow(clippy::await_holding_lock)]
7422 #[tokio::test]
7423 async fn test_error_page_mapping_serves_custom_404() {
7424 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7425 ServerRegistry::reset();
7426
7427 let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
7428 let errors_dir = temp_dir.join("errors");
7429 std::fs::create_dir_all(&errors_dir).unwrap();
7430 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
7431 std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
7432
7433 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
7434 let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
7435
7436 let registry = make_test_registry();
7437 let serve_dir = ServeDir::new(&canonical_dir)
7438 .precompressed_gzip()
7439 .precompressed_br()
7440 .append_index_html_on_directories(true);
7441
7442 let mut error_pages = std::collections::HashMap::new();
7443 error_pages.insert(404, canonical_404);
7444
7445 let mount = StaticMount {
7446 mount_path: "/".to_string(),
7447 mode: MountMode::Static,
7448 dir: canonical_dir.clone(),
7449 cache_control: "public, max-age=0".to_string(),
7450 error_pages,
7451 serve_dir,
7452 };
7453 registry.register_static_mount(mount).await.unwrap();
7454
7455 let state = make_test_state(registry);
7456
7457 let req = Request::builder()
7459 .method("GET")
7460 .uri("/missing.html")
7461 .body(AxumBody::empty())
7462 .unwrap();
7463 let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
7464 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7465 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7466 .await
7467 .unwrap();
7468 assert_eq!(&body[..], b"<h1>Custom 404</h1>");
7469
7470 let req = Request::builder()
7472 .method("GET")
7473 .uri("/index.html")
7474 .body(AxumBody::empty())
7475 .unwrap();
7476 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
7477 assert_eq!(resp.status(), StatusCode::OK);
7478 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7479 .await
7480 .unwrap();
7481 assert_eq!(&body[..], b"<h1>Home</h1>");
7482
7483 std::fs::remove_dir_all(&temp_dir).ok();
7485 }
7486
7487 #[tokio::test]
7488 async fn http_consumer_returns_body_and_code_on_stop() {
7489 use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
7490 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
7491 use tower::ServiceExt;
7492
7493 let set_body_step = CompiledStep::Process {
7495 kind_hint: camel_api::SpanKindHint::Internal,
7496 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
7497 ex.input.body = Body::Text("nope".into());
7498 Box::pin(async move { Ok(ex) })
7499 }),
7500 body_contract: None,
7501 lifecycle: None,
7502 label: None,
7503 };
7504 let set_status_step = CompiledStep::Process {
7505 kind_hint: camel_api::SpanKindHint::Internal,
7506 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
7507 ex.input.set_header(
7508 "CamelHttpResponseCode",
7509 serde_json::Value::Number(409.into()),
7510 );
7511 Box::pin(async move { Ok(ex) })
7512 }),
7513 body_contract: None,
7514 lifecycle: None,
7515 label: None,
7516 };
7517 let pipeline = compose_pipeline_with_handler(
7518 vec![set_body_step, set_status_step, CompiledStep::Stop],
7519 None,
7520 PipelineRuntimeCtx::compile_time(),
7521 );
7522
7523 let ex = Exchange::new(Message::default());
7524 let result = pipeline.oneshot(ex).await;
7525 assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
7526 let returned = result.unwrap();
7527 assert_eq!(returned.input.body.as_text(), Some("nope"));
7528 assert_eq!(
7529 returned
7530 .input
7531 .header("CamelHttpResponseCode")
7532 .and_then(|v| v.as_u64()),
7533 Some(409)
7534 );
7535 }
7536
7537 #[tokio::test]
7538 async fn http_consumer_returns_200_when_body_empty_on_stop() {
7539 use camel_api::{Exchange, Message};
7547 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
7548 use tower::ServiceExt;
7549
7550 let pipeline = compose_pipeline_with_handler(
7551 vec![CompiledStep::Stop],
7552 None,
7553 PipelineRuntimeCtx::compile_time(),
7554 );
7555 let ex = Exchange::new(Message::default());
7556 let result = pipeline.oneshot(ex).await;
7557 assert!(result.is_ok(), "Stop with empty body arrives as Ok");
7558 }
7561
7562 async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
7570 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7571 let port = listener.local_addr().unwrap().port();
7572 let registry = HttpRouteRegistry::new();
7573 tokio::spawn(run_axum_server(
7574 listener,
7575 registry.clone(),
7576 2 * 1024 * 1024,
7577 10 * 1024 * 1024,
7578 Arc::new(tokio::sync::Semaphore::new(1024)),
7579 test_rt(),
7580 "test-route".into(),
7581 ));
7582 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7584 (port, registry)
7585 }
7586
7587 fn spawn_responder(
7592 mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
7593 status: u16,
7594 body: String,
7595 ) -> tokio::task::JoinHandle<()> {
7596 tokio::spawn(async move {
7597 if let Some(envelope) = rx.recv().await {
7598 let _ = envelope.reply_tx.send(HttpReply {
7599 status,
7600 headers: vec![],
7601 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
7602 });
7603 }
7604 })
7605 }
7606
7607 #[tokio::test]
7608 async fn method_aware_dispatch_same_path_different_verbs() {
7609 let (port, registry) = spawn_test_server().await;
7610
7611 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7616 registry
7617 .register_rest_endpoint(
7618 "GET".into(),
7619 vec![PathSegment::Literal("users".into())],
7620 get_tx,
7621 )
7622 .await;
7623
7624 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7625 registry
7626 .register_rest_endpoint(
7627 "POST".into(),
7628 vec![PathSegment::Literal("users".into())],
7629 post_tx,
7630 )
7631 .await;
7632
7633 let get_handle = spawn_responder(get_rx, 200, "list".into());
7634 let post_handle = spawn_responder(post_rx, 201, "create".into());
7635
7636 let client = reqwest::Client::new();
7637
7638 let resp = client
7640 .get(format!("http://127.0.0.1:{port}/users"))
7641 .send()
7642 .await
7643 .unwrap();
7644 assert_eq!(resp.status().as_u16(), 200);
7645 let body = resp.text().await.unwrap();
7646 assert_eq!(body, "list");
7647
7648 let resp = client
7650 .post(format!("http://127.0.0.1:{port}/users"))
7651 .send()
7652 .await
7653 .unwrap();
7654 assert_eq!(resp.status().as_u16(), 201);
7655 let body = resp.text().await.unwrap();
7656 assert_eq!(body, "create");
7657
7658 let _ = tokio::join!(get_handle, post_handle);
7659 }
7660
7661 #[tokio::test]
7662 async fn method_aware_dispatch_templated_path_extracts_params() {
7663 let (port, registry) = spawn_test_server().await;
7664
7665 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7669 registry
7670 .register_rest_endpoint(
7671 "GET".into(),
7672 vec![
7673 PathSegment::Literal("users".into()),
7674 PathSegment::Param("id".into()),
7675 ],
7676 tx,
7677 )
7678 .await;
7679
7680 let handle = tokio::spawn(async move {
7683 if let Some(envelope) = rx.recv().await {
7684 let id = envelope.path_params.get("id").cloned().unwrap_or_default();
7685 let _ = envelope.reply_tx.send(HttpReply {
7686 status: 200,
7687 headers: vec![],
7688 body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
7689 });
7690 }
7691 });
7692
7693 let client = reqwest::Client::new();
7694 let resp = client
7695 .get(format!("http://127.0.0.1:{port}/users/42"))
7696 .send()
7697 .await
7698 .unwrap();
7699 assert_eq!(resp.status().as_u16(), 200);
7700 let body = resp.text().await.unwrap();
7701 assert_eq!(body, "id=42");
7702
7703 let _ = handle.await;
7704 }
7705
7706 #[tokio::test]
7707 async fn method_aware_dispatch_unmatched_method_falls_through() {
7708 let (port, _registry) = spawn_test_server().await;
7713
7714 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7716 _registry
7717 .register_rest_endpoint(
7718 "GET".into(),
7719 vec![PathSegment::Literal("users".into())],
7720 get_tx,
7721 )
7722 .await;
7723
7724 let drain = tokio::spawn(async move {
7727 let mut get_rx = get_rx;
7728 while get_rx.recv().await.is_some() {}
7729 });
7730
7731 let client = reqwest::Client::new();
7732 let resp = client
7733 .delete(format!("http://127.0.0.1:{port}/users"))
7734 .send()
7735 .await
7736 .unwrap();
7737 assert_eq!(resp.status().as_u16(), 404);
7738
7739 drop(drain);
7740 }
7741
7742 #[tokio::test]
7743 async fn regression_legacy_exact_api_route_still_works() {
7744 let (port, registry) = spawn_test_server().await;
7749
7750 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7751 registry.register_api_route("/legacy/path".into(), tx).await;
7752
7753 let handle = tokio::spawn(async move {
7754 if let Some(envelope) = rx.recv().await {
7755 let _ = envelope.reply_tx.send(HttpReply {
7756 status: 200,
7757 headers: vec![],
7758 body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
7759 });
7760 }
7761 });
7762
7763 let client = reqwest::Client::new();
7764 let resp = client
7765 .get(format!("http://127.0.0.1:{port}/legacy/path"))
7766 .send()
7767 .await
7768 .unwrap();
7769 assert_eq!(resp.status().as_u16(), 200);
7770 let body = resp.text().await.unwrap();
7771 assert_eq!(body, "legacy ok");
7772
7773 let _ = handle.await;
7774 }
7775
7776 #[allow(clippy::await_holding_lock)]
7777 #[tokio::test]
7778 async fn regression_static_mount_still_works() {
7779 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7783 ServerRegistry::reset();
7784
7785 let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
7786 std::fs::create_dir_all(&temp_dir).unwrap();
7787 std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
7788 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
7789
7790 let registry = make_test_registry();
7791 let serve_dir = ServeDir::new(&canonical_dir)
7792 .precompressed_gzip()
7793 .precompressed_br()
7794 .append_index_html_on_directories(true);
7795 let mount = StaticMount {
7796 mount_path: "/".to_string(),
7797 mode: MountMode::Static,
7798 dir: canonical_dir.clone(),
7799 cache_control: "public, max-age=3600".to_string(),
7800 error_pages: std::collections::HashMap::new(),
7801 serve_dir,
7802 };
7803 registry.register_static_mount(mount).await.unwrap();
7804
7805 let state = make_test_state(registry);
7806 let req = Request::builder()
7807 .uri("/regress.txt")
7808 .body(AxumBody::empty())
7809 .unwrap();
7810 let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
7811 assert_eq!(resp.status(), StatusCode::OK);
7812 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7813 .await
7814 .unwrap();
7815 assert_eq!(&body[..], b"static works");
7816
7817 std::fs::remove_dir_all(&temp_dir).ok();
7818 }
7819
7820 #[tokio::test]
7829 async fn deregister_one_method_keeps_sibling_verbs() {
7830 let (port, registry) = spawn_test_server().await;
7834
7835 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7836 registry
7837 .register_rest_endpoint(
7838 "GET".into(),
7839 vec![PathSegment::Literal("users".into())],
7840 get_tx,
7841 )
7842 .await;
7843
7844 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7845 registry
7846 .register_rest_endpoint(
7847 "POST".into(),
7848 vec![PathSegment::Literal("users".into())],
7849 post_tx,
7850 )
7851 .await;
7852
7853 let drain = tokio::spawn(async move {
7855 let mut get_rx = get_rx;
7856 while get_rx.recv().await.is_some() {}
7857 });
7858
7859 registry.unregister_rest_endpoint("GET", "/users").await;
7861 drop(drain);
7862
7863 let post_handle = spawn_responder(post_rx, 201, "create".into());
7864
7865 let client = reqwest::Client::new();
7866 let resp = client
7868 .post(format!("http://127.0.0.1:{port}/users"))
7869 .send()
7870 .await
7871 .unwrap();
7872 assert_eq!(resp.status().as_u16(), 201);
7873 assert_eq!(resp.text().await.unwrap(), "create");
7874
7875 let _ = post_handle.await;
7876 }
7877
7878 #[tokio::test]
7879 async fn dispatch_exact_legacy_beats_rest_template() {
7880 let (port, registry) = spawn_test_server().await;
7885
7886 let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7888 registry
7889 .register_api_route("/api/users".into(), exact_tx)
7890 .await;
7891 let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
7892
7893 let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7895 registry
7896 .register_rest_endpoint(
7897 "GET".into(),
7898 vec![
7899 PathSegment::Literal("api".into()),
7900 PathSegment::Param("resource".into()),
7901 ],
7902 tpl_tx,
7903 )
7904 .await;
7905 let _tpl_drain = tokio::spawn(async move {
7911 let mut tpl_rx = tpl_rx;
7912 if let Some(env) = tpl_rx.recv().await {
7913 let _ = env.reply_tx.send(HttpReply {
7914 status: 200,
7915 headers: vec![],
7916 body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
7917 });
7918 }
7919 });
7920
7921 let client = reqwest::Client::new();
7922 let resp = client
7923 .get(format!("http://127.0.0.1:{port}/api/users"))
7924 .send()
7925 .await
7926 .unwrap();
7927 assert_eq!(resp.status().as_u16(), 200);
7928 assert_eq!(resp.text().await.unwrap(), "exact");
7930
7931 let _ = exact_handle.await;
7932 }
7933
7934 #[tokio::test]
7935 async fn ambiguous_rest_templates_return_500_not_silent_404() {
7936 let (port, registry) = spawn_test_server().await;
7941
7942 let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7943 registry
7944 .register_rest_endpoint(
7945 "GET".into(),
7946 vec![
7947 PathSegment::Literal("users".into()),
7948 PathSegment::Param("id".into()),
7949 ],
7950 a_tx,
7951 )
7952 .await;
7953
7954 let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7955 registry
7956 .register_rest_endpoint(
7957 "GET".into(),
7958 vec![
7959 PathSegment::Literal("users".into()),
7960 PathSegment::Param("name".into()),
7961 ],
7962 b_tx,
7963 )
7964 .await;
7965
7966 let client = reqwest::Client::new();
7967 let resp = client
7968 .get(format!("http://127.0.0.1:{port}/users/42"))
7969 .send()
7970 .await
7971 .unwrap();
7972 assert_eq!(resp.status().as_u16(), 500);
7974 }
7975
7976 #[test]
7977 fn from_uri_round_trips_templated_path_with_http_method() {
7978 use crate::UriConfig;
7984 let cfg =
7985 HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
7986 assert_eq!(cfg.host, "0.0.0.0");
7987 assert_eq!(cfg.port, 8080);
7988 assert_eq!(cfg.path, "/users/{id}");
7989 assert_eq!(cfg.method.as_deref(), Some("GET"));
7990
7991 let cfg_lc =
7993 HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
7994 assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
7995 assert_eq!(cfg_lc.path, "/orders");
7996 }
7997
7998 #[test]
8003 fn type_conversion_failed_maps_to_400() {
8004 let reply = pipeline_error_to_reply(
8005 CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
8006 "/api/users",
8007 );
8008 assert_eq!(reply.status, 400);
8009 let ct = reply
8011 .headers
8012 .iter()
8013 .find(|(k, _)| k == "Content-Type")
8014 .map(|(_, v)| v.as_str());
8015 assert_eq!(ct, Some("application/json"));
8016 let body = match &reply.body {
8018 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
8019 _ => panic!("expected bytes body"),
8020 };
8021 assert!(body.contains("\"error\""));
8022 assert!(body.contains("bad_request"));
8023 assert!(body.contains("invalid JSON at line 1"));
8024 }
8025
8026 #[test]
8027 fn other_error_still_maps_to_500() {
8028 let reply =
8029 pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
8030 assert_eq!(reply.status, 500);
8031 }
8032
8033 #[test]
8034 fn unauthenticated_maps_to_401() {
8035 let reply = pipeline_error_to_reply(
8036 CamelError::Unauthenticated("no token".to_string()),
8037 "/api/users",
8038 );
8039 assert_eq!(reply.status, 401);
8040 }
8041
8042 #[test]
8043 fn unauthorized_maps_to_403() {
8044 let reply = pipeline_error_to_reply(
8045 CamelError::Unauthorized("forbidden".to_string()),
8046 "/api/users",
8047 );
8048 assert_eq!(reply.status, 403);
8049 }
8050
8051 #[test]
8052 fn validation_error_maps_to_400() {
8053 let reply = pipeline_error_to_reply(
8054 CamelError::ValidationError("body does not match schema".to_string()),
8055 "/api/users",
8056 );
8057 assert_eq!(reply.status, 400);
8058 let ct = reply
8059 .headers
8060 .iter()
8061 .find(|(k, _)| k == "Content-Type")
8062 .map(|(_, v)| v.as_str());
8063 assert_eq!(ct, Some("application/json"));
8064 let body = match &reply.body {
8065 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
8066 _ => panic!("expected bytes body"),
8067 };
8068 assert!(body.contains("\"error\""));
8069 assert!(body.contains("validation_error"));
8070 assert!(body.contains("body does not match schema"));
8071 }
8072
8073 #[test]
8074 fn https_consumer_without_tls_cert_errors() {
8075 let endpoint = HttpEndpoint {
8076 uri: "https://0.0.0.0:8443/api".to_string(),
8077 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
8078 server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
8079 client: reqwest::Client::new(),
8080 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
8081 PINNED_CLIENT_TTL,
8082 PINNED_CLIENT_MAX_ENTRIES,
8083 )),
8084 http_config: HttpConfig::default(),
8085 };
8086 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
8087 let result = endpoint.create_consumer(rt);
8088 assert!(result.is_err(), "expected error for https without tls cert");
8089 if let Err(e) = result {
8090 let msg = e.to_string();
8091 assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
8092 }
8093 }
8094
8095 #[test]
8096 fn http_consumer_with_tls_config_errors() {
8097 let endpoint = HttpEndpoint {
8098 uri: "http://0.0.0.0:8080/api".to_string(),
8099 config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
8100 server_config: HttpServerConfig::from_uri(
8101 "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
8102 )
8103 .unwrap(),
8104 client: reqwest::Client::new(),
8105 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
8106 PINNED_CLIENT_TTL,
8107 PINNED_CLIENT_MAX_ENTRIES,
8108 )),
8109 http_config: HttpConfig::default(),
8110 };
8111 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
8112 let result = endpoint.create_consumer(rt);
8113 assert!(result.is_err(), "expected error for http with tls config");
8114 if let Err(e) = result {
8115 let msg = e.to_string();
8116 assert!(msg.contains("https"), "error must mention https: {msg}");
8117 }
8118 }
8119
8120 #[test]
8121 fn https_consumer_with_partial_tls_cert_only_errors() {
8122 let server_config =
8125 HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
8126 assert!(
8127 server_config.tls_config.is_none(),
8128 "partial tlsCert must not create ServerTlsConfig"
8129 );
8130 let endpoint = HttpEndpoint {
8131 uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
8132 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
8133 .unwrap(),
8134 server_config,
8135 client: reqwest::Client::new(),
8136 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
8137 PINNED_CLIENT_TTL,
8138 PINNED_CLIENT_MAX_ENTRIES,
8139 )),
8140 http_config: HttpConfig::default(),
8141 };
8142 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
8143 let result = endpoint.create_consumer(rt);
8144 assert!(
8145 result.is_err(),
8146 "must error: https:// requires both tlsCert and tlsKey"
8147 );
8148 }
8149
8150 #[test]
8151 fn load_tls_config_parses_valid_pem() {
8152 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
8154 use camel_component_api::test_support::tls;
8155 let (_, cert_pem, key_pem) = tls::gen_server_cert();
8156 let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
8157 let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
8158
8159 let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
8160 assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
8161 }
8162
8163 #[tokio::test(flavor = "multi_thread")]
8164 #[allow(clippy::await_holding_lock)]
8165 async fn consumer_tls_handshake_roundtrip() {
8166 use camel_component_api::test_support::tls;
8167 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8168
8169 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
8171
8172 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
8174
8175 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
8177 let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
8178 let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
8179 let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
8180
8181 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8183 let port = probe.local_addr().unwrap().port();
8184 drop(probe);
8185
8186 ServerRegistry::reset();
8187
8188 let component = HttpComponent::new();
8190 let endpoint_ctx = NoOpComponentContext;
8191 let uri = format!(
8192 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
8193 cert_path.to_string_lossy(),
8194 key_path.to_string_lossy(),
8195 );
8196 let endpoint = component
8197 .create_endpoint(&uri, &endpoint_ctx)
8198 .expect("create TLS endpoint");
8199 let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
8200
8201 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8203 let token = tokio_util::sync::CancellationToken::new();
8204 let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
8205 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8206
8207 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
8209
8210 let ca_bytes = std::fs::read(&ca_path).unwrap();
8212 let client = reqwest::Client::builder()
8213 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
8214 .build()
8215 .unwrap();
8216
8217 let send_fut = client
8218 .post(format!("https://localhost:{port}/test"))
8219 .body("ping")
8220 .send();
8221
8222 let (http_result, _) = tokio::join!(send_fut, async {
8224 if let Some(mut envelope) = rx.recv().await {
8225 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
8226 if let Some(reply_tx) = envelope.reply_tx {
8227 let _ = reply_tx.send(Ok(envelope.exchange));
8228 }
8229 }
8230 });
8231
8232 let resp = http_result.expect("TLS handshake + request must succeed");
8233
8234 assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
8235 let body = resp.text().await.unwrap();
8236 assert_eq!(body, "pong");
8237
8238 token.cancel();
8239 }
8240
8241 #[tokio::test(flavor = "multi_thread")]
8242 #[allow(clippy::await_holding_lock)]
8243 async fn consumer_tls_rejects_client_without_ca() {
8244 use camel_component_api::test_support::tls;
8245 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8246
8247 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
8248
8249 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
8251
8252 let (_, cert_pem, key_pem) = tls::gen_server_cert();
8253 let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
8254 let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
8255
8256 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8257 let port = probe.local_addr().unwrap().port();
8258 drop(probe);
8259
8260 ServerRegistry::reset();
8261
8262 let component = HttpComponent::new();
8264 let endpoint_ctx = NoOpComponentContext;
8265 let uri = format!(
8266 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
8267 cert_path.to_string_lossy(),
8268 key_path.to_string_lossy(),
8269 );
8270 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
8271 let mut consumer = endpoint.create_consumer(rt()).unwrap();
8272 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8273 let token = tokio_util::sync::CancellationToken::new();
8274 let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
8275 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8276
8277 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
8278
8279 let client = reqwest::Client::builder().build().unwrap();
8281
8282 let result = client
8283 .get(format!("https://localhost:{port}/test"))
8284 .send()
8285 .await;
8286
8287 assert!(
8288 result.is_err(),
8289 "must reject without CA — proves real verification"
8290 );
8291
8292 token.cancel();
8293 }
8294
8295 #[test]
8296 fn server_config_partial_tls_cert_without_key() {
8297 let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
8299 assert!(cfg.tls_config.is_none());
8301 }
8302
8303 #[test]
8304 fn endpoint_uri_options_count_parity() {
8305 assert_eq!(
8307 HttpEndpointConfig::uri_options().len(),
8308 20,
8309 "HttpEndpointUriConfig #[uri_param] count drifted from parser"
8310 );
8311 }
8312
8313 fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
8314 pairs
8315 .iter()
8316 .map(|(k, v)| {
8317 (
8318 (*k).to_string(),
8319 serde_json::Value::String((*v).to_string()),
8320 )
8321 })
8322 .collect()
8323 }
8324
8325 #[test]
8326 fn response_emits_cache_control_via_pragma_warning() {
8327 let headers = make_headers(&[
8328 ("Cache-Control", "public, max-age=3600"),
8329 ("Via", "1.1 myproxy"),
8330 ("Pragma", "no-cache"),
8331 ("Warning", "199 misc"),
8332 ]);
8333 let selected = select_response_headers(&headers, None, None);
8334 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
8335 for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
8336 assert!(
8337 names.contains(&expected),
8338 "{expected} should pass through to the response"
8339 );
8340 }
8341 }
8342
8343 #[test]
8344 fn response_excludes_request_only_and_server_owned() {
8345 let headers = make_headers(&[
8346 ("User-Agent", "x"),
8347 ("Accept", "*/*"),
8348 ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
8349 ]);
8350 let selected = select_response_headers(&headers, None, None);
8351 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
8352 for excluded in ["User-Agent", "Accept", "Date"] {
8353 assert!(
8354 !names.contains(&excluded),
8355 "{excluded} should NOT appear in the response"
8356 );
8357 }
8358 }
8359
8360 #[test]
8361 fn response_re_derives_content_type() {
8362 let headers = make_headers(&[("Content-Type", "text/plain")]);
8363 let selected = select_response_headers(&headers, Some("application/json".into()), None);
8364 let ct_entries: Vec<&str> = selected
8365 .iter()
8366 .filter(|(k, _)| k == "Content-Type")
8367 .map(|(_, v)| v.as_str())
8368 .collect();
8369 assert_eq!(
8370 ct_entries,
8371 ["application/json"],
8372 "exactly one Content-Type entry, re-derived from user_content_type"
8373 );
8374 }
8375
8376 #[test]
8377 fn response_excludes_camel_headers() {
8378 let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
8379 let selected = select_response_headers(&headers, None, None);
8380 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
8381 assert!(
8382 !names.contains(&"CamelHttpPath"),
8383 "Camel-namespace headers must be excluded"
8384 );
8385 assert!(
8386 names.contains(&"Cache-Control"),
8387 "Cache-Control must pass through"
8388 );
8389 }
8390
8391 async fn start_host_capturing_destination() -> (
8403 String,
8404 Arc<std::sync::Mutex<Option<(String, String)>>>,
8405 tokio::task::JoinHandle<()>,
8406 ) {
8407 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8408 let port = listener.local_addr().unwrap().port();
8409 let url = format!("http://127.0.0.1:{port}");
8410 let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
8411 Arc::new(std::sync::Mutex::new(None));
8412 let captured_clone = Arc::clone(&captured);
8413 let handle = tokio::spawn(async move {
8414 use tokio::io::{AsyncReadExt, AsyncWriteExt};
8415 if let Ok((mut stream, _)) = listener.accept().await {
8416 let mut buf = vec![0u8; 16384];
8417 let n = stream.read(&mut buf).await.unwrap_or(0);
8418 let request = String::from_utf8_lossy(&buf[..n]).to_string();
8419 if request.contains("\r\n\r\n") {
8420 let request_line = request.lines().next().unwrap_or("").to_string();
8421 let host_value = request
8422 .lines()
8423 .find(|l| l.to_lowercase().starts_with("host:"))
8424 .and_then(|l| l.split_once(':'))
8425 .map(|(_, v)| v.trim().to_string())
8426 .unwrap_or_default();
8427 *captured_clone.lock().unwrap() = Some((host_value, request_line));
8428 }
8429 let body = r#"{"echo":"ok"}"#;
8430 let resp = format!(
8431 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
8432 body.len(),
8433 body
8434 );
8435 let _ = stream.write_all(resp.as_bytes()).await;
8436 }
8437 });
8438 (url, captured, handle)
8439 }
8440
8441 #[tokio::test]
8446 async fn bridge_proxy_outbound_host_matches_destination() {
8447 use tower::ServiceExt;
8448
8449 let (url, captured, _handle) = start_host_capturing_destination().await;
8450 let expected_host = url.strip_prefix("http://").unwrap();
8453
8454 let ctx = test_producer_ctx();
8455 let component = HttpComponent::new();
8456 let endpoint_ctx = NoOpComponentContext;
8457 let endpoint = component
8458 .create_endpoint(
8459 &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
8460 &endpoint_ctx,
8461 )
8462 .unwrap();
8463 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
8464
8465 let mut exchange = Exchange::new(Message::default());
8468 exchange.input.set_header("Host", "localhost");
8469 exchange.input.set_header("CamelHttpPath", "/foo");
8470
8471 let result = producer.oneshot(exchange).await;
8472 assert!(result.is_ok(), "producer call failed: {:?}", result);
8473
8474 tokio::time::sleep(Duration::from_millis(100)).await;
8475 let (host_value, request_line) = captured
8476 .lock()
8477 .unwrap()
8478 .take()
8479 .expect("destination capture mutex empty — producer did not reach the destination");
8480
8481 assert_ne!(
8482 host_value, "localhost",
8483 "bridge producer must not forward the exchange Host: localhost"
8484 );
8485 assert_eq!(
8486 host_value, expected_host,
8487 "Host must be derived from the destination authority (no scheme)"
8488 );
8489 assert!(
8490 !request_line.contains("/foo"),
8491 "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
8492 );
8493 }
8494
8495 #[tokio::test]
8500 async fn bridge_proxy_route_set_response_header_survives() {
8501 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8502
8503 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8504 let port = listener.local_addr().unwrap().port();
8505 drop(listener);
8506
8507 let component = HttpComponent::new();
8508 let endpoint_ctx = NoOpComponentContext;
8509 let endpoint = component
8510 .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
8511 .unwrap();
8512 let mut consumer = endpoint.create_consumer(rt()).unwrap();
8513
8514 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8515 let token = tokio_util::sync::CancellationToken::new();
8516 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8517
8518 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8519 tokio::time::sleep(Duration::from_millis(50)).await;
8520
8521 let client = reqwest::Client::new();
8522 let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
8523
8524 let (http_result, _) = tokio::join!(send_fut, async {
8528 if let Some(mut envelope) = rx.recv().await {
8529 envelope
8530 .exchange
8531 .input
8532 .set_header("Cache-Control", "public, max-age=3600");
8533 if let Some(reply_tx) = envelope.reply_tx {
8534 let _ = reply_tx.send(Ok(envelope.exchange));
8535 }
8536 }
8537 });
8538
8539 let resp = http_result.unwrap();
8540 assert_eq!(resp.status().as_u16(), 200);
8541
8542 let cache_control = resp.headers().get("cache-control");
8543 assert!(
8544 cache_control.is_some(),
8545 "Cache-Control header must survive to the wire response"
8546 );
8547 assert_eq!(
8548 cache_control.unwrap().to_str().unwrap(),
8549 "public, max-age=3600"
8550 );
8551
8552 token.cancel();
8553 }
8554
8555 use camel_api::security_policy::CredentialSource;
8574 use camel_auth::credential_source::extract_token_from_exchange;
8575 use camel_auth::native_auth::NativeCredentialStore;
8576 use camel_auth::{StaticTokenAuthenticator, TokenAuthenticator};
8577
8578 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 {
8586 let mut msg = Message::default();
8587 msg.set_header(
8588 "CamelHttpMethod",
8589 serde_json::Value::String(envelope.method.clone()),
8590 );
8591 msg.set_header(
8592 "CamelHttpPath",
8593 serde_json::Value::String(envelope.path.clone()),
8594 );
8595 msg.set_header(
8596 "CamelHttpQuery",
8597 serde_json::Value::String(envelope.query.clone()),
8598 );
8599 for (k, v) in &envelope.headers {
8600 if let Ok(val_str) = v.to_str() {
8601 msg.set_header(
8602 title_case_header(k.as_str()),
8603 serde_json::Value::String(val_str.to_string()),
8604 );
8605 }
8606 }
8607 Exchange::new(msg)
8608 }
8609
8610 async fn spawn_failing_auth_route(
8617 registry: &HttpRouteRegistry,
8618 path: &str,
8619 sources: Vec<CredentialSource>,
8620 ) {
8621 let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
8622 NativeCredentialStore::try_new(vec![]).unwrap(),
8623 ));
8624 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
8625 registry.register_api_route(path.to_string(), tx).await;
8626 let path_owned = path.to_string();
8627 tokio::spawn(async move {
8628 while let Some(envelope) = rx.recv().await {
8629 let exchange = envelope_to_exchange(&envelope);
8630 let reply_tx = envelope.reply_tx;
8631 let result: Result<(), CamelError> = async {
8632 let token = extract_token_from_exchange(&exchange, &sources)
8633 .map(|extracted| extracted.token)
8634 .ok_or_else(|| {
8635 CamelError::Unauthenticated("no credential in any source".into())
8636 })?;
8637 authenticator.authenticate_bearer(&token).await?;
8638 Ok(())
8639 }
8640 .await;
8641 let reply = match result {
8642 Ok(()) => HttpReply {
8643 status: 200,
8644 headers: vec![],
8645 body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
8646 },
8647 Err(e) => pipeline_error_to_reply(e, &path_owned),
8648 };
8649 let _ = reply_tx.send(reply);
8650 }
8651 });
8652 }
8653
8654 fn captured_logs_contain(needle: &str) -> bool {
8658 let buf = tracing_test::internal::global_buf().lock().unwrap();
8659 String::from_utf8_lossy(&buf).contains(needle)
8660 }
8661
8662 #[tracing_test::traced_test]
8663 #[tokio::test]
8664 async fn error_context_redacts_query_sentinel() {
8665 let (port, registry) = spawn_test_server().await;
8666 spawn_failing_auth_route(
8667 ®istry,
8668 "/secure-query",
8669 vec![CredentialSource::QueryParam {
8670 param: "token".to_string(),
8671 }],
8672 )
8673 .await;
8674
8675 let client = reqwest::Client::new();
8676 let resp = client
8677 .get(format!(
8679 "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
8680 ))
8681 .send()
8682 .await
8683 .unwrap();
8684
8685 assert_eq!(resp.status().as_u16(), 401);
8686 let body = resp.text().await.unwrap();
8687 assert_eq!(body, "Unauthorized");
8688 assert!(
8689 !body.contains(SENTINEL_QRY_42),
8690 "reply body must not contain the query credential"
8691 );
8692 assert!(
8693 !captured_logs_contain(SENTINEL_QRY_42),
8694 "no tracing record during request handling may render the query credential"
8695 );
8696 assert!(
8700 captured_logs_contain("Authentication failed"),
8701 "positive control: the failed-auth warn! must be captured by the test subscriber"
8702 );
8703 }
8704
8705 #[tracing_test::traced_test]
8706 #[tokio::test]
8707 async fn error_context_redacts_cookie_sentinel() {
8708 let (port, registry) = spawn_test_server().await;
8709 spawn_failing_auth_route(
8710 ®istry,
8711 "/secure-cookie",
8712 vec![CredentialSource::Cookie {
8713 name: "session".to_string(),
8714 }],
8715 )
8716 .await;
8717
8718 let client = reqwest::Client::new();
8719 let resp = client
8720 .get(format!("http://127.0.0.1:{port}/secure-cookie"))
8721 .header("Cookie", format!("session={SENTINEL_CKY_7}"))
8722 .send()
8723 .await
8724 .unwrap();
8725
8726 assert_eq!(resp.status().as_u16(), 401);
8727 let body = resp.text().await.unwrap();
8728 assert_eq!(body, "Unauthorized");
8729 assert!(
8730 !body.contains(SENTINEL_CKY_7),
8731 "reply body must not contain the cookie credential"
8732 );
8733 assert!(
8734 !captured_logs_contain(SENTINEL_CKY_7),
8735 "no tracing record during request handling may render the cookie credential"
8736 );
8737 }
8738
8739 #[tracing_test::traced_test]
8740 #[tokio::test]
8741 async fn error_reply_no_credential_value() {
8742 let (port, registry) = spawn_test_server().await;
8743 spawn_failing_auth_route(
8744 ®istry,
8745 "/secure-bad",
8746 vec![CredentialSource::Cookie {
8747 name: "session".to_string(),
8748 }],
8749 )
8750 .await;
8751
8752 let client = reqwest::Client::new();
8753 let resp = client
8754 .get(format!("http://127.0.0.1:{port}/secure-bad"))
8755 .header("Cookie", format!("session={SENTINEL_BAD_1}"))
8756 .send()
8757 .await
8758 .unwrap();
8759
8760 assert_eq!(resp.status().as_u16(), 401);
8761 let body = resp.text().await.unwrap();
8762 assert_eq!(body, "Unauthorized");
8763 assert!(
8764 !body.contains(SENTINEL_BAD_1),
8765 "reply body must not contain the credential value"
8766 );
8767 assert!(
8768 !captured_logs_contain(SENTINEL_BAD_1),
8769 "error logs must not render the credential value"
8770 );
8771 }
8772
8773 async fn spawn_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
8787 use tokio::io::AsyncWriteExt;
8788
8789 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
8790 .await
8791 .expect("bind ephemeral 127.0.0.1 listener");
8792 let port = listener.local_addr().expect("local addr").port();
8793 let base_url = format!("http://localhost:{port}");
8794 let handle = tokio::spawn(async move {
8795 while let Ok((mut conn, _)) = listener.accept().await {
8796 let _ = conn
8797 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
8798 .await;
8799 let _ = conn.shutdown().await;
8800 }
8801 });
8802 (base_url, handle)
8803 }
8804
8805 fn responder_port(base_url: &str) -> u16 {
8809 url::Url::parse(base_url)
8810 .expect("responder base URL parses")
8811 .port()
8812 .expect("responder base URL carries an explicit port")
8813 }
8814
8815 fn endpoint_with_shared_cache(
8820 base_url: &str,
8821 pinned_cache: &Arc<PinnedClientCache>,
8822 ) -> HttpEndpoint {
8823 let uri = format!("{base_url}?allowInternal=true");
8824 HttpEndpoint {
8825 uri: uri.clone(),
8826 config: HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses"),
8827 server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
8828 client: reqwest::Client::new(),
8829 pinned_cache: Arc::clone(pinned_cache),
8830 http_config: HttpConfig::default(),
8831 }
8832 }
8833
8834 #[tokio::test]
8835 async fn producers_share_endpoint_cache() {
8836 use tower::ServiceExt;
8837
8838 let (base_url, _handle) = spawn_multi_accept_200().await;
8839 let pinned_cache = Arc::new(PinnedClientCache::new(
8840 PINNED_CLIENT_TTL,
8841 PINNED_CLIENT_MAX_ENTRIES,
8842 ));
8843
8844 let ctx = test_producer_ctx();
8845 let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
8846 let producer_a = endpoint.create_producer(rt(), &ctx);
8847 let producer_b = endpoint.create_producer(rt(), &ctx);
8848
8849 for producer in [producer_a, producer_b] {
8852 let producer = producer.expect("create producer");
8853 let exchange = Exchange::new(Message::default());
8854 let reply = producer.oneshot(exchange).await;
8855 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
8856 }
8857
8858 assert_eq!(
8859 pinned_cache.build_count(),
8860 1,
8861 "both producers must hit the same shared cache entry; a second \
8862 build means sharing is broken"
8863 );
8864 }
8865
8866 #[tokio::test]
8867 async fn producer_repeated_hostname_requests_build_one_client() {
8868 use tower::ServiceExt;
8869
8870 let (base_url, _handle) = spawn_multi_accept_200().await;
8871 let pinned_cache = Arc::new(PinnedClientCache::new(
8872 PINNED_CLIENT_TTL,
8873 PINNED_CLIENT_MAX_ENTRIES,
8874 ));
8875 let ctx = test_producer_ctx();
8876 let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
8877 let producer = endpoint
8878 .create_producer(rt(), &ctx)
8879 .expect("create producer");
8880
8881 for i in 0..2 {
8884 let exchange = Exchange::new(Message::default());
8885 let reply = producer.clone().oneshot(exchange).await;
8886 assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
8887 }
8888
8889 assert_eq!(
8890 pinned_cache.build_count(),
8891 1,
8892 "repeated hostname requests must reuse the one pinned client; \
8893 0 builds means the producer bypassed the cache, more than 1 \
8894 means the entry was dropped"
8895 );
8896 }
8897
8898 #[tokio::test]
8899 async fn ip_literal_request_never_enters_cache() {
8900 use tower::ServiceExt;
8901
8902 let (base_url, _handle) = spawn_multi_accept_200().await;
8903 let pinned_cache = Arc::new(PinnedClientCache::new(
8904 PINNED_CLIENT_TTL,
8905 PINNED_CLIENT_MAX_ENTRIES,
8906 ));
8907
8908 let ctx = test_producer_ctx();
8909 let destination = format!("http://127.0.0.1:{}/ping", responder_port(&base_url));
8910 let endpoint = endpoint_with_shared_cache(&destination, &pinned_cache);
8911 let producer = endpoint
8912 .create_producer(rt(), &ctx)
8913 .expect("create producer");
8914
8915 let exchange = Exchange::new(Message::default());
8916 let reply = producer.oneshot(exchange).await;
8917 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
8918
8919 assert_eq!(
8920 pinned_cache.build_count(),
8921 0,
8922 "an IP-literal URL must use the shared unpinned client and \
8923 never enter the pinned cache"
8924 );
8925 }
8926
8927 #[tokio::test]
8928 async fn test_component_endpoints_share_pinned_cache() {
8929 use tower::ServiceExt;
8930
8931 let component = HttpComponent::new();
8932 let (base_url, _handle) = spawn_multi_accept_200().await;
8933 let baseline = component.pinned_cache.build_count();
8934
8935 let ctx = test_producer_ctx();
8936 let endpoint_ctx = NoOpComponentContext;
8937 for uri in [
8938 format!("{base_url}/a?allowInternal=true&k=a"),
8939 format!("{base_url}/b?allowInternal=true&k=b"),
8940 ] {
8941 let endpoint = component
8942 .create_endpoint(&uri, &endpoint_ctx)
8943 .expect("create endpoint");
8944 let producer = endpoint
8945 .create_producer(rt(), &ctx)
8946 .expect("create producer");
8947 let exchange = Exchange::new(Message::default());
8948 let reply = producer.oneshot(exchange).await;
8949 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
8950 }
8951
8952 assert_eq!(
8953 component.pinned_cache.build_count() - baseline,
8954 1,
8955 "endpoints created by one component must share its pinned cache; \
8956 0 builds means the endpoints bypassed it, more than 1 means \
8957 per-endpoint caches came back"
8958 );
8959 }
8960
8961 #[tokio::test]
8962 async fn test_dynamic_resolution_sequence_hits_shared_cache() {
8963 use tower::ServiceExt;
8964
8965 let component = HttpComponent::new();
8966 let (base_url, _handle) = spawn_multi_accept_200().await;
8967 let baseline = component.pinned_cache.build_count();
8968
8969 let ctx = test_producer_ctx();
8970 let endpoint_ctx = NoOpComponentContext;
8971 for i in 0..3 {
8972 let endpoint = component
8973 .create_endpoint(
8974 &format!("{base_url}/r{i}?allowInternal=true&k={i}"),
8975 &endpoint_ctx,
8976 )
8977 .expect("create endpoint");
8978 let producer = endpoint
8979 .create_producer(rt(), &ctx)
8980 .expect("create producer");
8981 let exchange = Exchange::new(Message::default());
8982 let reply = producer.oneshot(exchange).await;
8983 assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
8984 }
8985
8986 assert_eq!(
8987 component.pinned_cache.build_count() - baseline,
8988 1,
8989 "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
8990 must reuse the component's one pinned cache entry; 0 builds \
8991 means the endpoints bypassed it, more than 1 means \
8992 per-endpoint caches came back"
8993 );
8994 }
8995
8996 #[test]
8997 fn test_https_component_owns_distinct_cache() {
8998 let http = HttpComponent::new();
8999 let https = HttpsComponent::new();
9000
9001 assert!(
9002 !Arc::ptr_eq(&http.pinned_cache, &https.pinned_cache),
9003 "http and https components must each own their own pinned cache"
9004 );
9005
9006 let endpoint_ctx = NoOpComponentContext;
9007 let _ = http
9008 .create_endpoint("http://localhost:1/?allowInternal=true", &endpoint_ctx)
9009 .expect("http endpoint");
9010 let _ = https
9011 .create_endpoint("https://localhost:1/?allowInternal=true", &endpoint_ctx)
9012 .expect("https endpoint");
9013
9014 assert_eq!(
9015 http.pinned_cache.build_count(),
9016 0,
9017 "endpoint creation must not build a pinned client"
9018 );
9019 assert_eq!(
9020 https.pinned_cache.build_count(),
9021 0,
9022 "endpoint creation must not build a pinned client"
9023 );
9024 }
9025
9026 #[test]
9027 fn test_component_constructor_builds_one_unpinned_client() {
9028 let baseline = build_client_call_count();
9029
9030 let _http = HttpComponent::new();
9031 assert_eq!(
9032 build_client_call_count() - baseline,
9033 1,
9034 "HttpComponent::new() must build exactly one shared unpinned client"
9035 );
9036
9037 let _https = HttpsComponent::new();
9038 assert_eq!(
9039 build_client_call_count() - baseline,
9040 2,
9041 "HttpsComponent::new() must build exactly one more shared unpinned client"
9042 );
9043 }
9044
9045 #[test]
9046 fn test_component_endpoints_share_unpinned_client() {
9047 let component = HttpComponent::new();
9048 let baseline = build_client_call_count();
9049
9050 let endpoint_ctx = NoOpComponentContext;
9051 for uri in [
9052 "http://localhost:1/a?allowInternal=true",
9053 "http://localhost:1/b?allowInternal=true",
9054 ] {
9055 let _endpoint = component
9056 .create_endpoint(uri, &endpoint_ctx)
9057 .expect("create endpoint");
9058 }
9059
9060 assert_eq!(
9061 build_client_call_count() - baseline,
9062 0,
9063 "create_endpoint must clone the component's shared unpinned client, \
9064 never build a fresh one"
9065 );
9066 }
9067
9068 #[test]
9069 fn test_dynamic_resolution_adds_no_unpinned_client_builds() {
9070 let component = HttpComponent::new();
9071 let baseline = build_client_call_count();
9072
9073 let ctx = test_producer_ctx();
9074 let endpoint_ctx = NoOpComponentContext;
9075 for i in 0..3 {
9076 let endpoint = component
9077 .create_endpoint(
9078 &format!("http://localhost:1/r{i}?allowInternal=true&k={i}"),
9079 &endpoint_ctx,
9080 )
9081 .expect("create endpoint");
9082 let _producer = endpoint
9083 .create_producer(rt(), &ctx)
9084 .expect("create producer");
9085 }
9086
9087 assert_eq!(
9088 build_client_call_count() - baseline,
9089 0,
9090 "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
9091 must reuse the component's shared unpinned client and build \
9092 no additional clients"
9093 );
9094 }
9095}