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::TryStreamExt;
47use futures::stream::BoxStream;
48
49#[derive(Debug, Clone)]
110pub struct HttpEndpointConfig {
111 pub base_url: String,
112 pub http_method: Option<String>,
113 pub throw_exception_on_failure: bool,
114 pub ok_status_code_range: (u16, u16),
115 pub response_timeout: Option<Duration>,
116 pub query_params: HashMap<String, String>,
117 pub allow_internal: bool,
118 pub blocked_hosts: Vec<String>,
119 pub max_body_size: usize,
120 pub read_timeout_ms: u64,
121 pub max_response_bytes: usize,
122 pub auth: HttpAuth,
123 pub token_provider: Option<Arc<dyn TokenProvider>>,
124 pub user_agent: Option<String>,
125 pub bridge_endpoint: bool,
126 pub connection_close: bool,
127 pub skip_request_headers: Vec<String>,
128 pub skip_response_headers: Vec<String>,
129 pub follow_redirects: bool,
130 pub max_redirects: usize,
131}
132
133#[derive(Clone, PartialEq)]
134pub enum HttpAuth {
135 None,
136 Basic { username: String, password: String },
137 Bearer { token: String },
138}
139
140impl std::fmt::Debug for HttpAuth {
141 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142 match self {
143 HttpAuth::None => f.write_str("None"),
144 HttpAuth::Basic { username, .. } => f
145 .debug_struct("Basic")
146 .field("username", username)
147 .field("password", &"***")
148 .finish(),
149 HttpAuth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
150 }
151 }
152}
153
154const HTTP_CAMEL_OPTIONS: &[&str] = &[
156 "httpMethod",
157 "throwExceptionOnFailure",
158 "okStatusCodeRange",
159 "followRedirects",
160 "maxRedirects",
161 "connectTimeout",
162 "responseTimeout",
163 "allowInternal",
164 "blockedHosts",
165 "maxBodySize",
166 "readTimeout",
167 "maxResponseBytes",
168 "authMethod",
169 "authUsername",
170 "authPassword",
171 "authBearerToken",
172 "userAgent",
173 "cookieHandling",
174 "bridgeEndpoint",
175 "connectionClose",
176 "skipRequestHeaders",
177 "skipResponseHeaders",
178];
179
180impl UriConfig for HttpEndpointConfig {
181 fn scheme() -> &'static str {
183 "http"
184 }
185
186 fn from_uri(uri: &str) -> Result<Self, CamelError> {
187 let parts = parse_uri(uri)?;
188 Self::from_components(parts)
189 }
190
191 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
192 if parts.scheme != "http" && parts.scheme != "https" {
194 return Err(CamelError::InvalidUri(format!(
195 "expected scheme 'http' or 'https', got '{}'",
196 parts.scheme
197 )));
198 }
199
200 let base_url = format!("{}:{}", parts.scheme, parts.path);
203
204 let http_method = parts.params.get("httpMethod").cloned();
205
206 let throw_exception_on_failure = match parts.params.get("throwExceptionOnFailure") {
207 Some(v) => parse_bool_param_http(v).map_err(|e| {
208 CamelError::InvalidUri(format!("invalid value for throwExceptionOnFailure: {e}"))
209 })?,
210 None => true,
211 };
212
213 let ok_status_code_range = match parts.params.get("okStatusCodeRange") {
215 Some(v) => parse_ok_status_code_range(v)?,
216 None => (200, 299),
217 };
218
219 let response_timeout = match parts.params.get("responseTimeout") {
220 Some(v) => Some(v.parse::<u64>().map(Duration::from_millis).map_err(|e| {
221 CamelError::InvalidUri(format!("invalid value for responseTimeout: {e}"))
222 })?),
223 None => None,
224 };
225
226 let allow_internal = match parts.params.get("allowInternal") {
228 Some(v) => parse_bool_param_http(v).map_err(|e| {
229 CamelError::InvalidUri(format!("invalid value for allowInternal: {e}"))
230 })?,
231 None => false, };
233
234 let blocked_hosts = parts
236 .params
237 .get("blockedHosts")
238 .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
239 .unwrap_or_default();
240
241 let max_body_size = match parts.params.get("maxBodySize") {
242 Some(v) => v.parse::<usize>().map_err(|e| {
243 CamelError::InvalidUri(format!("invalid value for maxBodySize: {e}"))
244 })?,
245 None => 10 * 1024 * 1024, };
247
248 let read_timeout_ms = match parts.params.get("readTimeout") {
249 Some(v) => v.parse::<u64>().map_err(|e| {
250 CamelError::InvalidUri(format!("invalid value for readTimeout: {e}"))
251 })?,
252 None => 30_000, };
254
255 let max_response_bytes = match parts.params.get("maxResponseBytes") {
256 Some(v) => v.parse::<usize>().map_err(|e| {
257 CamelError::InvalidUri(format!("invalid value for maxResponseBytes: {e}"))
258 })?,
259 None => 10 * 1024 * 1024, };
261
262 let auth = parse_auth_from_params(&parts.params)?;
263
264 let user_agent = parts.params.get("userAgent").cloned();
265
266 if parts.params.contains_key("cookieHandling") {
267 return Err(CamelError::InvalidUri(
268 "cookieHandling is not supported".into(),
269 ));
270 }
271
272 let bridge_endpoint = match parts.params.get("bridgeEndpoint") {
273 Some(v) => parse_bool_param_http(v).map_err(|e| {
274 CamelError::InvalidUri(format!("invalid value for bridgeEndpoint: {e}"))
275 })?,
276 None => false,
277 };
278
279 let connection_close = match parts.params.get("connectionClose") {
280 Some(v) => parse_bool_param_http(v).map_err(|e| {
281 CamelError::InvalidUri(format!("invalid value for connectionClose: {e}"))
282 })?,
283 None => false,
284 };
285
286 let skip_request_headers = parts
287 .params
288 .get("skipRequestHeaders")
289 .map(|v| {
290 v.split(',')
291 .map(str::trim)
292 .filter(|s| !s.is_empty())
293 .map(|s| s.to_ascii_lowercase())
294 .collect::<Vec<_>>()
295 })
296 .unwrap_or_default();
297
298 let skip_response_headers = parts
299 .params
300 .get("skipResponseHeaders")
301 .map(|v| {
302 v.split(',')
303 .map(str::trim)
304 .filter(|s| !s.is_empty())
305 .map(|s| s.to_ascii_lowercase())
306 .collect::<Vec<_>>()
307 })
308 .unwrap_or_default();
309
310 let follow_redirects = match parts.params.get("followRedirects") {
311 Some(v) => parse_bool_param_http(v).map_err(|e| {
312 CamelError::InvalidUri(format!("invalid value for followRedirects: {e}"))
313 })?,
314 None => false,
315 };
316
317 let max_redirects = match parts.params.get("maxRedirects") {
318 Some(v) => v.parse::<usize>().map_err(|e| {
319 CamelError::InvalidUri(format!("invalid value for maxRedirects: {e}"))
320 })?,
321 None => 10,
322 };
323
324 let query_params: HashMap<String, String> = parts
326 .params
327 .into_iter()
328 .filter(|(k, _)| !HTTP_CAMEL_OPTIONS.contains(&k.as_str()))
329 .collect();
330
331 Ok(Self {
332 base_url,
333 http_method,
334 throw_exception_on_failure,
335 ok_status_code_range,
336 response_timeout,
337 query_params,
338 allow_internal,
339 blocked_hosts,
340 max_body_size,
341 read_timeout_ms,
342 max_response_bytes,
343 auth,
344 token_provider: None,
345 user_agent,
346 bridge_endpoint,
347 connection_close,
348 skip_request_headers,
349 skip_response_headers,
350 follow_redirects,
351 max_redirects,
352 })
353 }
354}
355
356#[derive(Debug, Clone, UriConfig)]
363#[allow(dead_code)]
364#[uri_scheme = "http"]
365#[uri_config(
366 skip_impl,
367 metadata(
368 scheme = "http",
369 description = "HTTP client and server component",
370 producer,
371 consumer,
372 streaming
373 ),
374 crate = "camel_component_api"
375)]
376struct HttpEndpointUriConfig {
377 #[allow(dead_code)]
378 _base_url: String,
379
380 #[uri_param(
381 name = "httpMethod",
382 desc = "HTTP method. Defaults to CamelHttpMethod header or POST/GET"
383 )]
384 http_method: Option<String>,
385
386 #[uri_param(
387 name = "throwExceptionOnFailure",
388 default = "true",
389 desc = "Throw on non-2xx status"
390 )]
391 throw_exception_on_failure: bool,
392
393 #[uri_param(
394 name = "okStatusCodeRange",
395 default = "200-299",
396 desc = "Success status code range"
397 )]
398 ok_status_code_range: String,
399
400 #[uri_param(name = "responseTimeout", desc = "Response timeout in milliseconds")]
401 response_timeout: Option<u64>,
402
403 #[uri_param(
404 name = "allowInternal",
405 default = "false",
406 desc = "Allow private/internal network destinations (SSRF)"
407 )]
408 allow_internal: bool,
409
410 #[uri_param(name = "blockedHosts", desc = "Comma-separated blocked host list")]
411 blocked_hosts: Option<String>,
412
413 #[uri_param(
414 name = "maxBodySize",
415 default = "10485760",
416 desc = "Max request/response body bytes"
417 )]
418 max_body_size: u64,
419
420 #[uri_param(name = "readTimeout", desc = "Socket read timeout in milliseconds")]
421 read_timeout: Option<u64>,
422
423 #[uri_param(name = "maxResponseBytes", desc = "Max response body bytes")]
424 max_response_bytes: Option<u64>,
425
426 #[uri_param(
427 name = "authMethod",
428 kind = "enum:Basic,Bearer",
429 desc = "Authentication method"
430 )]
431 auth_method: Option<String>,
432
433 #[uri_param(name = "authUsername", secret, desc = "Basic auth username")]
434 auth_username: Option<String>,
435
436 #[uri_param(name = "authPassword", secret, desc = "Basic auth password")]
437 auth_password: Option<String>,
438
439 #[uri_param(name = "authBearerToken", secret, desc = "Bearer auth token")]
440 auth_bearer_token: Option<String>,
441
442 #[uri_param(name = "userAgent", desc = "User-Agent header")]
443 user_agent: Option<String>,
444
445 #[uri_param(
446 name = "bridgeEndpoint",
447 default = "false",
448 desc = "Bridge endpoint mode"
449 )]
450 bridge_endpoint: bool,
451
452 #[uri_param(
453 name = "connectionClose",
454 default = "false",
455 desc = "Send Connection: close"
456 )]
457 connection_close: bool,
458
459 #[uri_param(
460 name = "skipRequestHeaders",
461 desc = "Comma-separated request headers to skip"
462 )]
463 skip_request_headers: Option<String>,
464
465 #[uri_param(
466 name = "skipResponseHeaders",
467 desc = "Comma-separated response headers to skip"
468 )]
469 skip_response_headers: Option<String>,
470
471 #[uri_param(
472 name = "followRedirects",
473 default = "false",
474 desc = "Follow HTTP redirects"
475 )]
476 follow_redirects: bool,
477
478 #[uri_param(name = "maxRedirects", default = "10", desc = "Max redirect hops")]
479 max_redirects: u64,
480}
481
482impl HttpEndpointConfig {
483 pub fn metadata() -> ComponentMetadata {
486 HttpEndpointUriConfig::metadata()
487 }
488
489 pub fn uri_options() -> Vec<camel_api::component_metadata::UriOption> {
491 HttpEndpointUriConfig::uri_options()
492 }
493}
494
495fn parse_auth_from_params(params: &HashMap<String, String>) -> Result<HttpAuth, CamelError> {
496 let Some(method) = params.get("authMethod") else {
497 return Ok(HttpAuth::None);
498 };
499
500 if method.eq_ignore_ascii_case("none") {
501 return Ok(HttpAuth::None);
502 }
503
504 if method.eq_ignore_ascii_case("basic") {
505 let username = params.get("authUsername").cloned().ok_or_else(|| {
506 CamelError::InvalidUri("authUsername is required for authMethod=Basic".to_string())
507 })?;
508 let password = params.get("authPassword").cloned().ok_or_else(|| {
509 CamelError::InvalidUri("authPassword is required for authMethod=Basic".to_string())
510 })?;
511 return Ok(HttpAuth::Basic { username, password });
512 }
513
514 if method.eq_ignore_ascii_case("bearer") {
515 let token = params.get("authBearerToken").cloned().ok_or_else(|| {
516 CamelError::InvalidUri("authBearerToken is required for authMethod=Bearer".to_string())
517 })?;
518 return Ok(HttpAuth::Bearer { token });
519 }
520
521 Err(CamelError::InvalidUri(format!(
522 "invalid value for authMethod: {method} (expected None, Basic, or Bearer)"
523 )))
524}
525
526fn parse_bool_param_http(value: &str) -> Result<bool, CamelError> {
527 match value.to_ascii_lowercase().as_str() {
528 "true" | "1" | "yes" => Ok(true),
529 "false" | "0" | "no" => Ok(false),
530 _ => Err(CamelError::InvalidUri(format!(
531 "invalid boolean value: '{value}'"
532 ))),
533 }
534}
535
536impl HttpEndpointConfig {
537 pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
538 let parts = parse_uri(uri)?;
539 let mut endpoint = Self::from_components(parts.clone())?;
540 if endpoint.response_timeout.is_none() {
541 endpoint.response_timeout = Some(Duration::from_millis(config.response_timeout_ms));
542 }
543 if !parts.params.contains_key("allowInternal") {
544 endpoint.allow_internal = config.allow_internal;
545 }
546 if !parts.params.contains_key("blockedHosts") {
547 endpoint.blocked_hosts = config.blocked_hosts.clone();
548 }
549 if !parts.params.contains_key("maxBodySize") {
550 endpoint.max_body_size = config.max_body_size;
551 }
552 if !parts.params.contains_key("readTimeout") {
553 endpoint.read_timeout_ms = config.read_timeout_ms;
554 }
555 if !parts.params.contains_key("maxResponseBytes") {
556 endpoint.max_response_bytes = config.max_response_bytes;
557 }
558 if !parts.params.contains_key("okStatusCodeRange")
559 && let Some(range) = &config.ok_status_code_range
560 {
561 endpoint.ok_status_code_range = parse_ok_status_code_range(range)?;
562 }
563 if !parts.params.contains_key("followRedirects") {
564 endpoint.follow_redirects = config.follow_redirects;
565 }
566 if !parts.params.contains_key("maxRedirects") {
567 endpoint.max_redirects = config.max_redirects.unwrap_or(10);
568 }
569
570 Ok(endpoint)
571 }
572}
573
574#[derive(Debug, Clone)]
580pub struct HttpServerConfig {
581 pub scheme: String,
583 pub host: String,
585 pub port: u16,
587 pub path: String,
589 pub max_request_body: usize,
591 pub max_response_body: usize,
593 pub max_inflight_requests: usize,
595 pub method: Option<String>,
602 pub tls_config: Option<crate::config::ServerTlsConfig>,
605}
606
607impl UriConfig for HttpServerConfig {
608 fn scheme() -> &'static str {
610 "http"
611 }
612
613 fn from_uri(uri: &str) -> Result<Self, CamelError> {
614 let parts = parse_uri(uri)?;
615 Self::from_components(parts)
616 }
617
618 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
619 if parts.scheme != "http" && parts.scheme != "https" {
621 return Err(CamelError::InvalidUri(format!(
622 "expected scheme 'http' or 'https', got '{}'",
623 parts.scheme
624 )));
625 }
626
627 let authority_and_path = parts.path.trim_start_matches('/');
630
631 let (authority, path_suffix) = if let Some(idx) = authority_and_path.find('/') {
633 (&authority_and_path[..idx], &authority_and_path[idx..])
634 } else {
635 (authority_and_path, "/")
636 };
637
638 let path = if path_suffix.is_empty() {
639 "/"
640 } else {
641 path_suffix
642 }
643 .to_string();
644
645 let (host, port) = if let Some(colon) = authority.rfind(':') {
647 let port_str = &authority[colon + 1..];
648 match port_str.parse::<u16>() {
649 Ok(p) => (authority[..colon].to_string(), p),
650 Err(_) => {
651 return Err(CamelError::InvalidUri(format!(
652 "invalid port '{}' in authority",
653 port_str
654 )));
655 }
656 }
657 } else {
658 let default_port = if parts.scheme == "https" { 443 } else { 80 };
660 (authority.to_string(), default_port)
661 };
662
663 let max_request_body = parts
664 .params
665 .get("maxRequestBody")
666 .and_then(|v| v.parse::<usize>().ok())
667 .unwrap_or(2 * 1024 * 1024); let max_response_body = parts
670 .params
671 .get("maxResponseBody")
672 .and_then(|v| v.parse::<usize>().ok())
673 .unwrap_or(10 * 1024 * 1024); let max_inflight_requests = parts
676 .params
677 .get("maxInflightRequests")
678 .and_then(|v| v.parse::<usize>().ok())
679 .unwrap_or(1024);
680
681 let method = parts.params.get("httpMethod").map(|m| m.to_uppercase());
687
688 Ok(Self {
689 scheme: parts.scheme,
690 host,
691 port,
692 path,
693 max_request_body,
694 max_response_body,
695 max_inflight_requests,
696 method,
697 tls_config: {
698 let cert = parts.params.get("tlsCert").cloned();
699 let key = parts.params.get("tlsKey").cloned();
700 match (cert, key) {
701 (Some(c), Some(k)) => Some(crate::config::ServerTlsConfig {
702 cert_path: c,
703 key_path: k,
704 }),
705 (None, None) => None,
706 _ => None, }
708 },
709 })
710 }
711}
712
713impl HttpServerConfig {
714 pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
715 let parts = parse_uri(uri)?;
716 let mut server = Self::from_components(parts.clone())?;
717 if !parts.params.contains_key("maxRequestBody") {
718 server.max_request_body = config.max_request_body;
719 }
720 if !parts.params.contains_key("maxResponseBody") {
721 server.max_response_body = config.max_body_size;
723 }
724 Ok(server)
725 }
726}
727
728pub enum HttpReplyBody {
736 Bytes(bytes::Bytes),
737 Stream(BoxStream<'static, Result<bytes::Bytes, CamelError>>),
738}
739
740pub struct RequestEnvelope {
745 pub method: String,
746 pub path: String,
747 pub query: String,
748 pub headers: http::HeaderMap,
749 pub body: StreamBody,
750 pub path_params: std::collections::HashMap<String, String>,
756 pub reply_tx: tokio::sync::oneshot::Sender<HttpReply>,
757}
758
759pub struct HttpReply {
763 pub status: u16,
764 pub headers: Vec<(String, String)>,
765 pub body: HttpReplyBody,
766}
767
768type ServerKey = (String, u16);
773
774struct ServerHandle {
776 registry: HttpRouteRegistry,
777 max_request_body: usize,
778 max_response_body: usize,
779 max_inflight_requests: usize,
780 is_tls: bool,
781 tls_cert_path: Option<String>,
782 tls_key_path: Option<String>,
783 monitor_task: tokio::task::JoinHandle<()>,
786 tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
789 tls_source: Option<ServerTlsSource>,
790}
791
792pub struct ServerRegistry {
794 inner: Mutex<HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>>,
795}
796
797impl ServerRegistry {
798 pub fn global() -> &'static Self {
800 static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
801 INSTANCE.get_or_init(|| ServerRegistry {
802 inner: Mutex::new(HashMap::new()),
803 })
804 }
805
806 #[allow(clippy::too_many_arguments)]
809 pub async fn get_or_spawn(
810 &'static self,
811 host: &str,
812 port: u16,
813 max_request_body: usize,
814 max_response_body: usize,
815 max_inflight_requests: usize,
816 runtime: Arc<dyn RuntimeObservability>,
817 route_id: String,
818 tls_config: Option<crate::config::ServerTlsConfig>,
819 ) -> Result<HttpRouteRegistry, CamelError> {
820 let host_owned = host.to_string();
821
822 let cell = {
823 let mut guard = self.inner.lock().map_err(|_| {
824 CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
825 })?;
826 let key = (host.to_string(), port);
827 if let Some(existing) = guard.get(&key)
831 && let Some(handle) = existing.get()
832 && handle.monitor_task.is_finished()
833 {
834 if handle.is_tls {
837 let scheme = if handle.is_tls { "https" } else { "http" };
838 camel_component_api::tls_source::TlsReloadRegistry::global()
839 .unregister(scheme, host, port);
840 }
841 guard.remove(&key);
842 }
843 guard
844 .entry(key)
845 .or_insert_with(|| Arc::new(OnceCell::new()))
846 .clone()
847 };
848
849 if let Some(existing) = cell.get()
850 && existing.max_request_body != max_request_body
851 {
852 return Err(CamelError::EndpointCreationFailed(format!(
853 "incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
854 existing.max_request_body, max_request_body
855 )));
856 }
857
858 if let Some(existing) = cell.get()
859 && existing.max_response_body != max_response_body
860 {
861 return Err(CamelError::EndpointCreationFailed(format!(
862 "incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
863 existing.max_response_body, max_response_body
864 )));
865 }
866
867 if let Some(existing) = cell.get()
868 && existing.max_inflight_requests != max_inflight_requests
869 {
870 return Err(CamelError::EndpointCreationFailed(format!(
871 "incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
872 existing.max_inflight_requests, max_inflight_requests
873 )));
874 }
875
876 if let Some(existing) = cell.get()
878 && existing.is_tls != tls_config.is_some()
879 {
880 return Err(CamelError::EndpointCreationFailed(format!(
881 "incompatible TLS mode for shared server (host={host}, port={port}): existing is_tls={}, new has_tls={}",
882 existing.is_tls,
883 tls_config.is_some()
884 )));
885 }
886
887 if let (Some(existing), Some(new_tls)) = (cell.get(), &tls_config)
889 && (existing.tls_cert_path.as_deref() != Some(&new_tls.cert_path)
890 || existing.tls_key_path.as_deref() != Some(&new_tls.key_path))
891 {
892 return Err(CamelError::EndpointCreationFailed(format!(
893 "incompatible TLS cert/key for shared server (host={host}, port={port}): routes on the same TLS port must use the same cert and key"
894 )));
895 }
896
897 let handle = cell
898 .get_or_try_init(|| {
899 let rt = Arc::clone(&runtime);
900 let rid = route_id.clone();
901 async move {
902 let addr = format!("{host_owned}:{port}");
903 let listener = tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
904 CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
905 })?;
906 let registry = HttpRouteRegistry::new();
907 let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
908 let tls_rustls_cfg: Option<axum_server::tls_rustls::RustlsConfig>;
911 let tls_source: Option<ServerTlsSource>;
912 let server_task = if let Some(ref tls) = tls_config {
913 let rustls_config = load_tls_config(&tls.cert_path, &tls.key_path)?;
914 let source = ServerTlsSource {
915 cert_path: std::path::PathBuf::from(&tls.cert_path),
916 key_path: std::path::PathBuf::from(&tls.key_path),
917 client_ca_path: None,
918 };
919 let rustls_cfg = axum_server::tls_rustls::RustlsConfig::from_config(
923 std::sync::Arc::new(rustls_config),
924 );
925 tls_rustls_cfg = Some(rustls_cfg.clone());
926 tls_source = Some(source);
927 let std_listener = listener.into_std().map_err(|e| {
929 CamelError::EndpointCreationFailed(format!(
930 "TLS listener conversion: {e}"
931 ))
932 })?;
933 tokio::spawn(run_axum_server_tls(
934 std_listener,
935 rustls_cfg,
936 registry.clone(),
937 max_request_body,
938 max_response_body,
939 Arc::clone(&inflight),
940 Arc::clone(&rt),
941 rid.clone(),
942 ))
943 } else {
944 tls_rustls_cfg = None;
945 tls_source = None;
946 tokio::spawn(run_axum_server(
947 listener,
948 registry.clone(),
949 max_request_body,
950 max_response_body,
951 Arc::clone(&inflight),
952 Arc::clone(&rt),
953 rid.clone(),
954 ))
955 };
956 let addr_for_monitor = format!("{host_owned}:{port}");
957 let monitor_task = tokio::spawn(monitor_axum_task(
958 server_task,
959 addr_for_monitor,
960 Arc::clone(&rt),
961 rid,
962 ));
963 let handle = ServerHandle {
964 registry,
965 max_request_body,
966 max_response_body,
967 max_inflight_requests,
968 is_tls: tls_config.is_some(),
969 tls_cert_path: tls_config.as_ref().map(|t| t.cert_path.clone()),
970 tls_key_path: tls_config.as_ref().map(|t| t.key_path.clone()),
971 monitor_task,
972 tls_config: tls_rustls_cfg,
973 tls_source,
974 };
975 if let (Some(tls_cfg), Some(source)) =
980 (handle.tls_config.as_ref(), handle.tls_source.as_ref())
981 {
982 let handler = Arc::new(crate::tls_reload::HttpReloadHandler::new(
983 tls_cfg.clone(),
984 source.clone(),
985 host_owned.clone(),
986 port,
987 ));
988 camel_component_api::tls_source::TlsReloadRegistry::global()
989 .register(handler);
990 }
991 Ok::<ServerHandle, CamelError>(handle)
992 }
993 })
994 .await?;
995
996 Ok(handle.registry.clone())
997 }
998
999 pub async fn unregister(&self, host: &str, port: u16) {
1003 debug!(
1004 host = host,
1005 port = port,
1006 "consumer unregistered from HTTP server"
1007 );
1008 }
1009
1010 #[cfg(test)]
1017 pub fn reset() {
1018 let instance = Self::global();
1019 let mut guard = instance
1020 .inner
1021 .lock()
1022 .expect("ServerRegistry lock poisoned during test reset");
1023 guard.clear();
1024 }
1025}
1026
1027use axum::{
1032 Router,
1033 body::Body as AxumBody,
1034 extract::{Request, State},
1035 http::{Response, StatusCode},
1036 response::IntoResponse,
1037};
1038
1039#[derive(Clone)]
1040pub(crate) struct AppState {
1041 registry: HttpRouteRegistry,
1042 max_request_body: usize,
1043 max_response_body: usize,
1044 inflight: Arc<tokio::sync::Semaphore>,
1045}
1046
1047async fn run_axum_server(
1048 listener: tokio::net::TcpListener,
1049 registry: HttpRouteRegistry,
1050 max_request_body: usize,
1051 max_response_body: usize,
1052 inflight: Arc<tokio::sync::Semaphore>,
1053 runtime: Arc<dyn RuntimeObservability>,
1054 route_id: String,
1055) {
1056 let state = AppState {
1057 registry,
1058 max_request_body,
1059 max_response_body,
1060 inflight,
1061 };
1062 let app = Router::new().fallback(dispatch_handler).with_state(state);
1063
1064 axum::serve(listener, app).await.unwrap_or_else(|e| {
1065 runtime
1066 .metrics()
1067 .increment_errors(&route_id, "e:http:accept");
1068 tracing::error!(error = %e, "Axum server error");
1070 });
1071}
1072
1073#[allow(clippy::too_many_arguments)]
1074async fn run_axum_server_tls(
1075 listener: std::net::TcpListener,
1076 tls_cfg: axum_server::tls_rustls::RustlsConfig,
1077 registry: HttpRouteRegistry,
1078 max_request_body: usize,
1079 max_response_body: usize,
1080 inflight: Arc<tokio::sync::Semaphore>,
1081 runtime: Arc<dyn RuntimeObservability>,
1082 route_id: String,
1083) {
1084 let state = AppState {
1085 registry,
1086 max_request_body,
1087 max_response_body,
1088 inflight,
1089 };
1090 let app = Router::new().fallback(dispatch_handler).with_state(state);
1091
1092 axum_server::from_tcp_rustls(listener, tls_cfg)
1096 .serve(app.into_make_service())
1097 .await
1098 .unwrap_or_else(|e| {
1099 runtime
1100 .metrics()
1101 .increment_errors(&route_id, "e:http:accept-tls");
1102 tracing::error!(error = %e, "Axum TLS server error");
1104 });
1105}
1106
1107async fn monitor_axum_task(
1115 handle: tokio::task::JoinHandle<()>,
1116 addr: String,
1117 runtime: Arc<dyn RuntimeObservability>,
1118 route_id: String,
1119) {
1120 match handle.await {
1121 Ok(()) => {
1122 }
1124 Err(join_err) => {
1125 runtime
1126 .metrics()
1127 .increment_errors(&route_id, "e:http:server-task-exited");
1128 tracing::error!(
1130 addr = %addr,
1131 error = %join_err,
1132 "Axum server task exited unexpectedly — all routes on this port are now dead"
1133 );
1134 }
1135 }
1136}
1137
1138fn load_tls_config(
1141 cert_path: &str,
1142 key_path: &str,
1143) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
1144 use std::fs::File;
1145 use std::io::BufReader;
1146
1147 let cert_file = File::open(cert_path)
1148 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
1149 let key_file = File::open(key_path)
1150 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
1151
1152 let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
1153 .collect::<Result<Vec<_>, _>>()
1154 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
1155
1156 let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
1157 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
1158 .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
1159
1160 tokio_rustls::rustls::ServerConfig::builder()
1161 .with_no_client_auth()
1162 .with_single_cert(certs, key)
1163 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
1164}
1165
1166async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
1167 let path = req.uri().path().to_owned();
1168 let method = req.method().to_string();
1169
1170 let api_sender = {
1187 let inner = state.registry.inner.read().await;
1188 inner.api_routes.get(&path).cloned()
1189 }; let (rest_sender, path_params) = if api_sender.is_some() {
1192 (None, Default::default())
1194 } else {
1195 let inner = state.registry.inner.read().await;
1196 match rest_match::match_endpoint(&method, &path, &inner.rest_endpoints) {
1197 rest_match::MatchOutcome::Found(m) => (Some(m.payload), m.path_params),
1198 rest_match::MatchOutcome::Ambiguous => {
1199 tracing::warn!(
1205 method = %method,
1206 path = %path,
1207 "ambiguous REST template match — returning 500"
1208 );
1209 return Response::builder()
1210 .status(StatusCode::INTERNAL_SERVER_ERROR)
1211 .body(AxumBody::from("Internal Server Error"))
1212 .expect("infallible"); }
1214 rest_match::MatchOutcome::NotFound => (None, Default::default()),
1215 }
1216 }; let sender = api_sender.or(rest_sender);
1219
1220 if let Some(sender) = sender {
1221 let query = req.uri().query().unwrap_or("").to_string();
1222 let headers = req.headers().clone();
1223
1224 let content_length: Option<u64> = headers
1226 .get(http::header::CONTENT_LENGTH)
1227 .and_then(|v| v.to_str().ok())
1228 .and_then(|s| s.parse().ok());
1229
1230 if let Some(len) = content_length
1231 && len > state.max_request_body as u64
1232 {
1233 return Response::builder()
1234 .status(StatusCode::PAYLOAD_TOO_LARGE)
1235 .body(AxumBody::from("Request body exceeds configured limit"))
1236 .expect("infallible"); }
1238
1239 let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
1240 Ok(permit) => permit,
1241 Err(_) => {
1242 return Response::builder()
1243 .status(StatusCode::SERVICE_UNAVAILABLE)
1244 .body(AxumBody::from("Service Unavailable"))
1245 .expect("infallible"); }
1247 };
1248
1249 let content_type = headers
1251 .get(http::header::CONTENT_TYPE)
1252 .and_then(|v| v.to_str().ok())
1253 .map(|s| s.to_string());
1254
1255 let data_stream: BodyDataStream = req.into_body().into_data_stream();
1256 let mapped_stream = data_stream.map_err(|e| CamelError::Io(e.to_string()));
1257 let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(mapped_stream);
1258
1259 let stream_body = StreamBody {
1260 stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
1261 metadata: StreamMetadata {
1262 size_hint: content_length,
1263 content_type,
1264 origin: None,
1265 },
1266 };
1267
1268 let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
1269 let envelope = RequestEnvelope {
1270 method,
1271 path,
1272 query,
1273 headers,
1274 body: stream_body,
1275 path_params,
1276 reply_tx,
1277 };
1278
1279 if sender.send(envelope).await.is_err() {
1280 return Response::builder()
1281 .status(StatusCode::SERVICE_UNAVAILABLE)
1282 .body(AxumBody::from("Consumer unavailable"))
1283 .expect("infallible"); }
1285
1286 match reply_rx.await {
1287 Ok(reply) => {
1288 let reply = match reply.body {
1289 HttpReplyBody::Bytes(b)
1290 if exceeds_max_response_body(b.len(), state.max_response_body) =>
1291 {
1292 HttpReply {
1293 status: 500,
1294 headers: vec![],
1295 body: HttpReplyBody::Bytes(bytes::Bytes::from(
1296 "Response body exceeds configured limit",
1297 )),
1298 }
1299 }
1300 _ => reply,
1301 };
1302
1303 let status =
1304 StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1305 let mut builder = Response::builder().status(status);
1306 for (k, v) in &reply.headers {
1307 builder = builder.header(k.as_str(), v.as_str());
1308 }
1309 match reply.body {
1310 HttpReplyBody::Bytes(b) => {
1311 builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
1312 Response::builder()
1313 .status(StatusCode::INTERNAL_SERVER_ERROR)
1314 .body(AxumBody::from("Invalid response headers from consumer"))
1315 .expect("infallible") })
1317 }
1318 HttpReplyBody::Stream(stream) => builder
1319 .body(AxumBody::from_stream(stream))
1320 .unwrap_or_else(|_| {
1321 Response::builder()
1322 .status(StatusCode::INTERNAL_SERVER_ERROR)
1323 .body(AxumBody::from("Invalid response headers from consumer"))
1324 .expect("infallible") }),
1326 }
1327 }
1328 Err(_) => Response::builder()
1329 .status(StatusCode::INTERNAL_SERVER_ERROR)
1330 .body(AxumBody::from("Pipeline error"))
1331 .expect("infallible"), }
1333 } else {
1334 static_dispatch::dispatch_static(&state, req, &path).await
1336 }
1337}
1338
1339fn exceeds_max_response_body(len: usize, max: usize) -> bool {
1340 len > max
1341}
1342
1343fn title_case_header(name: &str) -> String {
1344 name.split('-')
1345 .map(|part| {
1346 let mut chars = part.chars();
1347 match chars.next() {
1348 None => String::new(),
1349 Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
1350 }
1351 })
1352 .collect::<Vec<_>>()
1353 .join("-")
1354}
1355
1356pub(crate) struct HttpKernelAuth {
1371 pub(crate) plan: camel_api::security_policy::RouteSecurityPlan,
1372 pub(crate) providers: Arc<camel_auth::ProviderRegistry>,
1373}
1374
1375impl HttpKernelAuth {
1376 pub(crate) fn from_security_context(
1381 ctx: &camel_component_api::SecurityContext,
1382 ) -> Option<Self> {
1383 Some(Self {
1384 plan: ctx.plan.clone()?,
1385 providers: ctx.providers.clone()?,
1386 })
1387 }
1388}
1389
1390fn envelope_channel_capacity(max_inflight_requests: usize) -> usize {
1404 max_inflight_requests.max(1)
1405}
1406
1407pub struct HttpConsumer {
1408 config: HttpServerConfig,
1409 runtime: Arc<dyn RuntimeObservability>,
1411 kernel: Option<Arc<HttpKernelAuth>>,
1415}
1416
1417impl HttpConsumer {
1418 pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
1419 Self {
1420 config,
1421 runtime,
1422 kernel: None,
1423 }
1424 }
1425}
1426
1427#[async_trait::async_trait]
1428impl Consumer for HttpConsumer {
1429 async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
1430 use camel_component_api::{Body, Exchange, Message};
1431
1432 let registry = ServerRegistry::global()
1433 .get_or_spawn(
1434 &self.config.host,
1435 self.config.port,
1436 self.config.max_request_body,
1437 self.config.max_response_body,
1438 self.config.max_inflight_requests,
1439 self.runtime.clone(),
1440 ctx.route_id().to_string(),
1441 self.config.tls_config.clone(),
1442 )
1443 .await?;
1444
1445 let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(
1449 envelope_channel_capacity(self.config.max_inflight_requests),
1450 );
1451 if let Some(method) = self.config.method.clone() {
1458 let segments = rest_match::parse_path_template(&self.config.path);
1459 registry
1460 .register_rest_endpoint(method, segments, env_tx)
1461 .await;
1462 } else {
1463 registry
1464 .register_api_route(self.config.path.clone(), env_tx)
1465 .await;
1466 }
1467
1468 ctx.mark_ready();
1477
1478 let path = self.config.path.clone();
1479 let registry_for_cleanup = registry.clone();
1480 let cancel_token = ctx.cancel_token();
1481 let kernel = self.kernel.clone();
1482 loop {
1483 tokio::select! {
1484 _ = ctx.cancelled() => {
1485 break;
1486 }
1487 envelope = env_rx.recv() => {
1488 let Some(envelope) = envelope else { break; };
1489
1490 let mut msg = Message::default();
1492
1493 msg.set_header("CamelHttpMethod",
1495 serde_json::Value::String(envelope.method.clone()));
1496 msg.set_header("CamelHttpPath",
1497 serde_json::Value::String(envelope.path.clone()));
1498 msg.set_header("CamelHttpQuery",
1499 serde_json::Value::String(envelope.query.clone()));
1500
1501 for (param_name, param_value) in &envelope.path_params {
1508 msg.set_header(
1509 format!("CamelHttpPath_{param_name}"),
1510 serde_json::Value::String(param_value.clone()),
1511 );
1512 }
1513
1514 for (k, v) in &envelope.headers {
1516 if let Ok(val_str) = v.to_str() {
1517 msg.set_header(
1518 title_case_header(k.as_str()),
1519 serde_json::Value::String(val_str.to_string()),
1520 );
1521 }
1522 }
1523
1524 msg.body = Body::Stream(envelope.body);
1527
1528 #[allow(unused_mut)]
1529 let mut exchange = Exchange::new(msg);
1530
1531 #[cfg(feature = "otel")]
1533 {
1534 let headers: HashMap<String, String> = envelope
1535 .headers
1536 .iter()
1537 .filter_map(|(k, v)| {
1538 Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1539 })
1540 .collect();
1541 camel_otel::extract_into_exchange(&mut exchange, &headers);
1542 }
1543
1544 let reply_tx = envelope.reply_tx;
1545 let sender = ctx.sender().clone();
1546 let path_clone = path.clone();
1547 let cancel = cancel_token.clone();
1548 let auth_headers = envelope.headers.clone();
1552 let auth_uri: http::Uri = {
1553 let full = if envelope.query.is_empty() {
1554 envelope.path.clone()
1555 } else {
1556 format!("{}?{}", envelope.path, envelope.query)
1557 };
1558 full.parse().unwrap_or_default()
1562 };
1563 let kernel = kernel.clone();
1564
1565 tokio::spawn(async move {
1585 if cancel.is_cancelled() {
1593 let _ = reply_tx.send(HttpReply {
1594 status: 503,
1595 headers: vec![],
1596 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
1597 });
1598 return;
1599 }
1600
1601 if let Some(kernel) = kernel.as_ref()
1610 && !matches!(
1611 kernel.plan.access_mode,
1612 camel_api::security_policy::AccessMode::Public
1613 )
1614 {
1615 let principal = match camel_auth::extract_token_multi(
1616 &auth_headers,
1617 &auth_uri,
1618 &kernel.plan.credential_sources,
1619 ) {
1620 Some(extracted) => {
1621 match camel_auth::kernel_authenticate(
1622 &kernel.plan,
1623 &kernel.providers,
1624 &extracted,
1625 )
1626 .await
1627 {
1628 Ok(principal) => principal,
1629 Err(e) => {
1630 tracing::warn!(
1632 path = %path_clone,
1633 error = %e,
1634 "HTTP request authentication failed"
1635 );
1636 let _ = reply_tx.send(pipeline_error_to_reply(
1637 e,
1638 &path_clone,
1639 ));
1640 return;
1641 }
1642 }
1643 }
1644 None => {
1645 tracing::warn!(
1647 path = %path_clone,
1648 "HTTP request rejected: no credential found in any source"
1649 );
1650 let _ = reply_tx.send(pipeline_error_to_reply(
1651 CamelError::Unauthenticated(
1652 "no credential found in any source".to_string(),
1653 ),
1654 &path_clone,
1655 ));
1656 return;
1657 }
1658 };
1659 camel_auth::install_carrier(&mut exchange, &principal);
1660 }
1661
1662 let (tx, rx) = tokio::sync::oneshot::channel();
1664 let envelope = camel_component_api::consumer::ExchangeEnvelope {
1665 exchange,
1666 reply_tx: Some(tx),
1667 };
1668
1669 let result = match sender.send(envelope).await {
1670 Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
1671 Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
1672 }
1673 .and_then(|r| r);
1674
1675 let reply = match result {
1676 Ok(out) => {
1677 let status = out
1678 .input
1679 .header("CamelHttpResponseCode")
1680 .and_then(|v| {
1681 let raw = v.as_u64()
1682 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
1683 let code = raw as u16;
1684 (100..1000).contains(&code).then_some(code)
1685 })
1686 .unwrap_or(200);
1687
1688 let user_content_type = out
1689 .input
1690 .header("Content-Type")
1691 .and_then(|v| v.as_str().map(|s| s.to_string()));
1692
1693 let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
1694 Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
1695 Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
1696 Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
1697 Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
1698 v.to_string().into_bytes(),
1699 )), Some("application/json".to_string())),
1700 Body::Stream(s) => {
1701 let ct = s.metadata.content_type.clone();
1702 match s.stream.lock().await.take() {
1703 Some(stream) => (
1704 HttpReplyBody::Stream(stream),
1705 ct,
1706 ),
1707 None => {
1708 tracing::error!(
1710 "Body::Stream already consumed before HTTP reply — returning 500"
1711 );
1712 let error_reply = HttpReply {
1713 status: 500,
1714 headers: vec![],
1715 body: HttpReplyBody::Bytes(bytes::Bytes::new()),
1716 };
1717 if reply_tx.send(error_reply).is_err() {
1718 debug!("reply_tx dropped before error reply could be sent");
1719 }
1720 return;
1721 }
1722 }
1723 }
1724 _ => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
1726 };
1727
1728 let resp_headers = select_response_headers(
1729 &out.input.headers,
1730 user_content_type,
1731 inferred_content_type,
1732 );
1733
1734 HttpReply {
1735 status,
1736 headers: resp_headers,
1737 body: reply_body,
1738 }
1739 }
1740 Err(e) => {
1741 pipeline_error_to_reply(e, &path_clone)
1742 }
1743 };
1744
1745 let _ = reply_tx.send(reply);
1747 });
1748 }
1749 }
1750 }
1751
1752 if let Some(method) = &self.config.method {
1757 registry_for_cleanup
1758 .unregister_rest_endpoint(method, &path)
1759 .await;
1760 } else {
1761 registry_for_cleanup.unregister_api_route(&path).await;
1762 }
1763
1764 ServerRegistry::global()
1768 .unregister(&self.config.host, self.config.port)
1769 .await;
1770
1771 Ok(())
1772 }
1773
1774 async fn stop(&mut self) -> Result<(), CamelError> {
1775 Ok(())
1776 }
1777
1778 fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
1779 camel_component_api::ConcurrencyModel::Concurrent { max: None }
1780 }
1781
1782 fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
1789 camel_component_api::ConsumerStartupMode::Explicit
1790 }
1791
1792 fn set_security_context(&mut self, ctx: camel_component_api::SecurityContext) {
1795 self.kernel = HttpKernelAuth::from_security_context(&ctx).map(Arc::new);
1796 }
1797}
1798
1799pub struct HttpComponent {
1804 config: HttpConfig,
1805 pinned_cache: std::sync::Arc<PinnedClientCache>,
1806 client: reqwest::Client,
1807}
1808
1809#[cfg(test)]
1810thread_local! {
1811 static BUILD_CLIENT_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
1812}
1813
1814pub(crate) fn build_client(
1815 config: &HttpConfig,
1816 resolve_override: Option<(&str, &[std::net::SocketAddr])>,
1817) -> reqwest::Client {
1818 #[cfg(test)]
1819 BUILD_CLIENT_CALLS.with(|c| c.set(c.get() + 1));
1820
1821 let mut builder = reqwest::Client::builder()
1822 .no_proxy() .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
1824 .pool_max_idle_per_host(config.pool_max_idle_per_host)
1825 .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
1826
1827 builder = builder.redirect(reqwest::redirect::Policy::none());
1831
1832 if let Some((host, addrs)) = resolve_override {
1833 builder = builder.resolve_to_addrs(host, addrs);
1834 }
1835
1836 if let Some(tls) = &config.tls
1837 && tls.enabled
1838 {
1839 if tls.insecure || !tls.verify_peer {
1840 tracing::warn!("HTTP TLS verification disabled — connections are vulnerable to MitM");
1842 builder = builder.danger_accept_invalid_certs(true);
1843 }
1844
1845 if let Some(ca_path) = &tls.ca_cert_path
1846 && let Ok(ca_bytes) = std::fs::read(ca_path)
1847 {
1848 let cert = reqwest::Certificate::from_pem(&ca_bytes)
1849 .or_else(|_| reqwest::Certificate::from_der(&ca_bytes));
1850 if let Ok(ca_cert) = cert {
1851 builder = builder.add_root_certificate(ca_cert);
1852 }
1853 }
1854
1855 if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path)
1856 && let (Ok(cert_bytes), Ok(key_bytes)) =
1857 (std::fs::read(cert_path), std::fs::read(key_path))
1858 {
1859 let mut identity_pem = cert_bytes;
1860 identity_pem.extend_from_slice(&key_bytes);
1861 if let Ok(identity) = reqwest::Identity::from_pem(&identity_pem) {
1862 builder = builder.identity(identity);
1863 }
1864 }
1865 }
1866
1867 builder
1868 .build()
1869 .expect("reqwest::Client::build() with valid config should not fail") }
1871
1872#[cfg(test)]
1873pub(crate) fn build_client_call_count() -> u64 {
1874 BUILD_CLIENT_CALLS.with(|c| c.get())
1875}
1876
1877impl HttpComponent {
1878 pub fn new() -> Self {
1879 let config = HttpConfig::default();
1880 Self {
1881 client: build_client(&config, None),
1882 config,
1883 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
1884 PINNED_CLIENT_TTL,
1885 PINNED_CLIENT_MAX_ENTRIES,
1886 )),
1887 }
1888 }
1889
1890 pub fn with_config(config: HttpConfig) -> Self {
1891 Self {
1892 client: build_client(&config, None),
1893 config,
1894 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
1895 PINNED_CLIENT_TTL,
1896 PINNED_CLIENT_MAX_ENTRIES,
1897 )),
1898 }
1899 }
1900
1901 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
1902 match config {
1903 Some(cfg) => Self::with_config(cfg),
1904 None => Self::new(),
1905 }
1906 }
1907}
1908
1909impl Default for HttpComponent {
1910 fn default() -> Self {
1911 Self::new()
1912 }
1913}
1914
1915impl Component for HttpComponent {
1916 fn scheme(&self) -> &str {
1917 "http"
1918 }
1919
1920 fn metadata(&self) -> ComponentMetadata {
1921 HttpEndpointConfig::metadata()
1922 }
1923
1924 fn create_endpoint(
1925 &self,
1926 uri: &str,
1927 ctx: &dyn camel_component_api::ComponentContext,
1928 ) -> Result<Box<dyn Endpoint>, CamelError> {
1929 self.config.validate()?;
1930 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
1931 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
1932 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
1933 server_config.host.clone(),
1934 server_config.port,
1935 )));
1936 self.pinned_cache
1937 .wire(HttpComponentKind::Http, ctx.metrics());
1938 Ok(Box::new(HttpEndpoint {
1939 uri: uri.to_string(),
1940 config,
1941 server_config,
1942 client: self.client.clone(),
1943 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
1944 http_config: self.config.clone(),
1945 }))
1946 }
1947}
1948
1949pub struct HttpsComponent {
1950 config: HttpConfig,
1951 pinned_cache: std::sync::Arc<PinnedClientCache>,
1952 client: reqwest::Client,
1953}
1954
1955impl HttpsComponent {
1956 pub fn new() -> Self {
1957 let config = HttpConfig::default();
1958 Self {
1959 client: build_client(&config, None),
1960 config,
1961 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
1962 PINNED_CLIENT_TTL,
1963 PINNED_CLIENT_MAX_ENTRIES,
1964 )),
1965 }
1966 }
1967
1968 pub fn with_config(config: HttpConfig) -> Self {
1969 Self {
1970 client: build_client(&config, None),
1971 config,
1972 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
1973 PINNED_CLIENT_TTL,
1974 PINNED_CLIENT_MAX_ENTRIES,
1975 )),
1976 }
1977 }
1978
1979 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
1980 match config {
1981 Some(cfg) => Self::with_config(cfg),
1982 None => Self::new(),
1983 }
1984 }
1985}
1986
1987impl Default for HttpsComponent {
1988 fn default() -> Self {
1989 Self::new()
1990 }
1991}
1992
1993impl Component for HttpsComponent {
1994 fn scheme(&self) -> &str {
1995 "https"
1996 }
1997
1998 fn metadata(&self) -> ComponentMetadata {
1999 let mut meta = HttpEndpointConfig::metadata();
2002 meta.scheme = "https".to_string();
2003 meta.description = "HTTPS client and server component (TLS over HTTP)".to_string();
2004 meta
2005 }
2006
2007 fn create_endpoint(
2008 &self,
2009 uri: &str,
2010 ctx: &dyn camel_component_api::ComponentContext,
2011 ) -> Result<Box<dyn Endpoint>, CamelError> {
2012 self.config.validate()?;
2013 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2014 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2015 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
2016 server_config.host.clone(),
2017 server_config.port,
2018 )));
2019 self.pinned_cache
2020 .wire(HttpComponentKind::Https, ctx.metrics());
2021 Ok(Box::new(HttpEndpoint {
2022 uri: uri.to_string(),
2023 config,
2024 server_config,
2025 client: self.client.clone(),
2026 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2027 http_config: self.config.clone(),
2028 }))
2029 }
2030}
2031
2032struct HttpEndpoint {
2037 uri: String,
2038 config: HttpEndpointConfig,
2039 server_config: HttpServerConfig,
2040 client: reqwest::Client,
2041 pinned_cache: std::sync::Arc<PinnedClientCache>,
2042 http_config: HttpConfig,
2043}
2044
2045impl Endpoint for HttpEndpoint {
2046 fn uri(&self) -> &str {
2047 &self.uri
2048 }
2049
2050 fn create_consumer(
2051 &self,
2052 rt: Arc<dyn camel_component_api::RuntimeObservability>,
2053 ) -> Result<Box<dyn Consumer>, CamelError> {
2054 let scheme_is_https = self.server_config.scheme == "https";
2057 let has_tls = self.server_config.tls_config.is_some();
2058
2059 if scheme_is_https && !has_tls {
2060 return Err(CamelError::EndpointCreationFailed(
2061 "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
2062 ));
2063 }
2064 if !scheme_is_https && has_tls {
2065 return Err(CamelError::EndpointCreationFailed(
2066 "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
2067 ));
2068 }
2069 Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
2070 }
2071
2072 fn create_producer(
2073 &self,
2074 rt: Arc<dyn camel_component_api::RuntimeObservability>,
2075 _ctx: &ProducerContext,
2076 ) -> Result<BoxProcessor, CamelError> {
2077 let producer = HttpProducer {
2078 config: Arc::new(self.config.clone()),
2079 client: self.client.clone(),
2080 pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2081 http_config: Arc::new(self.http_config.clone()),
2082 runtime: rt,
2083 };
2084 if let Some(ref provider) = self.config.token_provider {
2085 let layer = BearerTokenLayer::new(Arc::clone(provider));
2086 Ok(BoxProcessor::new(layer.layer(producer)))
2087 } else {
2088 Ok(BoxProcessor::new(producer))
2089 }
2090 }
2091}
2092
2093#[derive(Clone)]
2098struct HttpProducer {
2099 config: Arc<HttpEndpointConfig>,
2100 client: reqwest::Client,
2101 pinned_cache: std::sync::Arc<PinnedClientCache>,
2102 http_config: Arc<HttpConfig>,
2103 runtime: Arc<dyn RuntimeObservability>,
2109}
2110
2111impl HttpProducer {
2112 fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
2113 if let Some(ref method) = config.http_method {
2114 return method.to_uppercase();
2115 }
2116 if let Some(method) = exchange
2117 .input
2118 .header("CamelHttpMethod")
2119 .and_then(|v| v.as_str())
2120 {
2121 return method.to_uppercase();
2122 }
2123 if !exchange.input.body.is_empty() {
2124 return "POST".to_string();
2125 }
2126 "GET".to_string()
2127 }
2128
2129 fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
2130 if config.bridge_endpoint {
2136 let url = config.base_url.clone();
2137 if config.query_params.is_empty() {
2138 return url;
2139 }
2140 let mut parsed = url::Url::parse(&url).expect("base URL must be valid"); for (k, v) in &config.query_params {
2142 parsed.query_pairs_mut().append_pair(k, v);
2143 }
2144 return parsed.to_string();
2145 }
2146
2147 if let Some(uri) = exchange
2148 .input
2149 .header("CamelHttpUri")
2150 .and_then(|v| v.as_str())
2151 {
2152 let mut url = uri.to_string();
2153 if let Some(path) = exchange
2154 .input
2155 .header("CamelHttpPath")
2156 .and_then(|v| v.as_str())
2157 {
2158 if !url.ends_with('/') && !path.starts_with('/') {
2159 url.push('/');
2160 }
2161 url.push_str(path);
2162 }
2163 if let Some(query) = exchange
2164 .input
2165 .header("CamelHttpQuery")
2166 .and_then(|v| v.as_str())
2167 {
2168 url.push('?');
2169 url.push_str(query);
2170 }
2171 return url;
2172 }
2173
2174 let mut url = config.base_url.clone();
2175
2176 if let Some(path) = exchange
2177 .input
2178 .header("CamelHttpPath")
2179 .and_then(|v| v.as_str())
2180 {
2181 if !url.ends_with('/') && !path.starts_with('/') {
2182 url.push('/');
2183 }
2184 url.push_str(path);
2185 }
2186
2187 if let Some(query) = exchange
2188 .input
2189 .header("CamelHttpQuery")
2190 .and_then(|v| v.as_str())
2191 {
2192 url.push('?');
2193 url.push_str(query);
2194 } else if !config.query_params.is_empty() {
2195 let mut parsed = url::Url::parse(&url).expect("base URL must be valid"); for (k, v) in &config.query_params {
2197 parsed.query_pairs_mut().append_pair(k, v);
2198 }
2199 url = parsed.to_string();
2200 }
2201
2202 url
2203 }
2204
2205 fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
2206 status >= range.0 && status <= range.1
2207 }
2208
2209 fn is_entity_enclosing(method: &str) -> bool {
2214 matches!(method, "POST" | "PUT" | "PATCH")
2215 }
2216}
2217
2218impl Service<Exchange> for HttpProducer {
2219 type Response = Exchange;
2220 type Error = CamelError;
2221 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
2222
2223 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
2224 Poll::Ready(Ok(()))
2225 }
2226
2227 fn call(&mut self, exchange: Exchange) -> Self::Future {
2228 let config = self.config.clone();
2229 let shared_client = self.client.clone();
2230 let pinned_cache = std::sync::Arc::clone(&self.pinned_cache);
2231 let http_config = self.http_config.clone();
2232 let component_metrics = self.runtime.component_metrics();
2233
2234 Box::pin(async move {
2235 let mut exchange = exchange;
2236 let outcome = async {
2237 let method_str = HttpProducer::resolve_method(&exchange, &config);
2238 let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
2243 let url = HttpProducer::resolve_url(&exchange, &config);
2244
2245 ssrf::validate_url_for_ssrf(&url, &config)?;
2247
2248 let resolved =
2256 ssrf::resolve_initial_url_for_ssrf(&url, config.allow_internal).await?;
2257 let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
2258 pinned_cache
2259 .get_or_build(host.as_str(), addrs, || {
2260 build_client(&http_config, Some((host.as_str(), addrs)))
2261 })
2262 .await
2263 } else {
2264 shared_client.clone()
2265 };
2266
2267 debug!(
2268 correlation_id = %exchange.correlation_id(),
2269 method = %method_str,
2270 url = %url,
2271 "HTTP request"
2272 );
2273
2274 let method = method_str.parse::<reqwest::Method>().map_err(|e| {
2275 CamelError::ProcessorError(format!(
2276 "Invalid HTTP method '{}': {}",
2277 method_str, e
2278 ))
2279 })?;
2280
2281 let mut collected_headers: Vec<(
2283 reqwest::header::HeaderName,
2284 reqwest::header::HeaderValue,
2285 )> = Vec::new();
2286
2287 if let Some(user_agent) = &config.user_agent
2288 && !config.bridge_endpoint
2289 && let Ok(val) = reqwest::header::HeaderValue::from_str(user_agent)
2290 {
2291 collected_headers.push((reqwest::header::USER_AGENT, val));
2292 }
2293
2294 #[cfg(feature = "otel")]
2296 let should_inject_otel = !config.bridge_endpoint;
2297 #[cfg(feature = "otel")]
2298 if should_inject_otel {
2299 let mut otel_headers = HashMap::new();
2300 camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
2301 for (k, v) in otel_headers {
2302 if let (Ok(name), Ok(val)) = (
2303 reqwest::header::HeaderName::from_bytes(k.as_bytes()),
2304 reqwest::header::HeaderValue::from_str(&v),
2305 ) {
2306 collected_headers.push((name, val));
2307 }
2308 }
2309 }
2310
2311 let conn_tokens = header_policy::connection_tokens(
2312 exchange
2313 .input
2314 .headers
2315 .iter()
2316 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
2317 .filter_map(|(_, v)| v.as_str()),
2318 );
2319
2320 for (key, value) in &exchange.input.headers {
2321 if !key.starts_with("Camel")
2322 && !config
2323 .skip_request_headers
2324 .iter()
2325 .any(|h| h.eq_ignore_ascii_case(key))
2326 && !header_policy::excluded_outbound(key, &conn_tokens)
2327 && let Some(val_str) = value.as_str()
2328 && let (Ok(name), Ok(val)) = (
2329 reqwest::header::HeaderName::from_bytes(key.as_bytes()),
2330 reqwest::header::HeaderValue::from_str(val_str),
2331 )
2332 {
2333 collected_headers.push((name, val));
2334 }
2335 }
2336
2337 if !config.bridge_endpoint {
2339 match &config.auth {
2340 HttpAuth::None => {}
2341 HttpAuth::Basic { username, password } => {
2342 use base64::Engine;
2343 let credentials = format!("{username}:{password}");
2345 let encoded =
2346 base64::engine::general_purpose::STANDARD.encode(credentials);
2347 if let Ok(val) =
2348 reqwest::header::HeaderValue::from_str(&format!("Basic {encoded}"))
2349 {
2350 collected_headers.push((reqwest::header::AUTHORIZATION, val));
2351 }
2352 }
2353 HttpAuth::Bearer { token } => {
2354 let bearer = format!("Bearer {token}");
2356 if let Ok(val) = reqwest::header::HeaderValue::from_str(&bearer) {
2357 collected_headers.push((reqwest::header::AUTHORIZATION, val));
2358 }
2359 }
2360 }
2361
2362 if config.connection_close
2363 && let Ok(val) = reqwest::header::HeaderValue::from_str("close")
2364 {
2365 collected_headers.push((reqwest::header::CONNECTION, val));
2366 }
2367 }
2368
2369 let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
2371 let materialized_body: Option<Vec<u8>> = if is_stream_body {
2372 if suppress_body {
2373 std::mem::take(&mut exchange.input.body);
2381 tracing::warn!(
2383 correlation_id = %exchange.correlation_id(),
2384 method = %method_str,
2385 "dropping request body for non-entity-enclosing HTTP method"
2386 );
2387 }
2388 None } else {
2390 let body = std::mem::take(&mut exchange.input.body);
2391 let bytes = body.into_bytes(config.max_body_size).await?;
2392 if bytes.is_empty() {
2393 None
2395 } else if suppress_body {
2396 tracing::warn!(
2398 correlation_id = %exchange.correlation_id(),
2399 method = %method_str,
2400 "dropping request body for non-entity-enclosing HTTP method"
2401 );
2402 None
2403 } else {
2404 Some(bytes.to_vec())
2405 }
2406 };
2407
2408 let response = if config.follow_redirects && !is_stream_body {
2409 ssrf::send_with_ssrf_safe_redirects(
2415 &client,
2416 &shared_client,
2417 &pinned_cache,
2418 &http_config,
2419 &config,
2420 method,
2421 &url,
2422 collected_headers,
2423 materialized_body,
2424 config.max_redirects,
2425 config.response_timeout,
2426 )
2427 .await?
2428 } else {
2429 let mut request = client.request(method, &url);
2431
2432 if let Some(timeout) = config.response_timeout {
2433 request = request.timeout(timeout);
2434 }
2435
2436 for (name, value) in &collected_headers {
2437 request = request.header(name, value);
2438 }
2439
2440 if is_stream_body {
2441 if let Body::Stream(ref s) = exchange.input.body {
2442 let mut stream_lock = s.stream.lock().await;
2443 if let Some(stream) = stream_lock.take() {
2444 request = request.body(reqwest::Body::wrap_stream(stream));
2445 } else {
2446 return Err(CamelError::AlreadyConsumed);
2447 }
2448 }
2449 } else if let Some(ref body_bytes) = materialized_body {
2450 request = request.body(body_bytes.clone());
2451 }
2452
2453 request.send().await.map_err(|e| {
2454 CamelError::ProcessorError(format!("HTTP request failed: {e}"))
2455 })?
2456 };
2457
2458 let status_code = response.status().as_u16();
2459 let status_text = response
2460 .status()
2461 .canonical_reason()
2462 .unwrap_or("Unknown")
2463 .to_string();
2464
2465 for (key, value) in response.headers() {
2466 if config
2467 .skip_response_headers
2468 .iter()
2469 .any(|h| h.eq_ignore_ascii_case(key.as_str()))
2470 {
2471 continue;
2472 }
2473 if let Ok(val_str) = value.to_str() {
2474 exchange.input.set_header(
2475 title_case_header(key.as_str()),
2476 serde_json::Value::String(val_str.to_string()),
2477 );
2478 }
2479 }
2480
2481 exchange.input.set_header(
2482 "CamelHttpResponseCode",
2483 serde_json::Value::Number(status_code.into()),
2484 );
2485 exchange.input.set_header(
2486 "CamelHttpResponseText",
2487 serde_json::Value::String(status_text.clone()),
2488 );
2489
2490 let read_timeout = Duration::from_millis(config.read_timeout_ms);
2492 let response_body = tokio::time::timeout(read_timeout, async {
2493 if let Some(content_len) = response.content_length()
2495 && content_len > config.max_response_bytes as u64
2496 {
2497 return Err(CamelError::ProcessorError(format!(
2498 "Response body too large: {} bytes exceeds limit of {} bytes",
2499 content_len, config.max_response_bytes
2500 )));
2501 }
2502 use futures::TryStreamExt;
2504 let mut stream = response.bytes_stream();
2505 let mut total: usize = 0;
2506 let mut collected = Vec::new();
2507 while let Some(chunk) = stream.try_next().await.map_err(|e| {
2508 CamelError::ProcessorError(format!("Failed to read response body: {e}"))
2509 })? {
2510 total += chunk.len();
2511 if total > config.max_response_bytes {
2512 return Err(CamelError::ProcessorError(format!(
2513 "Response body too large: {} bytes exceeds limit of {} bytes",
2514 total, config.max_response_bytes
2515 )));
2516 }
2517 collected.push(chunk);
2518 }
2519 let mut result = bytes::BytesMut::with_capacity(total);
2520 for chunk in collected {
2521 result.extend_from_slice(&chunk);
2522 }
2523 Ok::<bytes::Bytes, CamelError>(result.freeze())
2524 })
2525 .await
2526 .map_err(|_| {
2527 CamelError::ProcessorError(format!(
2528 "Read timeout after {}ms",
2529 config.read_timeout_ms
2530 ))
2531 })??;
2532
2533 if config.throw_exception_on_failure
2534 && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
2535 {
2536 return Err(CamelError::HttpOperationFailed {
2537 method: method_str,
2538 url,
2539 status_code,
2540 status_text,
2541 response_body: Some(String::from_utf8_lossy(&response_body).to_string()),
2542 });
2543 }
2544
2545 if !response_body.is_empty() {
2546 exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
2547 }
2548
2549 debug!(
2550 correlation_id = %exchange.correlation_id(),
2551 status = status_code,
2552 url = %url,
2553 "HTTP response"
2554 );
2555 Ok(exchange)
2556 }
2557 .await;
2558 component_metrics.observe("http", "request", outcome.is_err());
2565 outcome
2566 })
2567 }
2568}
2569
2570#[cfg(test)]
2580pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
2581
2582fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
2590 match e {
2591 CamelError::Unauthenticated(msg) => {
2592 tracing::warn!(error = %msg, path = %path, "Authentication failed");
2593 HttpReply {
2594 status: 401,
2595 headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
2596 body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
2597 }
2598 }
2599 CamelError::Unauthorized(msg) => {
2600 tracing::warn!(error = %msg, path = %path, "Authorization failed");
2601 HttpReply {
2602 status: 403,
2603 headers: vec![],
2604 body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
2605 }
2606 }
2607 CamelError::TypeConversionFailed(msg) => {
2608 tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
2609 let body = serde_json::to_string(&serde_json::json!({
2610 "error": "bad_request",
2611 "message": msg,
2612 }))
2613 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
2615 status: 400,
2616 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
2617 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
2618 }
2619 }
2620 CamelError::ValidationError(msg) => {
2621 tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
2622 let body = serde_json::to_string(&serde_json::json!({
2623 "error": "validation_error",
2624 "message": msg,
2625 }))
2626 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
2628 status: 400,
2629 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
2630 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
2631 }
2632 }
2633 CamelError::ConsumerStopping => {
2634 tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
2635 HttpReply {
2636 status: 503,
2637 headers: vec![],
2638 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
2639 }
2640 }
2641 e => {
2642 tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
2644 HttpReply {
2645 status: 500,
2646 headers: vec![],
2647 body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
2648 }
2649 }
2650 }
2651}
2652
2653fn select_response_headers(
2663 headers: &HashMap<String, serde_json::Value>,
2664 user_content_type: Option<String>,
2665 inferred_content_type: Option<String>,
2666) -> Vec<(String, String)> {
2667 let conn_tokens = header_policy::connection_tokens(
2668 headers
2669 .iter()
2670 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
2671 .filter_map(|(_, v)| v.as_str()),
2672 );
2673 let mut selected: Vec<(String, String)> = headers
2674 .iter()
2675 .filter(|(k, _)| !k.starts_with("Camel"))
2676 .filter(|(k, _)| !header_policy::excluded_response(k, &conn_tokens))
2677 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
2678 .collect();
2679 if let Some(ct) = user_content_type.or(inferred_content_type) {
2680 selected.push(("Content-Type".to_string(), ct));
2681 }
2682 selected
2683}
2684
2685#[cfg(test)]
2686mod tests {
2687 use camel_component_api::test_support::NoopRuntimeObservability;
2688
2689 fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2693 std::sync::Arc::new(NoopRuntimeObservability)
2694 }
2695 fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2696 std::sync::Arc::new(NoopRuntimeObservability)
2697 }
2698 fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2699 std::sync::Arc::new(NoopRuntimeObservability)
2700 }
2701
2702 use super::*;
2703 use crate::rest_match::PathSegment;
2704 use camel_component_api::{Message, NoOpComponentContext};
2705 use std::sync::Arc;
2706 use std::time::Duration;
2707
2708 fn test_producer_ctx() -> ProducerContext {
2709 ProducerContext::new()
2710 }
2711
2712 #[test]
2713 fn test_http_config_defaults() {
2714 let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
2715 assert_eq!(config.base_url, "http://localhost:8080/api");
2716 assert!(config.http_method.is_none());
2717 assert!(config.throw_exception_on_failure);
2718 assert_eq!(config.ok_status_code_range, (200, 299));
2719 assert!(config.response_timeout.is_none());
2720 assert!(matches!(config.auth, HttpAuth::None));
2721 assert!(!config.bridge_endpoint);
2722 assert!(!config.connection_close);
2723 }
2724
2725 #[test]
2726 fn test_http_config_scheme() {
2727 assert_eq!(HttpEndpointConfig::scheme(), "http");
2729 }
2730
2731 #[test]
2732 fn test_http_config_from_components() {
2733 let components = camel_component_api::UriComponents {
2735 scheme: "https".to_string(),
2736 path: "//api.example.com/v1".to_string(),
2737 params: std::collections::HashMap::from([(
2738 "httpMethod".to_string(),
2739 "POST".to_string(),
2740 )]),
2741 };
2742 let config = HttpEndpointConfig::from_components(components).unwrap();
2743 assert_eq!(config.base_url, "https://api.example.com/v1");
2744 assert_eq!(config.http_method, Some("POST".to_string()));
2745 }
2746
2747 #[test]
2748 fn test_http_config_with_options() {
2749 let config = HttpEndpointConfig::from_uri(
2750 "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
2751 ).unwrap();
2752 assert_eq!(config.base_url, "https://api.example.com/v1");
2753 assert_eq!(config.http_method, Some("PUT".to_string()));
2754 assert!(!config.throw_exception_on_failure);
2755 assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
2756 }
2757
2758 #[test]
2759 fn test_http_endpoint_config_auth_and_headers_options() {
2760 let config = HttpEndpointConfig::from_uri(
2761 "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
2762 )
2763 .unwrap();
2764
2765 assert!(matches!(
2766 config.auth,
2767 HttpAuth::Basic { username, password } if username == "u" && password == "p"
2768 ));
2769 assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
2770 assert!(config.bridge_endpoint);
2771 assert!(config.connection_close);
2772 assert_eq!(
2773 config.skip_request_headers,
2774 vec!["authorization".to_string(), "x-secret".to_string()]
2775 );
2776 assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
2777 }
2778
2779 #[test]
2780 fn test_http_endpoint_config_bearer_auth() {
2781 let config = HttpEndpointConfig::from_uri(
2782 "http://localhost/api?authMethod=Bearer&authBearerToken=t",
2783 )
2784 .unwrap();
2785 assert!(matches!(
2786 config.auth,
2787 HttpAuth::Bearer { token } if token == "t"
2788 ));
2789 }
2790
2791 #[test]
2792 fn rejects_cookie_handling_inmemory() {
2793 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
2794 match result {
2795 Err(CamelError::InvalidUri(msg)) => {
2796 assert!(
2797 msg.contains("cookieHandling is not supported"),
2798 "expected rejection message, got: {msg}"
2799 );
2800 }
2801 other => panic!("expected InvalidUri error, got: {other:?}"),
2802 }
2803 }
2804
2805 #[test]
2806 fn rejects_cookie_handling_disabled() {
2807 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
2808 match result {
2809 Err(CamelError::InvalidUri(msg)) => {
2810 assert!(
2811 msg.contains("cookieHandling is not supported"),
2812 "expected rejection message, got: {msg}"
2813 );
2814 }
2815 other => panic!("expected InvalidUri error, got: {other:?}"),
2816 }
2817 }
2818
2819 #[test]
2820 fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
2821 let config = HttpConfig::default()
2822 .with_response_timeout_ms(999)
2823 .with_allow_internal(true)
2824 .with_blocked_hosts(vec!["evil.com".to_string()])
2825 .with_max_body_size(12345);
2826 let endpoint =
2827 HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
2828 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
2829 assert!(endpoint.allow_internal);
2830 assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
2831 assert_eq!(endpoint.max_body_size, 12345);
2832 }
2833
2834 #[test]
2835 fn test_from_uri_with_defaults_uri_overrides_config() {
2836 let config = HttpConfig::default()
2837 .with_response_timeout_ms(999)
2838 .with_allow_internal(true)
2839 .with_blocked_hosts(vec!["evil.com".to_string()])
2840 .with_max_body_size(12345);
2841 let endpoint = HttpEndpointConfig::from_uri_with_defaults(
2842 "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
2843 &config,
2844 )
2845 .unwrap();
2846 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
2847 assert!(!endpoint.allow_internal);
2848 assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
2849 assert_eq!(endpoint.max_body_size, 99);
2850 }
2851
2852 #[test]
2853 fn test_http_config_ok_status_range() {
2854 let config =
2855 HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
2856 assert_eq!(config.ok_status_code_range, (200, 204));
2857 }
2858
2859 #[test]
2860 fn test_http_config_wrong_scheme() {
2861 let result = HttpEndpointConfig::from_uri("file:/tmp");
2862 assert!(result.is_err());
2863 }
2864
2865 #[test]
2866 fn test_http_component_scheme() {
2867 let component = HttpComponent::new();
2868 assert_eq!(component.scheme(), "http");
2869 }
2870
2871 #[test]
2872 fn test_https_component_scheme() {
2873 let component = HttpsComponent::new();
2874 assert_eq!(component.scheme(), "https");
2875 }
2876
2877 #[test]
2878 fn test_http_endpoint_creates_consumer() {
2879 let component = HttpComponent::new();
2880 let ctx = NoOpComponentContext;
2881 let endpoint = component
2882 .create_endpoint("http://0.0.0.0:19100/test", &ctx)
2883 .unwrap();
2884 assert!(endpoint.create_consumer(rt()).is_ok());
2885 }
2886
2887 #[test]
2888 fn test_https_endpoint_creates_consumer_errors_without_tls() {
2889 let component = HttpsComponent::new();
2890 let ctx = NoOpComponentContext;
2891 let endpoint = component
2892 .create_endpoint("https://0.0.0.0:8443/test", &ctx)
2893 .unwrap();
2894 assert!(endpoint.create_consumer(rt()).is_err());
2896 }
2897
2898 #[test]
2899 fn test_http_endpoint_creates_producer() {
2900 let ctx = test_producer_ctx();
2901 let component = HttpComponent::new();
2902 let endpoint_ctx = NoOpComponentContext;
2903 let endpoint = component
2904 .create_endpoint("http://localhost/api", &endpoint_ctx)
2905 .unwrap();
2906 assert!(endpoint.create_producer(rt(), &ctx).is_ok());
2907 }
2908
2909 #[tokio::test]
2914 async fn test_producer_with_token_provider() {
2915 use camel_auth::oauth2::TokenProvider;
2916 use tower::ServiceExt;
2917
2918 let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
2919 Arc::new(std::sync::Mutex::new(None));
2920 let captured_clone = Arc::clone(&captured_auth);
2921
2922 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2923 let port = listener.local_addr().unwrap().port();
2924
2925 let _handle = tokio::spawn(async move {
2926 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2927 if let Ok((mut stream, _)) = listener.accept().await {
2928 let mut buf = vec![0u8; 8192];
2929 let n = stream.read(&mut buf).await.unwrap_or(0);
2930 let request = String::from_utf8_lossy(&buf[..n]).to_string();
2931 let auth = request
2932 .lines()
2933 .find(|l| l.to_lowercase().starts_with("authorization:"))
2934 .map(|l| {
2935 l.split(':')
2936 .nth(1)
2937 .map(|s| s.trim().to_string())
2938 .unwrap_or_default()
2939 });
2940 *captured_clone.lock().unwrap() = auth;
2941 let body = r#"{"echo":"ok"}"#;
2942 let resp = format!(
2943 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
2944 body.len(),
2945 body
2946 );
2947 let _ = stream.write_all(resp.as_bytes()).await;
2948 }
2949 });
2950
2951 #[derive(Debug)]
2952 struct StaticProvider;
2953 #[async_trait::async_trait]
2954 impl TokenProvider for StaticProvider {
2955 async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
2956 Ok("injected-token".into())
2957 }
2958 }
2959
2960 let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
2961 let ctx = test_producer_ctx();
2962 let component = HttpComponent::new();
2963 let endpoint_ctx = NoOpComponentContext;
2964 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
2965 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2966
2967 let exchange = Exchange::new(Message::new("hello"));
2968
2969 let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
2970 let mut layered = layer.layer(producer);
2971 let result = layered.ready().await.unwrap().call(exchange).await;
2972 assert!(result.is_ok(), "producer call failed: {:?}", result);
2973
2974 tokio::time::sleep(Duration::from_millis(100)).await;
2975 let auth = captured_auth.lock().unwrap().take();
2976 assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
2977 }
2978
2979 async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
2980 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2981 let addr = listener.local_addr().unwrap();
2982 let url = format!("http://127.0.0.1:{}", addr.port());
2983
2984 let handle = tokio::spawn(async move {
2985 loop {
2986 if let Ok((mut stream, _)) = listener.accept().await {
2987 tokio::spawn(async move {
2988 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2989 let mut buf = vec![0u8; 4096];
2990 let n = stream.read(&mut buf).await.unwrap_or(0);
2991 let request = String::from_utf8_lossy(&buf[..n]).to_string();
2992
2993 let method = request.split_whitespace().next().unwrap_or("GET");
2994
2995 let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
2996 let response = format!(
2997 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
2998 body.len(),
2999 body
3000 );
3001 let _ = stream.write_all(response.as_bytes()).await;
3002 });
3003 }
3004 }
3005 });
3006
3007 (url, handle)
3008 }
3009
3010 async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
3011 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3012 let addr = listener.local_addr().unwrap();
3013 let url = format!("http://127.0.0.1:{}", addr.port());
3014
3015 let handle = tokio::spawn(async move {
3016 loop {
3017 if let Ok((mut stream, _)) = listener.accept().await {
3018 let status = status;
3019 tokio::spawn(async move {
3020 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3021 let mut buf = vec![0u8; 4096];
3022 let _ = stream.read(&mut buf).await;
3023
3024 let status_text = match status {
3025 404 => "Not Found",
3026 500 => "Internal Server Error",
3027 _ => "Error",
3028 };
3029 let body = "error body";
3030 let response = format!(
3031 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
3032 status,
3033 status_text,
3034 body.len(),
3035 body
3036 );
3037 let _ = stream.write_all(response.as_bytes()).await;
3038 });
3039 }
3040 }
3041 });
3042
3043 (url, handle)
3044 }
3045
3046 async fn start_request_capturing_server() -> (
3047 String,
3048 Arc<std::sync::Mutex<Option<String>>>,
3049 tokio::task::JoinHandle<()>,
3050 ) {
3051 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3052 let port = listener.local_addr().unwrap().port();
3053 let url = format!("http://127.0.0.1:{port}");
3054 let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
3055 let captured_clone = Arc::clone(&captured);
3056 let handle = tokio::spawn(async move {
3057 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3058 if let Ok((mut stream, _)) = listener.accept().await {
3059 let mut buf = vec![0u8; 16384];
3060 let n = stream.read(&mut buf).await.unwrap_or(0);
3061 let request = String::from_utf8_lossy(&buf[..n]).to_string();
3062 if request.contains("\r\n\r\n") {
3063 *captured_clone.lock().unwrap() = Some(request);
3064 }
3065 let body = r#"{"echo":"ok"}"#;
3066 let resp = format!(
3067 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
3068 body.len(),
3069 body
3070 );
3071 let _ = stream.write_all(resp.as_bytes()).await;
3072 }
3073 });
3074 (url, captured, handle)
3075 }
3076
3077 #[tokio::test]
3078 async fn test_http_producer_get_request() {
3079 use tower::ServiceExt;
3080
3081 let (url, _handle) = start_test_server().await;
3082 let ctx = test_producer_ctx();
3083
3084 let component = HttpComponent::new();
3085 let endpoint_ctx = NoOpComponentContext;
3086 let endpoint = component
3087 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
3088 .unwrap();
3089 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3090
3091 let exchange = Exchange::new(Message::default());
3092 let result = producer.oneshot(exchange).await.unwrap();
3093
3094 let status = result
3095 .input
3096 .header("CamelHttpResponseCode")
3097 .and_then(|v| v.as_u64())
3098 .unwrap();
3099 assert_eq!(status, 200);
3100
3101 assert!(!result.input.body.is_empty());
3102 }
3103
3104 #[tokio::test]
3105 async fn producer_excludes_host_and_framing() {
3106 use tower::ServiceExt;
3107
3108 let (url, captured, _handle) = start_request_capturing_server().await;
3109 let ctx = test_producer_ctx();
3110 let component = HttpComponent::new();
3111 let endpoint_ctx = NoOpComponentContext;
3112 let endpoint = component
3113 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
3114 .unwrap();
3115 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3116
3117 let mut exchange = Exchange::new(Message::default());
3118 exchange.input.set_header("Host", "localhost");
3119 exchange.input.set_header("Content-Length", "42");
3120 exchange.input.set_header("Connection", "keep-alive");
3121 exchange.input.set_header("Upgrade", "h2c");
3122
3123 let result = producer.oneshot(exchange).await;
3124 assert!(result.is_ok(), "producer call failed: {:?}", result);
3125
3126 tokio::time::sleep(Duration::from_millis(100)).await;
3127 let request = captured
3128 .lock()
3129 .unwrap()
3130 .take()
3131 .expect("no outbound request captured");
3132 let lower = request.to_ascii_lowercase();
3133 assert!(
3134 !lower.contains("\r\nhost: localhost"),
3135 "forwarded Host: localhost must be stripped\n{request}"
3136 );
3137 assert!(
3138 !lower.contains("content-length: 42"),
3139 "exchange Content-Length must not be copied\n{request}"
3140 );
3141 assert!(
3142 !lower.lines().any(|l| l.starts_with("connection:")),
3143 "Connection header must not be forwarded\n{request}"
3144 );
3145 assert!(
3146 !lower.lines().any(|l| l.starts_with("upgrade:")),
3147 "Upgrade header must not be forwarded\n{request}"
3148 );
3149 let host_header = lower
3150 .lines()
3151 .find(|l| l.starts_with("host:"))
3152 .map(|l| l.split_once(':').map(|(_, v)| v).unwrap_or("").trim())
3153 .expect("outbound Host header must be set by reqwest");
3154 assert!(
3155 host_header.starts_with("127.0.0.1:"),
3156 "outbound Host '{host_header}' must match the capture-server address"
3157 );
3158 }
3159
3160 #[tokio::test]
3161 async fn producer_forwards_request_only_headers() {
3162 use tower::ServiceExt;
3163
3164 let (url, captured, _handle) = start_request_capturing_server().await;
3165 let ctx = test_producer_ctx();
3166 let component = HttpComponent::new();
3167 let endpoint_ctx = NoOpComponentContext;
3168 let endpoint = component
3169 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
3170 .unwrap();
3171 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3172
3173 let mut exchange = Exchange::new(Message::default());
3174 exchange.input.set_header("Accept", "application/json");
3175 exchange.input.set_header("User-Agent", "myclient/1.0");
3176
3177 let result = producer.oneshot(exchange).await;
3178 assert!(result.is_ok(), "producer call failed: {:?}", result);
3179
3180 tokio::time::sleep(Duration::from_millis(100)).await;
3181 let request = captured
3182 .lock()
3183 .unwrap()
3184 .take()
3185 .expect("no outbound request captured");
3186 let lower = request.to_ascii_lowercase();
3187 assert!(
3188 lower.contains("accept: application/json"),
3189 "request-only Accept header must be forwarded\n{request}"
3190 );
3191 assert!(
3192 lower.contains("user-agent: myclient/1.0"),
3193 "request-only User-Agent header must be forwarded\n{request}"
3194 );
3195 }
3196
3197 #[tokio::test]
3198 async fn producer_honours_skip_request_headers() {
3199 use tower::ServiceExt;
3200
3201 let (url, captured, _handle) = start_request_capturing_server().await;
3202 let ctx = test_producer_ctx();
3203 let component = HttpComponent::new();
3204 let endpoint_ctx = NoOpComponentContext;
3205 let endpoint = component
3206 .create_endpoint(
3207 &format!("{url}/api/test?allowInternal=true&skipRequestHeaders=Authorization"),
3208 &endpoint_ctx,
3209 )
3210 .unwrap();
3211 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3212
3213 let mut exchange = Exchange::new(Message::default());
3214 exchange.input.set_header("Authorization", "Bearer x");
3215
3216 let result = producer.oneshot(exchange).await;
3217 assert!(result.is_ok(), "producer call failed: {:?}", result);
3218
3219 tokio::time::sleep(Duration::from_millis(100)).await;
3220 let request = captured
3221 .lock()
3222 .unwrap()
3223 .take()
3224 .expect("no outbound request captured");
3225 assert!(
3226 !request.to_ascii_lowercase().contains("authorization"),
3227 "Authorization must be stripped by skipRequestHeaders\n{request}"
3228 );
3229 }
3230
3231 #[tokio::test]
3232 async fn test_http_producer_post_with_body() {
3233 use tower::ServiceExt;
3234
3235 let (url, _handle) = start_test_server().await;
3236 let ctx = test_producer_ctx();
3237
3238 let component = HttpComponent::new();
3239 let endpoint_ctx = NoOpComponentContext;
3240 let endpoint = component
3241 .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
3242 .unwrap();
3243 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3244
3245 let exchange = Exchange::new(Message::new("request body"));
3246 let result = producer.oneshot(exchange).await.unwrap();
3247
3248 let status = result
3249 .input
3250 .header("CamelHttpResponseCode")
3251 .and_then(|v| v.as_u64())
3252 .unwrap();
3253 assert_eq!(status, 200);
3254 }
3255
3256 #[tokio::test]
3257 async fn test_http_producer_method_from_header() {
3258 use tower::ServiceExt;
3259
3260 let (url, _handle) = start_test_server().await;
3261 let ctx = test_producer_ctx();
3262
3263 let component = HttpComponent::new();
3264 let endpoint_ctx = NoOpComponentContext;
3265 let endpoint = component
3266 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
3267 .unwrap();
3268 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3269
3270 let mut exchange = Exchange::new(Message::default());
3271 exchange.input.set_header(
3272 "CamelHttpMethod",
3273 serde_json::Value::String("DELETE".to_string()),
3274 );
3275
3276 let result = producer.oneshot(exchange).await.unwrap();
3277 let status = result
3278 .input
3279 .header("CamelHttpResponseCode")
3280 .and_then(|v| v.as_u64())
3281 .unwrap();
3282 assert_eq!(status, 200);
3283 }
3284
3285 #[tokio::test]
3286 async fn test_http_producer_forced_method() {
3287 use tower::ServiceExt;
3288
3289 let (url, _handle) = start_test_server().await;
3290 let ctx = test_producer_ctx();
3291
3292 let component = HttpComponent::new();
3293 let endpoint_ctx = NoOpComponentContext;
3294 let endpoint = component
3295 .create_endpoint(
3296 &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
3297 &endpoint_ctx,
3298 )
3299 .unwrap();
3300 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3301
3302 let exchange = Exchange::new(Message::default());
3303 let result = producer.oneshot(exchange).await.unwrap();
3304
3305 let status = result
3306 .input
3307 .header("CamelHttpResponseCode")
3308 .and_then(|v| v.as_u64())
3309 .unwrap();
3310 assert_eq!(status, 200);
3311 }
3312
3313 #[tokio::test]
3314 async fn test_http_producer_throw_exception_on_failure() {
3315 use tower::ServiceExt;
3316
3317 let (url, _handle) = start_status_server(404).await;
3318 let ctx = test_producer_ctx();
3319
3320 let component = HttpComponent::new();
3321 let endpoint_ctx = NoOpComponentContext;
3322 let endpoint = component
3323 .create_endpoint(
3324 &format!("{url}/not-found?allowInternal=true"),
3325 &endpoint_ctx,
3326 )
3327 .unwrap();
3328 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3329
3330 let exchange = Exchange::new(Message::default());
3331 let result = producer.oneshot(exchange).await;
3332 assert!(result.is_err());
3333
3334 match result.unwrap_err() {
3335 CamelError::HttpOperationFailed { status_code, .. } => {
3336 assert_eq!(status_code, 404);
3337 }
3338 e => panic!("Expected HttpOperationFailed, got: {e}"),
3339 }
3340 }
3341
3342 #[tokio::test]
3343 async fn test_http_producer_no_throw_on_failure() {
3344 use tower::ServiceExt;
3345
3346 let (url, _handle) = start_status_server(500).await;
3347 let ctx = test_producer_ctx();
3348
3349 let component = HttpComponent::new();
3350 let endpoint_ctx = NoOpComponentContext;
3351 let endpoint = component
3352 .create_endpoint(
3353 &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
3354 &endpoint_ctx,
3355 )
3356 .unwrap();
3357 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3358
3359 let exchange = Exchange::new(Message::default());
3360 let result = producer.oneshot(exchange).await.unwrap();
3361
3362 let status = result
3363 .input
3364 .header("CamelHttpResponseCode")
3365 .and_then(|v| v.as_u64())
3366 .unwrap();
3367 assert_eq!(status, 500);
3368 }
3369
3370 #[tokio::test]
3371 async fn test_http_producer_uri_override() {
3372 use tower::ServiceExt;
3373
3374 let (url, _handle) = start_test_server().await;
3375 let ctx = test_producer_ctx();
3376
3377 let component = HttpComponent::new();
3378 let endpoint_ctx = NoOpComponentContext;
3379 let endpoint = component
3380 .create_endpoint(
3381 "http://localhost:1/does-not-exist?allowInternal=true",
3382 &endpoint_ctx,
3383 )
3384 .unwrap();
3385 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3386
3387 let mut exchange = Exchange::new(Message::default());
3388 exchange.input.set_header(
3389 "CamelHttpUri",
3390 serde_json::Value::String(format!("{url}/api")),
3391 );
3392
3393 let result = producer.oneshot(exchange).await.unwrap();
3394 let status = result
3395 .input
3396 .header("CamelHttpResponseCode")
3397 .and_then(|v| v.as_u64())
3398 .unwrap();
3399 assert_eq!(status, 200);
3400 }
3401
3402 #[tokio::test]
3403 async fn test_http_producer_response_headers_mapped() {
3404 use tower::ServiceExt;
3405
3406 let (url, _handle) = start_test_server().await;
3407 let ctx = test_producer_ctx();
3408
3409 let component = HttpComponent::new();
3410 let endpoint_ctx = NoOpComponentContext;
3411 let endpoint = component
3412 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
3413 .unwrap();
3414 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3415
3416 let exchange = Exchange::new(Message::default());
3417 let result = producer.oneshot(exchange).await.unwrap();
3418
3419 assert!(
3420 result.input.header("Content-Type").is_some(),
3421 "Response should have Content-Type header"
3422 );
3423 assert!(result.input.header("CamelHttpResponseText").is_some());
3424 }
3425
3426 async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
3431 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3432 let addr = listener.local_addr().unwrap();
3433 let url = format!("http://127.0.0.1:{}", addr.port());
3434
3435 let handle = tokio::spawn(async move {
3436 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3437 loop {
3438 if let Ok((mut stream, _)) = listener.accept().await {
3439 tokio::spawn(async move {
3440 let mut buf = vec![0u8; 4096];
3441 let n = stream.read(&mut buf).await.unwrap_or(0);
3442 let request = String::from_utf8_lossy(&buf[..n]).to_string();
3443
3444 if request.contains("GET /final") {
3446 let body = r#"{"status":"final"}"#;
3447 let response = format!(
3448 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
3449 body.len(),
3450 body
3451 );
3452 let _ = stream.write_all(response.as_bytes()).await;
3453 } else {
3454 let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\n\r\n";
3456 let _ = stream.write_all(response.as_bytes()).await;
3457 }
3458 });
3459 }
3460 }
3461 });
3462
3463 (url, handle)
3464 }
3465
3466 struct CapturedRequest {
3467 method: String,
3468 path: String,
3469 body: Vec<u8>,
3470 content_length: Option<String>,
3471 transfer_encoding: Option<String>,
3472 }
3473
3474 async fn capture_request(stream: &mut tokio::net::TcpStream) -> Option<CapturedRequest> {
3480 use tokio::io::AsyncReadExt;
3481
3482 let mut buf: Vec<u8> = Vec::new();
3484 let mut chunk = [0u8; 4096];
3485 let head_end: usize;
3486 loop {
3487 let n = stream.read(&mut chunk).await.unwrap_or(0);
3488 if n == 0 {
3489 return None;
3490 }
3491 buf.extend_from_slice(&chunk[..n]);
3492 if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
3493 head_end = pos + 4;
3494 break;
3495 }
3496 }
3497
3498 let head = String::from_utf8_lossy(&buf[..head_end]);
3500 let mut lines = head.split("\r\n");
3501 let request_line = lines.next().unwrap_or("");
3502 let mut parts = request_line.split_whitespace();
3503 let method = parts.next().unwrap_or("").to_string();
3504 let path = parts.next().unwrap_or("").to_string();
3505
3506 let mut content_length: Option<String> = None;
3507 let mut transfer_encoding: Option<String> = None;
3508 for line in lines {
3509 if let Some((name, value)) = line.split_once(':') {
3510 let name = name.trim().to_ascii_lowercase();
3511 let value = value.trim().to_string();
3512 if name == "content-length" {
3513 content_length = Some(value);
3514 } else if name == "transfer-encoding" {
3515 transfer_encoding = Some(value);
3516 }
3517 }
3518 }
3519
3520 let body_len: usize = content_length
3522 .as_deref()
3523 .and_then(|v| v.parse::<usize>().ok())
3524 .unwrap_or(0);
3525
3526 let mut body: Vec<u8> = buf[head_end..].to_vec();
3527 while body.len() < body_len {
3528 let n = stream.read(&mut chunk).await.unwrap_or(0);
3529 if n == 0 {
3530 break;
3531 }
3532 body.extend_from_slice(&chunk[..n]);
3533 }
3534 body.truncate(body_len);
3535
3536 Some(CapturedRequest {
3537 method,
3538 path,
3539 body,
3540 content_length,
3541 transfer_encoding,
3542 })
3543 }
3544
3545 async fn start_capture_server() -> (
3550 String,
3551 tokio::task::JoinHandle<()>,
3552 Arc<Mutex<Vec<CapturedRequest>>>,
3553 ) {
3554 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3555 let addr = listener.local_addr().unwrap();
3556 let url = format!("http://127.0.0.1:{}", addr.port());
3557
3558 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
3559 let captured_for_return = Arc::clone(&captured);
3560
3561 let handle = tokio::spawn(async move {
3562 use tokio::io::AsyncWriteExt;
3563 loop {
3564 if let Ok((mut stream, _)) = listener.accept().await {
3565 let captured = Arc::clone(&captured);
3566 tokio::spawn(async move {
3567 let Some(req) = capture_request(&mut stream).await else {
3568 return;
3569 };
3570 captured.lock().unwrap().push(req);
3571
3572 let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
3575 let _ = stream.write_all(response.as_bytes()).await;
3576 });
3577 }
3578 }
3579 });
3580
3581 (url, handle, captured_for_return)
3582 }
3583
3584 async fn start_redirect_capture_server() -> (
3590 String,
3591 tokio::task::JoinHandle<()>,
3592 Arc<Mutex<Vec<CapturedRequest>>>,
3593 ) {
3594 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3595 let addr = listener.local_addr().unwrap();
3596 let url = format!("http://127.0.0.1:{}", addr.port());
3597
3598 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
3599 let captured_for_return = Arc::clone(&captured);
3600
3601 let handle = tokio::spawn(async move {
3602 use tokio::io::AsyncWriteExt;
3603 loop {
3604 if let Ok((mut stream, _)) = listener.accept().await {
3605 let captured = Arc::clone(&captured);
3606 tokio::spawn(async move {
3607 let Some(req) = capture_request(&mut stream).await else {
3608 return;
3609 };
3610 let path = req.path.clone();
3611 captured.lock().unwrap().push(req);
3612
3613 let (status_line, location) = match path.as_str() {
3614 "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
3615 "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
3616 "/final" => ("HTTP/1.1 200 OK", None),
3617 _ => ("HTTP/1.1 404 Not Found", None),
3618 };
3619
3620 let response = match location {
3621 Some(loc) => format!(
3622 "{status_line}\r\nLocation: {loc}\r\nContent-Length: 0\r\n\r\n"
3623 ),
3624 None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
3625 };
3626 let _ = stream.write_all(response.as_bytes()).await;
3627 });
3628 }
3629 }
3630 });
3631
3632 (url, handle, captured_for_return)
3633 }
3634
3635 #[tokio::test]
3636 async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
3637 use tower::ServiceExt;
3638
3639 let (url, _handle, captured) = start_capture_server().await;
3640 let ctx = test_producer_ctx();
3641
3642 let component = HttpComponent::with_config(HttpConfig::default());
3643 let endpoint_ctx = NoOpComponentContext;
3644 let endpoint = component
3645 .create_endpoint(
3646 &format!("{url}?httpMethod=GET&allowInternal=true"),
3647 &endpoint_ctx,
3648 )
3649 .unwrap();
3650 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3651
3652 let mut exchange = Exchange::new(Message::default());
3653 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3654
3655 let result = producer.oneshot(exchange).await.unwrap();
3656
3657 let status = result
3658 .input
3659 .header("CamelHttpResponseCode")
3660 .and_then(|v| v.as_u64())
3661 .unwrap();
3662 assert_eq!(status, 200);
3663
3664 let captured = captured.lock().unwrap();
3665 assert_eq!(captured.len(), 1, "expected exactly one captured request");
3666 let req = &captured[0];
3667 assert_eq!(req.method, "GET");
3668 assert_eq!(req.path, "/");
3671 assert!(req.body.is_empty(), "GET must not carry a body");
3672 assert!(
3673 req.content_length.is_none(),
3674 "suppressed request must not carry Content-Length"
3675 );
3676 assert!(
3677 req.transfer_encoding.is_none(),
3678 "suppressed request must not carry Transfer-Encoding"
3679 );
3680
3681 assert!(
3683 result.input.body.is_empty(),
3684 "exchange body must be consumed"
3685 );
3686 }
3687
3688 #[tokio::test]
3689 async fn test_head_with_body_suppressed_via_header() {
3690 use tower::ServiceExt;
3691
3692 let (url, _handle, captured) = start_capture_server().await;
3693 let ctx = test_producer_ctx();
3694
3695 let component = HttpComponent::with_config(HttpConfig::default());
3696 let endpoint_ctx = NoOpComponentContext;
3697 let endpoint = component
3698 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
3699 .unwrap();
3700 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3701
3702 let mut exchange = Exchange::new(Message::default());
3703 exchange.input.set_header(
3704 "CamelHttpMethod",
3705 serde_json::Value::String("HEAD".to_string()),
3706 );
3707 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3708
3709 let result = producer.oneshot(exchange).await.unwrap();
3710 let status = result
3711 .input
3712 .header("CamelHttpResponseCode")
3713 .and_then(|v| v.as_u64())
3714 .unwrap();
3715 assert_eq!(status, 200);
3716
3717 let captured = captured.lock().unwrap();
3718 assert_eq!(captured.len(), 1);
3719 let req = &captured[0];
3720 assert_eq!(req.method, "HEAD");
3721 assert!(req.body.is_empty(), "HEAD must not carry a body");
3722 }
3723
3724 #[tokio::test]
3725 async fn test_delete_options_trace_with_body_suppressed() {
3726 use tower::ServiceExt;
3727
3728 let (url, _handle, captured) = start_capture_server().await;
3729 let ctx = test_producer_ctx();
3730 let component = HttpComponent::with_config(HttpConfig::default());
3731 let endpoint_ctx = NoOpComponentContext;
3732
3733 for method in ["DELETE", "OPTIONS", "TRACE"] {
3734 let endpoint = component
3735 .create_endpoint(
3736 &format!("{url}?httpMethod={method}&allowInternal=true"),
3737 &endpoint_ctx,
3738 )
3739 .unwrap();
3740 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3741
3742 let mut exchange = Exchange::new(Message::default());
3743 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3744
3745 let result = producer.oneshot(exchange).await.unwrap();
3746 let status = result
3747 .input
3748 .header("CamelHttpResponseCode")
3749 .and_then(|v| v.as_u64())
3750 .unwrap();
3751 assert_eq!(status, 200, "method {method} should succeed");
3752 }
3753
3754 let captured = captured.lock().unwrap();
3755 assert_eq!(captured.len(), 3, "expected three captured requests");
3756 for method in ["DELETE", "OPTIONS", "TRACE"] {
3757 let req = captured
3758 .iter()
3759 .find(|r| r.method == method)
3760 .unwrap_or_else(|| panic!("missing captured request for {method}"));
3761 assert!(req.body.is_empty(), "{} must not carry a body", req.method);
3762 }
3763 }
3764
3765 #[tokio::test]
3766 async fn test_post_put_patch_with_body_still_sent() {
3767 use tower::ServiceExt;
3768
3769 let (url, _handle, captured) = start_capture_server().await;
3770 let ctx = test_producer_ctx();
3771 let component = HttpComponent::with_config(HttpConfig::default());
3772 let endpoint_ctx = NoOpComponentContext;
3773
3774 for method in ["POST", "PUT", "PATCH"] {
3775 let endpoint = component
3776 .create_endpoint(
3777 &format!("{url}?httpMethod={method}&allowInternal=true"),
3778 &endpoint_ctx,
3779 )
3780 .unwrap();
3781 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3782
3783 let payload = format!("body-for-{method}");
3784 let mut exchange = Exchange::new(Message::default());
3785 exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
3786
3787 let result = producer.oneshot(exchange).await.unwrap();
3788 let status = result
3789 .input
3790 .header("CamelHttpResponseCode")
3791 .and_then(|v| v.as_u64())
3792 .unwrap();
3793 assert_eq!(status, 200, "method {method} should succeed");
3794 }
3795
3796 let captured = captured.lock().unwrap();
3797 assert_eq!(captured.len(), 3, "expected three captured requests");
3798 for method in ["POST", "PUT", "PATCH"] {
3799 let req = captured
3800 .iter()
3801 .find(|r| r.method == method)
3802 .unwrap_or_else(|| panic!("missing captured request for {method}"));
3803 let expected = format!("body-for-{method}");
3804 assert!(!req.body.is_empty(), "{method} must still carry its body");
3805 assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
3806 }
3807 }
3808
3809 #[tokio::test]
3813 async fn test_stream_body_under_get_not_attached() {
3814 use tower::ServiceExt;
3815
3816 let (url, _handle, captured) = start_capture_server().await;
3817 let ctx = test_producer_ctx();
3818
3819 let component = HttpComponent::with_config(HttpConfig::default());
3820 let endpoint_ctx = NoOpComponentContext;
3821 let endpoint = component
3822 .create_endpoint(
3823 &format!("{url}?httpMethod=GET&allowInternal=true"),
3824 &endpoint_ctx,
3825 )
3826 .unwrap();
3827 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3828
3829 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
3830 vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
3831 let stream = Box::pin(futures::stream::iter(chunks));
3832 let mut exchange = Exchange::new(Message::default());
3833 exchange.input.body = Body::Stream(StreamBody {
3834 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
3835 metadata: StreamMetadata::default(),
3836 });
3837
3838 let result = producer.oneshot(exchange).await.unwrap();
3839
3840 let status = result
3841 .input
3842 .header("CamelHttpResponseCode")
3843 .and_then(|v| v.as_u64())
3844 .unwrap();
3845 assert_eq!(status, 200);
3846
3847 let captured = captured.lock().unwrap();
3848 assert_eq!(captured.len(), 1, "expected exactly one captured request");
3849 assert!(
3850 captured[0].body.is_empty(),
3851 "GET must not carry a stream body"
3852 );
3853 assert!(
3854 captured[0].transfer_encoding.is_none(),
3855 "suppressed request must not carry Transfer-Encoding"
3856 );
3857 assert!(
3858 captured[0].content_length.is_none(),
3859 "suppressed request must not carry Content-Length"
3860 );
3861 assert!(
3862 result.input.body.is_empty(),
3863 "exchange body must be consumed to Empty, not left as a stream"
3864 );
3865 }
3866
3867 #[tokio::test]
3871 async fn test_redirect_hops_never_replay_suppressed_body() {
3872 use tower::ServiceExt;
3873
3874 let (url, _handle, captured) = start_redirect_capture_server().await;
3875 let ctx = test_producer_ctx();
3876
3877 let component =
3878 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3879 let endpoint_ctx = NoOpComponentContext;
3880
3881 for path in ["/hop307", "/hop308"] {
3882 let endpoint = component
3883 .create_endpoint(
3884 &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
3885 &endpoint_ctx,
3886 )
3887 .unwrap();
3888 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3889
3890 let mut exchange = Exchange::new(Message::default());
3891 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3892
3893 let result = producer.oneshot(exchange).await.unwrap();
3894 let status = result
3895 .input
3896 .header("CamelHttpResponseCode")
3897 .and_then(|v| v.as_u64())
3898 .unwrap();
3899 assert_eq!(
3900 status, 200,
3901 "redirect chain for {path} should end at /final"
3902 );
3903 }
3904
3905 let captured = captured.lock().unwrap();
3907 assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
3908 for req in captured.iter() {
3909 assert!(
3910 req.body.is_empty(),
3911 "hop {} {} must not carry a body",
3912 req.method,
3913 req.path
3914 );
3915 }
3916 }
3917
3918 #[tracing_test::traced_test]
3927 #[tokio::test]
3928 async fn test_suppressed_body_logs_exactly_one_warn() {
3929 use tower::ServiceExt;
3930
3931 let (url, _handle, _captured) = start_capture_server().await;
3932 let ctx = test_producer_ctx();
3933
3934 let component = HttpComponent::with_config(HttpConfig::default());
3935 let endpoint_ctx = NoOpComponentContext;
3936 let endpoint = component
3937 .create_endpoint(
3938 &format!("{url}?httpMethod=GET&allowInternal=true"),
3939 &endpoint_ctx,
3940 )
3941 .unwrap();
3942 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3943
3944 let mut exchange = Exchange::new(Message::default());
3945 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3946 let correlation_id = exchange.correlation_id().to_string();
3947
3948 let result = producer.oneshot(exchange).await.unwrap();
3949 let status = result
3950 .input
3951 .header("CamelHttpResponseCode")
3952 .and_then(|v| v.as_u64())
3953 .unwrap();
3954 assert_eq!(status, 200);
3955
3956 logs_assert(|lines: &[&str]| {
3957 let hits = lines
3958 .iter()
3959 .filter(|l| {
3960 l.contains("dropping request body")
3961 && l.contains("method=GET")
3962 && l.contains(&format!("correlation_id={correlation_id}"))
3963 })
3964 .count();
3965 match hits {
3966 1 => Ok(()),
3967 n => Err(format!("expected exactly one body-drop warn, found {n}")),
3968 }
3969 });
3970 }
3971
3972 #[tracing_test::traced_test]
3973 #[tokio::test]
3974 async fn test_empty_body_get_emits_no_warn() {
3975 use tower::ServiceExt;
3976
3977 let (url, _handle, _captured) = start_capture_server().await;
3978 let ctx = test_producer_ctx();
3979
3980 let component = HttpComponent::with_config(HttpConfig::default());
3981 let endpoint_ctx = NoOpComponentContext;
3982 let endpoint = component
3983 .create_endpoint(
3984 &format!("{url}?httpMethod=GET&allowInternal=true"),
3985 &endpoint_ctx,
3986 )
3987 .unwrap();
3988 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3989
3990 let exchange = Exchange::new(Message::default());
3991 let result = producer.oneshot(exchange).await.unwrap();
3992 let status = result
3993 .input
3994 .header("CamelHttpResponseCode")
3995 .and_then(|v| v.as_u64())
3996 .unwrap();
3997 assert_eq!(status, 200);
3998
3999 logs_assert(|lines: &[&str]| {
4000 let hits = lines
4001 .iter()
4002 .filter(|l| l.contains("dropping request body"))
4003 .count();
4004 match hits {
4005 0 => Ok(()),
4006 n => Err(format!("expected no body-drop warn, found {n}")),
4007 }
4008 });
4009 }
4010
4011 #[tokio::test]
4012 async fn test_follow_redirects_false_does_not_follow() {
4013 use tower::ServiceExt;
4014
4015 let (url, _handle) = start_redirect_server().await;
4016 let ctx = test_producer_ctx();
4017
4018 let component =
4019 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
4020 let endpoint_ctx = NoOpComponentContext;
4021 let endpoint = component
4022 .create_endpoint(
4023 &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
4024 &endpoint_ctx,
4025 )
4026 .unwrap();
4027 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4028
4029 let exchange = Exchange::new(Message::default());
4030 let result = producer.oneshot(exchange).await.unwrap();
4031
4032 let status = result
4034 .input
4035 .header("CamelHttpResponseCode")
4036 .and_then(|v| v.as_u64())
4037 .unwrap();
4038 assert_eq!(
4039 status, 302,
4040 "Should NOT follow redirect when followRedirects=false"
4041 );
4042 }
4043
4044 #[tokio::test]
4045 async fn test_follow_redirects_true_follows_redirect() {
4046 use tower::ServiceExt;
4047
4048 let (url, _handle) = start_redirect_server().await;
4049 let ctx = test_producer_ctx();
4050
4051 let component =
4052 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4053 let endpoint_ctx = NoOpComponentContext;
4054 let endpoint = component
4055 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
4056 .unwrap();
4057 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4058
4059 let exchange = Exchange::new(Message::default());
4060 let result = producer.oneshot(exchange).await.unwrap();
4061
4062 let status = result
4064 .input
4065 .header("CamelHttpResponseCode")
4066 .and_then(|v| v.as_u64())
4067 .unwrap();
4068 assert_eq!(
4069 status, 200,
4070 "Should follow redirect when followRedirects=true"
4071 );
4072 }
4073
4074 #[tokio::test]
4077 async fn test_redirect_to_private_ip_is_ssrf_blocked() {
4078 use tower::ServiceExt;
4079
4080 let (url, _handle) = start_redirect_server().await;
4082 let ctx = test_producer_ctx();
4083
4084 let component =
4085 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4086 let endpoint_ctx = NoOpComponentContext;
4087 let endpoint = component
4088 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
4089 .unwrap();
4090 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4091
4092 let exchange = Exchange::new(Message::default());
4093 let result = producer.oneshot(exchange).await;
4094
4095 assert!(
4097 result.is_ok(),
4098 "Redirect should succeed with allowInternal=true, got: {:?}",
4099 result
4100 );
4101 let exchange = result.unwrap();
4102 let status = exchange
4103 .input
4104 .header("CamelHttpResponseCode")
4105 .and_then(|v| v.as_u64())
4106 .unwrap();
4107 assert_eq!(status, 200, "Should follow redirect to /final");
4108 }
4109
4110 #[tokio::test]
4112 async fn test_redirect_to_private_ip_allowed_when_configured() {
4113 use tower::ServiceExt;
4114
4115 let (url, _handle) = start_redirect_server().await;
4117 let ctx = test_producer_ctx();
4118
4119 let component =
4120 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4121 let endpoint_ctx = NoOpComponentContext;
4122 let endpoint = component
4123 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
4124 .unwrap();
4125 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4126
4127 let exchange = Exchange::new(Message::default());
4128 let result = producer.oneshot(exchange).await.unwrap();
4129
4130 let status = result
4131 .input
4132 .header("CamelHttpResponseCode")
4133 .and_then(|v| v.as_u64())
4134 .unwrap();
4135 assert_eq!(
4136 status, 200,
4137 "Should follow redirect to private IP when allowInternal=true"
4138 );
4139 }
4140
4141 #[tokio::test]
4144 async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
4145 use tower::ServiceExt;
4146
4147 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4149 let addr = listener.local_addr().unwrap();
4150 let url = format!("http://127.0.0.1:{}", addr.port());
4151
4152 let handle = tokio::spawn(async move {
4153 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4154 loop {
4155 if let Ok((mut stream, _)) = listener.accept().await {
4156 tokio::spawn(async move {
4157 let mut buf = vec![0u8; 4096];
4158 let _ = stream.read(&mut buf).await;
4159 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";
4161 let _ = stream.write_all(response.as_bytes()).await;
4162 });
4163 }
4164 }
4165 });
4166
4167 let ctx = test_producer_ctx();
4168 let component =
4169 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4170 let endpoint_ctx = NoOpComponentContext;
4171 let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
4173 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4174
4175 let exchange = Exchange::new(Message::default());
4176 let result = producer.oneshot(exchange).await;
4177
4178 assert!(
4180 result.is_err(),
4181 "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
4182 );
4183 let err = result.unwrap_err().to_string();
4184 assert!(
4185 err.contains("blocked IP")
4186 || err.contains("private IP")
4187 || err.contains("SSRF")
4188 || err.contains("not allowed"),
4189 "Error should mention SSRF/IP blocking, got: {err}"
4190 );
4191
4192 handle.abort();
4193 }
4194
4195 #[tokio::test]
4197 async fn test_too_many_redirects_returns_error() {
4198 use tower::ServiceExt;
4199
4200 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4202 let addr = listener.local_addr().unwrap();
4203 let url = format!("http://127.0.0.1:{}", addr.port());
4204
4205 let handle = tokio::spawn(async move {
4206 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4207 loop {
4208 if let Ok((mut stream, _)) = listener.accept().await {
4209 tokio::spawn(async move {
4210 let mut buf = vec![0u8; 4096];
4211 let _ = stream.read(&mut buf).await;
4212 let response =
4214 "HTTP/1.1 302 Found\r\nLocation: /loop\r\nContent-Length: 0\r\n\r\n";
4215 let _ = stream.write_all(response.as_bytes()).await;
4216 });
4217 }
4218 }
4219 });
4220
4221 let ctx = test_producer_ctx();
4222 let component =
4223 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4224 let endpoint_ctx = NoOpComponentContext;
4225 let endpoint = component
4226 .create_endpoint(
4227 &format!("{url}?allowInternal=true&maxRedirects=2"),
4228 &endpoint_ctx,
4229 )
4230 .unwrap();
4231 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4232
4233 let exchange = Exchange::new(Message::default());
4234 let result = producer.oneshot(exchange).await;
4235
4236 match result {
4244 Err(e) => {
4245 let msg = e.to_string();
4247 assert!(
4248 msg.contains("HTTP operation failed") || msg.contains("302"),
4249 "expected redirect-after-exhaustion error, got: {msg}"
4250 );
4251 }
4252 Ok(ex) => {
4253 let response_code = ex
4254 .input
4255 .header("CamelHttpResponseCode")
4256 .and_then(|v| v.as_u64());
4257 assert_eq!(
4258 response_code,
4259 Some(302),
4260 "expected 302 after exhausting redirects"
4261 );
4262 }
4263 }
4264
4265 handle.abort();
4266 }
4267
4268 #[tokio::test]
4269 async fn test_query_params_forwarded_to_http_request() {
4270 use tower::ServiceExt;
4271
4272 let (url, _handle) = start_test_server().await;
4273 let ctx = test_producer_ctx();
4274
4275 let component = HttpComponent::new();
4276 let endpoint_ctx = NoOpComponentContext;
4277 let endpoint = component
4279 .create_endpoint(
4280 &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
4281 &endpoint_ctx,
4282 )
4283 .unwrap();
4284 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4285
4286 let exchange = Exchange::new(Message::default());
4287 let result = producer.oneshot(exchange).await.unwrap();
4288
4289 let status = result
4292 .input
4293 .header("CamelHttpResponseCode")
4294 .and_then(|v| v.as_u64())
4295 .unwrap();
4296 assert_eq!(status, 200);
4297 }
4298
4299 #[tokio::test]
4300 async fn test_non_camel_query_params_are_forwarded() {
4301 let config = HttpEndpointConfig::from_uri(
4304 "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
4305 )
4306 .unwrap();
4307
4308 assert!(
4310 config.query_params.contains_key("apiKey"),
4311 "apiKey should be preserved"
4312 );
4313 assert!(
4314 config.query_params.contains_key("token"),
4315 "token should be preserved"
4316 );
4317 assert_eq!(config.query_params.get("apiKey").unwrap(), "secret123");
4318 assert_eq!(config.query_params.get("token").unwrap(), "abc456");
4319
4320 assert!(
4322 !config.query_params.contains_key("httpMethod"),
4323 "httpMethod should not be forwarded"
4324 );
4325 }
4326
4327 #[test]
4328 fn test_query_params_are_url_encoded_when_resolving_url() {
4329 let config =
4330 HttpEndpointConfig::from_uri("http://example.com/api?q=hello world&tag=a+b").unwrap();
4331 let exchange = Exchange::new(Message::default());
4332
4333 let url = HttpProducer::resolve_url(&exchange, &config);
4334
4335 assert!(url.contains("q=hello+world"), "url was: {url}");
4336 assert!(url.contains("tag=a%2Bb"), "url was: {url}");
4337 }
4338
4339 async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
4344 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4345 let addr = listener.local_addr().unwrap();
4346 let url = format!("http://127.0.0.1:{}", addr.port());
4347
4348 let handle = tokio::spawn(async move {
4349 loop {
4350 if let Ok((mut stream, _)) = listener.accept().await {
4351 let delay = delay_ms;
4352 tokio::spawn(async move {
4353 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4354 let mut buf = vec![0u8; 4096];
4355 let _ = stream.read(&mut buf).await;
4356 let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
4358 let _ = stream.write_all(headers.as_bytes()).await;
4359 tokio::time::sleep(Duration::from_millis(delay)).await;
4361 let body = r#"{"status":"slow"}"#;
4362 let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
4363 let _ = stream.write_all(chunk.as_bytes()).await;
4364 });
4365 }
4366 }
4367 });
4368
4369 (url, handle)
4370 }
4371
4372 #[tokio::test]
4373 async fn test_http_producer_timeout() {
4374 use tower::ServiceExt;
4375
4376 let (url, _handle) = start_slow_server(500).await;
4378 let ctx = test_producer_ctx();
4379
4380 let component = HttpComponent::with_config(
4381 HttpConfig::default()
4382 .with_read_timeout_ms(100)
4383 .with_response_timeout_ms(30_000), );
4385 let endpoint_ctx = NoOpComponentContext;
4386 let endpoint = component
4387 .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
4388 .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!(result.is_err(), "Expected timeout error, got: {:?}", result);
4395 let err = result.unwrap_err().to_string();
4396 assert!(
4397 err.contains("Read timeout") || err.contains("timeout"),
4398 "Error should mention timeout, got: {}",
4399 err
4400 );
4401 }
4402
4403 #[tokio::test]
4404 async fn test_http_producer_no_timeout_when_fast() {
4405 use tower::ServiceExt;
4406
4407 let (url, _handle) = start_test_server().await;
4408 let ctx = test_producer_ctx();
4409
4410 let component =
4411 HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
4412 let endpoint_ctx = NoOpComponentContext;
4413 let endpoint = component
4414 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4415 .unwrap();
4416 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4417
4418 let exchange = Exchange::new(Message::default());
4419 let result = producer.oneshot(exchange).await.unwrap();
4420
4421 let status = result
4422 .input
4423 .header("CamelHttpResponseCode")
4424 .and_then(|v| v.as_u64())
4425 .unwrap();
4426 assert_eq!(status, 200);
4427 }
4428
4429 #[tokio::test]
4434 async fn test_http_producer_blocks_metadata_endpoint() {
4435 use tower::ServiceExt;
4436
4437 let ctx = test_producer_ctx();
4438 let component = HttpComponent::new();
4439 let endpoint_ctx = NoOpComponentContext;
4440 let endpoint = component
4441 .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
4442 .unwrap();
4443 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4444
4445 let mut exchange = Exchange::new(Message::default());
4446 exchange.input.set_header(
4447 "CamelHttpUri",
4448 serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
4449 );
4450
4451 let result = producer.oneshot(exchange).await;
4452 assert!(result.is_err(), "Should block AWS metadata endpoint");
4453
4454 let err = result.unwrap_err();
4455 assert!(
4456 err.to_string().contains("Private IP"),
4457 "Error should mention private IP blocking, got: {}",
4458 err
4459 );
4460 }
4461
4462 #[test]
4463 fn test_ssrf_config_defaults() {
4464 let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
4465 assert!(
4466 !config.allow_internal,
4467 "Private IPs should be blocked by default"
4468 );
4469 assert!(
4470 config.blocked_hosts.is_empty(),
4471 "Blocked hosts should be empty by default"
4472 );
4473 }
4474
4475 #[test]
4476 fn test_ssrf_config_allow_internal() {
4477 let config =
4478 HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
4479 assert!(
4480 config.allow_internal,
4481 "Private IPs should be allowed when explicitly set"
4482 );
4483 }
4484
4485 #[test]
4486 fn test_ssrf_config_blocked_hosts() {
4487 let config = HttpEndpointConfig::from_uri(
4488 "http://example.com/api?blockedHosts=evil.com,malware.net",
4489 )
4490 .unwrap();
4491 assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
4492 }
4493
4494 #[tokio::test]
4495 async fn test_http_producer_blocks_localhost() {
4496 use tower::ServiceExt;
4497
4498 let ctx = test_producer_ctx();
4499 let component = HttpComponent::new();
4500 let endpoint_ctx = NoOpComponentContext;
4501 let endpoint = component
4502 .create_endpoint("http://example.com/api", &endpoint_ctx)
4503 .unwrap();
4504 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4505
4506 let mut exchange = Exchange::new(Message::default());
4507 exchange.input.set_header(
4508 "CamelHttpUri",
4509 serde_json::Value::String("http://localhost:8080/internal".to_string()),
4510 );
4511
4512 let result = producer.oneshot(exchange).await;
4513 assert!(result.is_err(), "Should block localhost");
4514 }
4515
4516 #[tokio::test]
4517 async fn test_http_producer_blocks_loopback_ip() {
4518 use tower::ServiceExt;
4519
4520 let ctx = test_producer_ctx();
4521 let component = HttpComponent::new();
4522 let endpoint_ctx = NoOpComponentContext;
4523 let endpoint = component
4524 .create_endpoint("http://example.com/api", &endpoint_ctx)
4525 .unwrap();
4526 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4527
4528 let mut exchange = Exchange::new(Message::default());
4529 exchange.input.set_header(
4530 "CamelHttpUri",
4531 serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
4532 );
4533
4534 let result = producer.oneshot(exchange).await;
4535 assert!(result.is_err(), "Should block loopback IP");
4536 }
4537
4538 #[tokio::test]
4539 async fn test_http_producer_allows_private_ip_when_enabled() {
4540 use tower::ServiceExt;
4541
4542 let ctx = test_producer_ctx();
4543 let component = HttpComponent::new();
4544 let endpoint_ctx = NoOpComponentContext;
4545 let endpoint = component
4548 .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
4549 .unwrap();
4550 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4551
4552 let exchange = Exchange::new(Message::default());
4553
4554 let result = producer.oneshot(exchange).await;
4557 if let Err(ref e) = result {
4559 let err_str = e.to_string();
4560 assert!(
4561 !err_str.contains("Private IP") && !err_str.contains("not allowed"),
4562 "Should not be SSRF error, got: {}",
4563 err_str
4564 );
4565 }
4566 }
4567
4568 #[test]
4573 fn test_http_server_config_parse() {
4574 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
4575 assert_eq!(cfg.host, "0.0.0.0");
4576 assert_eq!(cfg.port, 8080);
4577 assert_eq!(cfg.path, "/orders");
4578 assert_eq!(cfg.max_inflight_requests, 1024);
4579 }
4580
4581 #[test]
4582 fn test_http_server_config_scheme() {
4583 assert_eq!(HttpServerConfig::scheme(), "http");
4585 }
4586
4587 #[test]
4588 fn test_http_server_config_from_components() {
4589 let components = camel_component_api::UriComponents {
4591 scheme: "https".to_string(),
4592 path: "//0.0.0.0:8443/api".to_string(),
4593 params: std::collections::HashMap::from([
4594 ("maxRequestBody".to_string(), "5242880".to_string()),
4595 ("maxInflightRequests".to_string(), "7".to_string()),
4596 ]),
4597 };
4598 let cfg = HttpServerConfig::from_components(components).unwrap();
4599 assert_eq!(cfg.host, "0.0.0.0");
4600 assert_eq!(cfg.port, 8443);
4601 assert_eq!(cfg.path, "/api");
4602 assert_eq!(cfg.max_request_body, 5242880);
4603 assert_eq!(cfg.max_inflight_requests, 7);
4604 }
4605
4606 #[test]
4607 fn test_http_server_config_default_path() {
4608 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
4609 assert_eq!(cfg.path, "/");
4610 }
4611
4612 #[test]
4613 fn test_http_server_config_wrong_scheme() {
4614 assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
4615 }
4616
4617 #[test]
4618 fn test_http_server_config_invalid_port() {
4619 assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
4620 }
4621
4622 #[test]
4623 fn test_http_server_config_default_port_by_scheme() {
4624 let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
4626 assert_eq!(cfg_http.port, 80);
4627
4628 let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
4630 assert_eq!(cfg_https.port, 443);
4631 }
4632
4633 #[test]
4634 fn test_request_envelope_and_reply_are_send() {
4635 fn assert_send<T: Send>() {}
4636 assert_send::<RequestEnvelope>();
4637 assert_send::<HttpReply>();
4638 }
4639
4640 #[test]
4645 fn test_server_registry_global_is_singleton() {
4646 let r1 = ServerRegistry::global();
4647 let r2 = ServerRegistry::global();
4648 assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
4649 }
4650
4651 #[allow(clippy::await_holding_lock)]
4652 #[tokio::test]
4653 async fn test_concurrent_get_or_spawn_returns_same_registry() {
4654 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4655 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4656 let port = listener.local_addr().unwrap().port();
4657 drop(listener);
4658
4659 let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
4660 Arc::new(std::sync::Mutex::new(Vec::new()));
4661
4662 let mut handles = Vec::new();
4663 for _ in 0..4 {
4664 let results = results.clone();
4665 handles.push(tokio::spawn(async move {
4666 let registry = ServerRegistry::global()
4667 .get_or_spawn(
4668 "127.0.0.1",
4669 port,
4670 2 * 1024 * 1024,
4671 10 * 1024 * 1024,
4672 1024,
4673 test_rt(),
4674 "test-route".into(),
4675 None,
4676 )
4677 .await
4678 .unwrap();
4679 results.lock().unwrap().push(registry);
4680 }));
4681 }
4682
4683 for h in handles {
4684 h.await.unwrap();
4685 }
4686
4687 let registries = results.lock().unwrap();
4688 assert_eq!(registries.len(), 4);
4689 for i in 1..registries.len() {
4690 assert!(
4691 Arc::ptr_eq(®istries[0].inner, ®istries[i].inner),
4692 "all concurrent callers should get same route registry"
4693 );
4694 }
4695 }
4696
4697 #[test]
4698 fn test_server_registry_distinguishes_host_and_port() {
4699 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4700 let rt = tokio::runtime::Runtime::new().expect("runtime");
4701 rt.block_on(async {
4702 let registry = ServerRegistry::global();
4703 let d1 = registry
4707 .get_or_spawn(
4708 "127.0.0.1",
4709 0,
4710 1024 * 1024,
4711 10 * 1024 * 1024,
4712 1024,
4713 test_rt(),
4714 "test-route-1".into(),
4715 None,
4716 )
4717 .await;
4718 let d2 = registry
4719 .get_or_spawn(
4720 "0.0.0.0",
4721 0,
4722 1024 * 1024,
4723 10 * 1024 * 1024,
4724 1024,
4725 test_rt(),
4726 "test-route-2".into(),
4727 None,
4728 )
4729 .await;
4730 assert!(d1.is_ok());
4731 assert!(d2.is_ok());
4732 assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
4733 });
4734 }
4735
4736 #[allow(clippy::await_holding_lock)]
4737 #[tokio::test]
4738 async fn test_shared_server_max_request_body_policy_is_deterministic() {
4739 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4740 let registry = ServerRegistry::global();
4741 let d1 = registry
4743 .get_or_spawn(
4744 "127.0.0.1",
4745 9991,
4746 1024 * 1024,
4747 10 * 1024 * 1024,
4748 1024,
4749 test_rt(),
4750 "test-route".into(),
4751 None,
4752 )
4753 .await;
4754 assert!(d1.is_ok());
4755
4756 let d2 = registry
4759 .get_or_spawn(
4760 "127.0.0.1",
4761 9991,
4762 2 * 1024 * 1024,
4763 10 * 1024 * 1024,
4764 1024,
4765 test_rt(),
4766 "test-route-2".into(),
4767 None,
4768 )
4769 .await;
4770 assert!(d2.is_err());
4771 let err = d2.unwrap_err();
4772 assert!(
4773 err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
4774 "Expected incompatible maxRequestBody error, got: {}",
4775 err
4776 );
4777 }
4778
4779 #[test]
4780 fn test_server_registry_reset_clears_entries() {
4781 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4782 let rt = tokio::runtime::Runtime::new().expect("runtime");
4783 rt.block_on(async {
4784 let d1 = ServerRegistry::global()
4786 .get_or_spawn(
4787 "127.0.0.1",
4788 9992,
4789 1024 * 1024,
4790 10 * 1024 * 1024,
4791 1024,
4792 test_rt(),
4793 "test-route".into(),
4794 None,
4795 )
4796 .await;
4797 assert!(d1.is_ok());
4798
4799 let guard = ServerRegistry::global().inner.lock().expect("lock");
4801 assert!(guard.contains_key(&("127.0.0.1".to_string(), 9992)));
4802 drop(guard);
4803
4804 ServerRegistry::reset();
4806
4807 let guard = ServerRegistry::global().inner.lock().expect("lock");
4809 assert!(
4810 guard.is_empty(),
4811 "registry should be empty after reset, has {} entries",
4812 guard.len()
4813 );
4814 });
4815 }
4816
4817 #[tokio::test]
4818 async fn registry_rejects_tls_on_plain_port() {
4819 ServerRegistry::reset();
4820 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
4821
4822 let _r1 = ServerRegistry::global()
4824 .get_or_spawn(
4825 "127.0.0.1",
4826 0,
4827 1024,
4828 1024,
4829 16,
4830 Arc::clone(&rt),
4831 "route-1".into(),
4832 None, )
4834 .await;
4835
4836 let result = ServerRegistry::global()
4838 .get_or_spawn(
4839 "127.0.0.1",
4840 0,
4841 1024,
4842 1024,
4843 16,
4844 Arc::clone(&rt),
4845 "route-2".into(),
4846 Some(crate::config::ServerTlsConfig {
4847 cert_path: "/x.pem".into(),
4848 key_path: "/y.pem".into(),
4849 }),
4850 )
4851 .await;
4852 assert!(result.is_err(), "must reject TLS on plain port");
4853 }
4854
4855 #[allow(clippy::await_holding_lock)]
4860 #[tokio::test]
4861 async fn test_unregister_last_http_route_keeps_server_alive() {
4862 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4863 ServerRegistry::reset();
4864 let registry = ServerRegistry::global();
4865
4866 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4867 let port = listener.local_addr().unwrap().port();
4868 drop(listener); let rt = test_rt();
4870
4871 let _r1 = registry
4874 .get_or_spawn(
4875 "127.0.0.1",
4876 port,
4877 1024 * 1024,
4878 10 * 1024 * 1024,
4879 16,
4880 rt.clone(),
4881 "test-route-1".into(),
4882 None,
4883 )
4884 .await
4885 .unwrap();
4886 let _r2 = registry
4887 .get_or_spawn(
4888 "127.0.0.1",
4889 port,
4890 1024 * 1024,
4891 10 * 1024 * 1024,
4892 16,
4893 rt,
4894 "test-route-2".into(),
4895 None,
4896 )
4897 .await
4898 .unwrap();
4899
4900 let key = ("127.0.0.1".to_string(), port);
4901 let cell = {
4902 let guard = registry.inner.lock().expect("lock");
4903 guard.get(&key).expect("entry should exist").clone()
4904 };
4905
4906 registry.unregister("127.0.0.1", port).await;
4908 {
4909 let handle = cell
4910 .get()
4911 .expect("handle should still exist after first unregister");
4912 assert!(
4913 !handle.monitor_task.is_finished(),
4914 "monitor task should still be alive after first unregister"
4915 );
4916 }
4917
4918 registry.unregister("127.0.0.1", port).await;
4920 tokio::time::sleep(Duration::from_millis(20)).await;
4921 {
4922 let handle = cell
4923 .get()
4924 .expect("handle should still exist after last unregister");
4925 assert!(
4926 !handle.monitor_task.is_finished(),
4927 "monitor task should still be alive — server is process-lifetime"
4928 );
4929 }
4930
4931 {
4933 let guard = registry.inner.lock().expect("lock");
4934 assert!(
4935 guard.get(&key).is_some(),
4936 "entry should remain in registry — server kept alive for restart"
4937 );
4938 }
4939 }
4940
4941 #[tokio::test]
4946 async fn test_dispatch_handler_returns_404_for_unknown_path() {
4947 let registry = HttpRouteRegistry::new();
4948 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4950 let port = listener.local_addr().unwrap().port();
4951 tokio::spawn(run_axum_server(
4952 listener,
4953 registry,
4954 2 * 1024 * 1024,
4955 10 * 1024 * 1024,
4956 Arc::new(tokio::sync::Semaphore::new(1024)),
4957 test_rt(),
4958 "test-route".into(),
4959 ));
4960
4961 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4963
4964 let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
4965 .await
4966 .unwrap();
4967 assert_eq!(resp.status().as_u16(), 404);
4968 }
4969
4970 #[tokio::test]
4975 async fn test_http_consumer_start_registers_path() {
4976 use camel_component_api::ConsumerContext;
4977
4978 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4980 let port = listener.local_addr().unwrap().port();
4981 drop(listener); let consumer_cfg = HttpServerConfig {
4984 scheme: "http".to_string(),
4985 host: "127.0.0.1".to_string(),
4986 port,
4987 path: "/ping".to_string(),
4988 max_request_body: 2 * 1024 * 1024,
4989 max_response_body: 10 * 1024 * 1024,
4990 max_inflight_requests: 1024,
4991 method: None,
4992 tls_config: None,
4993 };
4994 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4995
4996 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
4997 let token = tokio_util::sync::CancellationToken::new();
4998 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4999
5000 tokio::spawn(async move {
5001 consumer.start(ctx).await.unwrap();
5002 });
5003
5004 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5005
5006 let client = reqwest::Client::new();
5007 let resp_future = client
5008 .post(format!("http://127.0.0.1:{port}/ping"))
5009 .body("hello world")
5010 .send();
5011
5012 let (http_result, _) = tokio::join!(resp_future, async {
5013 if let Some(mut envelope) = rx.recv().await {
5014 envelope.exchange.input.set_header(
5016 "CamelHttpResponseCode",
5017 serde_json::Value::Number(201.into()),
5018 );
5019 if let Some(reply_tx) = envelope.reply_tx {
5020 let _ = reply_tx.send(Ok(envelope.exchange));
5021 }
5022 }
5023 });
5024
5025 let resp = http_result.unwrap();
5026 assert_eq!(resp.status().as_u16(), 201);
5027
5028 token.cancel();
5029 }
5030
5031 #[test]
5035 fn test_envelope_channel_capacity_follows_max_inflight() {
5036 assert_eq!(envelope_channel_capacity(0), 1);
5037 assert_eq!(envelope_channel_capacity(1), 1);
5038 assert_eq!(envelope_channel_capacity(7), 7);
5039 assert_eq!(envelope_channel_capacity(64), 64);
5040 assert_eq!(envelope_channel_capacity(1024), 1024);
5041 }
5042
5043 #[tokio::test]
5047 async fn test_http_consumer_start_with_zero_max_inflight_rejects_503() {
5048 use camel_component_api::ConsumerContext;
5049
5050 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5051 let port = listener.local_addr().unwrap().port();
5052 drop(listener);
5053
5054 let consumer_cfg = HttpServerConfig {
5055 scheme: "http".to_string(),
5056 host: "127.0.0.1".to_string(),
5057 port,
5058 path: "/ping".to_string(),
5059 max_request_body: 2 * 1024 * 1024,
5060 max_response_body: 10 * 1024 * 1024,
5061 max_inflight_requests: 0,
5062 method: None,
5063 tls_config: None,
5064 };
5065 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5066
5067 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
5068 let token = tokio_util::sync::CancellationToken::new();
5069 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5070
5071 let start_handle = tokio::spawn(async move {
5072 consumer.start(ctx).await.unwrap();
5073 });
5074
5075 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5076
5077 let client = reqwest::Client::new();
5078 let resp = client
5079 .post(format!("http://127.0.0.1:{port}/ping"))
5080 .body("hello world")
5081 .send()
5082 .await
5083 .unwrap();
5084 assert_eq!(resp.status().as_u16(), 503);
5085
5086 token.cancel();
5087 let _ = start_handle.await;
5088 }
5089
5090 #[test]
5093 fn test_http_consumer_startup_mode_is_explicit() {
5094 use camel_component_api::ConsumerStartupMode;
5095 let consumer_cfg = HttpServerConfig {
5096 scheme: "http".to_string(),
5097 host: "127.0.0.1".to_string(),
5098 port: 0,
5099 path: "/x".to_string(),
5100 max_request_body: 2 * 1024 * 1024,
5101 max_response_body: 10 * 1024 * 1024,
5102 max_inflight_requests: 1024,
5103 method: None,
5104 tls_config: None,
5105 };
5106 let consumer = HttpConsumer::new(consumer_cfg, test_rt());
5107 assert_eq!(
5108 consumer.startup_mode(),
5109 ConsumerStartupMode::Explicit,
5110 "HttpConsumer must opt into Explicit startup"
5111 );
5112 }
5113
5114 #[allow(clippy::await_holding_lock)]
5120 #[tokio::test]
5121 async fn test_http_consumer_emits_mark_ready_after_bind() {
5122 use camel_component_api::{ConsumerContext, StartupSignal};
5123
5124 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5125
5126 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5127 let port = listener.local_addr().unwrap().port();
5128 drop(listener);
5129
5130 let consumer_cfg = HttpServerConfig {
5131 scheme: "http".to_string(),
5132 host: "127.0.0.1".to_string(),
5133 port,
5134 path: "/ready-probe".to_string(),
5135 max_request_body: 2 * 1024 * 1024,
5136 max_response_body: 10 * 1024 * 1024,
5137 max_inflight_requests: 1024,
5138 method: None,
5139 tls_config: None,
5140 };
5141 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5142
5143 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
5144 let token = tokio_util::sync::CancellationToken::new();
5145 let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
5146
5147 let (signal, startup_rx) = StartupSignal::pair();
5149 let ctx = ctx.with_startup(signal);
5150
5151 tokio::spawn(async move {
5154 let _ = consumer.start(ctx).await;
5155 });
5156
5157 let result =
5162 tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
5163 .await
5164 .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
5165 assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
5166
5167 token.cancel();
5169 }
5170
5171 #[tokio::test]
5172 async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
5173 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5174
5175 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5176 let port = listener.local_addr().unwrap().port();
5177 drop(listener);
5178
5179 let consumer_cfg = HttpServerConfig {
5180 scheme: "http".to_string(),
5181 host: "127.0.0.1".to_string(),
5182 port,
5183 path: "/saturation".to_string(),
5184 max_request_body: 2 * 1024 * 1024,
5185 max_response_body: 10 * 1024 * 1024,
5186 max_inflight_requests: 1,
5187 method: None,
5188 tls_config: None,
5189 };
5190 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5191
5192 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5193 let token = tokio_util::sync::CancellationToken::new();
5194 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5195 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5196 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5197
5198 let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
5199 let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
5200
5201 tokio::spawn(async move {
5202 let mut first_seen_tx = Some(first_seen_tx);
5203 let mut unblock_first_rx = Some(unblock_first_rx);
5204
5205 while let Some(envelope) = rx.recv().await {
5206 if let Some(tx) = first_seen_tx.take() {
5207 let _ = tx.send(());
5208 if let Some(rx_unblock) = unblock_first_rx.take() {
5209 let _ = rx_unblock.await;
5210 }
5211 }
5212
5213 if let Some(reply_tx) = envelope.reply_tx {
5214 let _ = reply_tx.send(Ok(envelope.exchange));
5215 }
5216 }
5217 });
5218
5219 let client = reqwest::Client::new();
5220 let first_req = {
5221 let client = client.clone();
5222 async move {
5223 client
5224 .get(format!("http://127.0.0.1:{port}/saturation"))
5225 .send()
5226 .await
5227 .unwrap()
5228 }
5229 };
5230
5231 let first_handle = tokio::spawn(first_req);
5232 first_seen_rx.await.unwrap();
5233
5234 let second_resp = client
5235 .get(format!("http://127.0.0.1:{port}/saturation"))
5236 .send()
5237 .await
5238 .unwrap();
5239
5240 assert_eq!(second_resp.status().as_u16(), 503);
5241
5242 let _ = unblock_first_tx.send(());
5243 let first_resp = first_handle.await.unwrap();
5244 assert_eq!(first_resp.status().as_u16(), 200);
5245
5246 token.cancel();
5247 }
5248
5249 #[tokio::test]
5250 #[allow(clippy::await_holding_lock)]
5251 async fn test_http_consumer_enforces_max_response_body_for_bytes() {
5252 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5253
5254 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5255
5256 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5257 let port = listener.local_addr().unwrap().port();
5258 drop(listener);
5259
5260 let consumer_cfg = HttpServerConfig {
5261 scheme: "http".to_string(),
5262 host: "127.0.0.1".to_string(),
5263 port,
5264 path: "/limit-bytes".to_string(),
5265 max_request_body: 2 * 1024 * 1024,
5266 max_response_body: 16,
5267 max_inflight_requests: 1024,
5268 method: None,
5269 tls_config: None,
5270 };
5271 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5272
5273 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5274 let token = tokio_util::sync::CancellationToken::new();
5275 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5276 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5277 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5278
5279 let client = reqwest::Client::new();
5280 let send_fut = client
5281 .get(format!("http://127.0.0.1:{port}/limit-bytes"))
5282 .send();
5283
5284 let (http_result, _) = tokio::join!(send_fut, async {
5285 if let Some(mut envelope) = rx.recv().await {
5286 envelope.exchange.input.body =
5287 camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
5288 if let Some(reply_tx) = envelope.reply_tx {
5289 let _ = reply_tx.send(Ok(envelope.exchange));
5290 }
5291 }
5292 });
5293
5294 let resp = http_result.unwrap();
5295 assert_eq!(resp.status().as_u16(), 500);
5296 let body = resp.text().await.unwrap();
5297 assert_eq!(body, "Response body exceeds configured limit");
5298 token.cancel();
5299 }
5300
5301 #[tokio::test]
5302 #[allow(clippy::await_holding_lock)]
5303 async fn test_http_consumer_enforces_max_response_body_for_json() {
5304 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5305
5306 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5307
5308 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5309 let port = listener.local_addr().unwrap().port();
5310 drop(listener);
5311
5312 let consumer_cfg = HttpServerConfig {
5313 scheme: "http".to_string(),
5314 host: "127.0.0.1".to_string(),
5315 port,
5316 path: "/limit-json".to_string(),
5317 max_request_body: 2 * 1024 * 1024,
5318 max_response_body: 16,
5319 max_inflight_requests: 1024,
5320 method: None,
5321 tls_config: None,
5322 };
5323 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5324
5325 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5326 let token = tokio_util::sync::CancellationToken::new();
5327 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5328 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5329 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5330
5331 let client = reqwest::Client::new();
5332 let send_fut = client
5333 .get(format!("http://127.0.0.1:{port}/limit-json"))
5334 .send();
5335
5336 let (http_result, _) = tokio::join!(send_fut, async {
5337 if let Some(mut envelope) = rx.recv().await {
5338 envelope.exchange.input.body = camel_component_api::Body::Json(
5339 serde_json::json!({"message":"this response is bigger than sixteen"}),
5340 );
5341 if let Some(reply_tx) = envelope.reply_tx {
5342 let _ = reply_tx.send(Ok(envelope.exchange));
5343 }
5344 }
5345 });
5346
5347 let resp = http_result.unwrap();
5348 assert_eq!(resp.status().as_u16(), 500);
5349 let body = resp.text().await.unwrap();
5350 assert_eq!(body, "Response body exceeds configured limit");
5351 token.cancel();
5352 }
5353
5354 #[tokio::test]
5355 #[allow(clippy::await_holding_lock)]
5356 async fn test_http_consumer_enforces_max_response_body_for_xml() {
5357 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5358
5359 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5360
5361 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5362 let port = listener.local_addr().unwrap().port();
5363 drop(listener);
5364
5365 let consumer_cfg = HttpServerConfig {
5366 scheme: "http".to_string(),
5367 host: "127.0.0.1".to_string(),
5368 port,
5369 path: "/limit-xml".to_string(),
5370 max_request_body: 2 * 1024 * 1024,
5371 max_response_body: 16,
5372 max_inflight_requests: 1024,
5373 method: None,
5374 tls_config: None,
5375 };
5376 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5377
5378 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5379 let token = tokio_util::sync::CancellationToken::new();
5380 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5381 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5382 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5383
5384 let client = reqwest::Client::new();
5385 let send_fut = client
5386 .get(format!("http://127.0.0.1:{port}/limit-xml"))
5387 .send();
5388
5389 let (http_result, _) = tokio::join!(send_fut, async {
5390 if let Some(mut envelope) = rx.recv().await {
5391 envelope.exchange.input.body = camel_component_api::Body::Xml(
5392 "<root><value>way-too-large</value></root>".into(),
5393 );
5394 if let Some(reply_tx) = envelope.reply_tx {
5395 let _ = reply_tx.send(Ok(envelope.exchange));
5396 }
5397 }
5398 });
5399
5400 let resp = http_result.unwrap();
5401 assert_eq!(resp.status().as_u16(), 500);
5402 let body = resp.text().await.unwrap();
5403 assert_eq!(body, "Response body exceeds configured limit");
5404 token.cancel();
5405 }
5406
5407 #[tokio::test]
5408 #[allow(clippy::await_holding_lock)]
5409 async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
5410 use camel_component_api::{
5411 CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
5412 };
5413 use futures::stream;
5414
5415 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5416
5417 let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
5418 let port = listener.local_addr().unwrap().port();
5419 drop(listener);
5420
5421 let consumer_cfg = HttpServerConfig {
5422 scheme: "http".to_string(),
5423 host: "0.0.0.0".to_string(),
5424 port,
5425 path: "/limit-stream".to_string(),
5426 max_request_body: 2 * 1024 * 1024,
5427 max_response_body: 16,
5428 max_inflight_requests: 1024,
5429 method: None,
5430 tls_config: None,
5431 };
5432 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5433
5434 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5435 let token = tokio_util::sync::CancellationToken::new();
5436 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5437 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5438 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5439
5440 let client = reqwest::Client::new();
5441 let send_fut = client
5442 .get(format!("http://127.0.0.1:{port}/limit-stream"))
5443 .send();
5444
5445 let (http_result, _) = tokio::join!(send_fut, async {
5446 if let Some(mut envelope) = rx.recv().await {
5447 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
5448 vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
5449 let stream = Box::pin(stream::iter(chunks));
5450 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
5451 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
5452 metadata: StreamMetadata {
5453 size_hint: Some(32),
5454 content_type: Some("application/octet-stream".into()),
5455 origin: None,
5456 },
5457 });
5458 if let Some(reply_tx) = envelope.reply_tx {
5459 let _ = reply_tx.send(Ok(envelope.exchange));
5460 }
5461 }
5462 });
5463
5464 let resp = http_result.unwrap();
5465 assert_eq!(resp.status().as_u16(), 200);
5466 let body = resp.bytes().await.unwrap();
5467 assert_eq!(body.len(), 32);
5468 token.cancel();
5469 }
5470
5471 #[tokio::test]
5476 #[allow(clippy::await_holding_lock)]
5477 async fn test_integration_single_consumer_round_trip() {
5478 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5479
5480 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5484
5485 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5487 let port = listener.local_addr().unwrap().port();
5488 drop(listener); let component = HttpComponent::new();
5491 let endpoint_ctx = NoOpComponentContext;
5492 let endpoint = component
5493 .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
5494 .unwrap();
5495 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5496
5497 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5498 let token = tokio_util::sync::CancellationToken::new();
5499 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5500
5501 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5502 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5503
5504 let client = reqwest::Client::new();
5505 let send_fut = client
5506 .post(format!("http://127.0.0.1:{port}/echo"))
5507 .header("Content-Type", "text/plain")
5508 .body("ping")
5509 .send();
5510
5511 let (http_result, _) = tokio::join!(send_fut, async {
5512 if let Some(mut envelope) = rx.recv().await {
5513 assert_eq!(
5514 envelope.exchange.input.header("CamelHttpMethod"),
5515 Some(&serde_json::Value::String("POST".into()))
5516 );
5517 assert_eq!(
5518 envelope.exchange.input.header("CamelHttpPath"),
5519 Some(&serde_json::Value::String("/echo".into()))
5520 );
5521 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
5522 if let Some(reply_tx) = envelope.reply_tx {
5523 let _ = reply_tx.send(Ok(envelope.exchange));
5524 }
5525 }
5526 });
5527
5528 let resp = http_result.unwrap();
5529 assert_eq!(resp.status().as_u16(), 200);
5530 let body = resp.text().await.unwrap();
5531 assert_eq!(body, "pong");
5532
5533 token.cancel();
5534 }
5535
5536 #[tokio::test]
5537 #[allow(clippy::await_holding_lock)]
5538 async fn test_integration_two_consumers_shared_port() {
5539 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5540
5541 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5542
5543 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5545 let port = listener.local_addr().unwrap().port();
5546 drop(listener);
5547
5548 let component = HttpComponent::new();
5549 let endpoint_ctx = NoOpComponentContext;
5550
5551 let endpoint_a = component
5553 .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
5554 .unwrap();
5555 let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
5556
5557 let endpoint_b = component
5559 .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
5560 .unwrap();
5561 let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
5562
5563 let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5564 let token_a = tokio_util::sync::CancellationToken::new();
5565 let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
5566
5567 let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5568 let token_b = tokio_util::sync::CancellationToken::new();
5569 let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
5570
5571 tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
5572 tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
5573 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5574
5575 let client = reqwest::Client::new();
5576
5577 let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
5579 let (resp_hello, _) = tokio::join!(fut_hello, async {
5580 if let Some(mut envelope) = rx_a.recv().await {
5581 envelope.exchange.input.body =
5582 camel_component_api::Body::Text("hello-response".to_string());
5583 if let Some(reply_tx) = envelope.reply_tx {
5584 let _ = reply_tx.send(Ok(envelope.exchange));
5585 }
5586 }
5587 });
5588
5589 let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
5591 let (resp_world, _) = tokio::join!(fut_world, async {
5592 if let Some(mut envelope) = rx_b.recv().await {
5593 envelope.exchange.input.body =
5594 camel_component_api::Body::Text("world-response".to_string());
5595 if let Some(reply_tx) = envelope.reply_tx {
5596 let _ = reply_tx.send(Ok(envelope.exchange));
5597 }
5598 }
5599 });
5600
5601 let body_a = resp_hello.unwrap().text().await.unwrap();
5602 let body_b = resp_world.unwrap().text().await.unwrap();
5603
5604 assert_eq!(body_a, "hello-response");
5605 assert_eq!(body_b, "world-response");
5606
5607 token_a.cancel();
5608 token_b.cancel();
5609 }
5610
5611 #[tokio::test]
5612 #[allow(clippy::await_holding_lock)]
5613 async fn test_integration_unregistered_path_returns_404() {
5614 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5615
5616 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5617
5618 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5620 let port = listener.local_addr().unwrap().port();
5621 drop(listener);
5622
5623 let component = HttpComponent::new();
5624 let endpoint_ctx = NoOpComponentContext;
5625 let endpoint = component
5626 .create_endpoint(
5627 &format!("http://127.0.0.1:{port}/registered"),
5628 &endpoint_ctx,
5629 )
5630 .unwrap();
5631 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5632
5633 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5634 let token = tokio_util::sync::CancellationToken::new();
5635 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5636
5637 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5638
5639 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
5641 loop {
5642 if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
5643 .await
5644 .is_ok()
5645 {
5646 break;
5647 }
5648 if std::time::Instant::now() >= deadline {
5649 panic!("HTTP server did not start within 5s on port {port}");
5650 }
5651 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
5652 }
5653
5654 let client = reqwest::Client::new();
5655 let resp = client
5656 .get(format!("http://127.0.0.1:{port}/not-there"))
5657 .send()
5658 .await
5659 .unwrap();
5660 assert_eq!(resp.status().as_u16(), 404);
5661
5662 token.cancel();
5663 }
5664
5665 #[test]
5666 fn test_http_consumer_declares_concurrent() {
5667 use camel_component_api::ConcurrencyModel;
5668
5669 let config = HttpServerConfig {
5670 scheme: "http".to_string(),
5671 host: "127.0.0.1".to_string(),
5672 port: 19999,
5673 path: "/test".to_string(),
5674 max_request_body: 2 * 1024 * 1024,
5675 max_response_body: 10 * 1024 * 1024,
5676 max_inflight_requests: 1024,
5677 method: None,
5678 tls_config: None,
5679 };
5680 let consumer = HttpConsumer::new(config, test_rt());
5681 assert_eq!(
5682 consumer.concurrency_model(),
5683 ConcurrencyModel::Concurrent { max: None }
5684 );
5685 }
5686
5687 #[test]
5688 fn server_config_parses_tls_cert_and_key() {
5689 let cfg = HttpServerConfig::from_uri(
5690 "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
5691 )
5692 .unwrap();
5693 assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
5694 assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
5695 }
5696
5697 #[test]
5698 fn server_config_no_tls_when_params_absent() {
5699 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
5700 assert!(cfg.tls_config.is_none());
5701 }
5702
5703 #[tokio::test]
5708 async fn test_http_reply_body_stream_variant_exists() {
5709 use bytes::Bytes;
5710 use camel_component_api::CamelError;
5711 use futures::stream;
5712
5713 let chunks: Vec<Result<Bytes, CamelError>> =
5714 vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
5715 let stream = Box::pin(stream::iter(chunks));
5716 let reply_body = HttpReplyBody::Stream(stream);
5717 match reply_body {
5719 HttpReplyBody::Stream(_) => {}
5720 HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
5721 }
5722 }
5723
5724 #[cfg(feature = "otel")]
5729 mod otel_tests {
5730 use super::*;
5731 use camel_component_api::Message;
5732 use tower::ServiceExt;
5733
5734 #[tokio::test]
5735 async fn test_producer_injects_traceparent_header() {
5736 let (url, _handle) = start_test_server_with_header_capture().await;
5737 let ctx = test_producer_ctx();
5738
5739 let component = HttpComponent::new();
5740 let endpoint_ctx = NoOpComponentContext;
5741 let endpoint = component
5742 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5743 .unwrap();
5744 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5745
5746 let mut exchange = Exchange::new(Message::default());
5748 let mut headers = std::collections::HashMap::new();
5749 headers.insert(
5750 "traceparent".to_string(),
5751 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
5752 );
5753 camel_otel::extract_into_exchange(&mut exchange, &headers);
5754
5755 let result = producer.oneshot(exchange).await.unwrap();
5756
5757 let status = result
5759 .input
5760 .header("CamelHttpResponseCode")
5761 .and_then(|v| v.as_u64())
5762 .unwrap();
5763 assert_eq!(status, 200);
5764
5765 let traceparent = result.input.header("X-Received-Traceparent");
5767 assert!(
5768 traceparent.is_some(),
5769 "traceparent header should have been sent"
5770 );
5771
5772 let traceparent_str = traceparent.unwrap().as_str().unwrap();
5773 let parts: Vec<&str> = traceparent_str.split('-').collect();
5775 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
5776 assert_eq!(parts[0], "00", "version should be 00");
5777 assert_eq!(
5778 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
5779 "trace-id should match"
5780 );
5781 assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
5782 assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
5783 }
5784
5785 #[tokio::test]
5786 async fn test_consumer_extracts_traceparent_header() {
5787 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5788
5789 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5791 let port = listener.local_addr().unwrap().port();
5792 drop(listener);
5793
5794 let component = HttpComponent::new();
5795 let endpoint_ctx = NoOpComponentContext;
5796 let endpoint = component
5797 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
5798 .unwrap();
5799 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5800
5801 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5802 let token = tokio_util::sync::CancellationToken::new();
5803 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5804
5805 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5806 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5807
5808 let client = reqwest::Client::new();
5810 let send_fut = client
5811 .post(format!("http://127.0.0.1:{port}/trace"))
5812 .header(
5813 "traceparent",
5814 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
5815 )
5816 .body("test")
5817 .send();
5818
5819 let (http_result, _) = tokio::join!(send_fut, async {
5820 if let Some(envelope) = rx.recv().await {
5821 let mut injected_headers = std::collections::HashMap::new();
5824 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
5825
5826 assert!(
5827 injected_headers.contains_key("traceparent"),
5828 "Exchange should have traceparent after extraction"
5829 );
5830
5831 let traceparent = injected_headers.get("traceparent").unwrap();
5832 let parts: Vec<&str> = traceparent.split('-').collect();
5833 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
5834 assert_eq!(
5835 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
5836 "Trace ID should match the original traceparent header"
5837 );
5838
5839 if let Some(reply_tx) = envelope.reply_tx {
5840 let _ = reply_tx.send(Ok(envelope.exchange));
5841 }
5842 }
5843 });
5844
5845 let resp = http_result.unwrap();
5846 assert_eq!(resp.status().as_u16(), 200);
5847
5848 token.cancel();
5849 }
5850
5851 #[tokio::test]
5852 async fn test_consumer_extracts_mixed_case_traceparent_header() {
5853 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5854
5855 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5857 let port = listener.local_addr().unwrap().port();
5858 drop(listener);
5859
5860 let component = HttpComponent::new();
5861 let endpoint_ctx = NoOpComponentContext;
5862 let endpoint = component
5863 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
5864 .unwrap();
5865 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5866
5867 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5868 let token = tokio_util::sync::CancellationToken::new();
5869 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5870
5871 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5872 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5873
5874 let client = reqwest::Client::new();
5876 let send_fut = client
5877 .post(format!("http://127.0.0.1:{port}/trace"))
5878 .header(
5879 "TraceParent",
5880 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
5881 )
5882 .body("test")
5883 .send();
5884
5885 let (http_result, _) = tokio::join!(send_fut, async {
5886 if let Some(envelope) = rx.recv().await {
5887 let mut injected_headers = HashMap::new();
5890 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
5891
5892 assert!(
5893 injected_headers.contains_key("traceparent"),
5894 "Exchange should have traceparent after extraction from mixed-case header"
5895 );
5896
5897 let traceparent = injected_headers.get("traceparent").unwrap();
5898 let parts: Vec<&str> = traceparent.split('-').collect();
5899 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
5900 assert_eq!(
5901 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
5902 "Trace ID should match the original mixed-case TraceParent header"
5903 );
5904
5905 if let Some(reply_tx) = envelope.reply_tx {
5906 let _ = reply_tx.send(Ok(envelope.exchange));
5907 }
5908 }
5909 });
5910
5911 let resp = http_result.unwrap();
5912 assert_eq!(resp.status().as_u16(), 200);
5913
5914 token.cancel();
5915 }
5916
5917 #[tokio::test]
5918 async fn test_producer_no_trace_context_no_crash() {
5919 let (url, _handle) = start_test_server().await;
5920 let ctx = test_producer_ctx();
5921
5922 let component = HttpComponent::new();
5923 let endpoint_ctx = NoOpComponentContext;
5924 let endpoint = component
5925 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5926 .unwrap();
5927 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5928
5929 let exchange = Exchange::new(Message::default());
5931
5932 let result = producer.oneshot(exchange).await.unwrap();
5934
5935 let status = result
5937 .input
5938 .header("CamelHttpResponseCode")
5939 .and_then(|v| v.as_u64())
5940 .unwrap();
5941 assert_eq!(status, 200);
5942 }
5943
5944 async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
5946 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5947 let addr = listener.local_addr().unwrap();
5948 let url = format!("http://127.0.0.1:{}", addr.port());
5949
5950 let handle = tokio::spawn(async move {
5951 loop {
5952 if let Ok((mut stream, _)) = listener.accept().await {
5953 tokio::spawn(async move {
5954 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5955 let mut buf = vec![0u8; 8192];
5956 let n = stream.read(&mut buf).await.unwrap_or(0);
5957 let request = String::from_utf8_lossy(&buf[..n]).to_string();
5958
5959 let traceparent = request
5961 .lines()
5962 .find(|line| line.to_lowercase().starts_with("traceparent:"))
5963 .map(|line| {
5964 line.split(':')
5965 .nth(1)
5966 .map(|s| s.trim().to_string())
5967 .unwrap_or_default()
5968 })
5969 .unwrap_or_default();
5970
5971 let body =
5972 format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
5973 let response = format!(
5974 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
5975 body.len(),
5976 traceparent,
5977 body
5978 );
5979 let _ = stream.write_all(response.as_bytes()).await;
5980 });
5981 }
5982 }
5983 });
5984
5985 (url, handle)
5986 }
5987 }
5988
5989 #[tokio::test]
5998 async fn test_request_body_arrives_as_stream() {
5999 use camel_component_api::Body;
6000 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6001
6002 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6003 let port = listener.local_addr().unwrap().port();
6004 drop(listener);
6005
6006 let component = HttpComponent::new();
6007 let endpoint_ctx = NoOpComponentContext;
6008 let endpoint = component
6009 .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
6010 .unwrap();
6011 let mut consumer = endpoint.create_consumer(rt()).unwrap();
6012
6013 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6014 let token = tokio_util::sync::CancellationToken::new();
6015 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6016
6017 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6018 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6019
6020 let client = reqwest::Client::new();
6021 let send_fut = client
6022 .post(format!("http://127.0.0.1:{port}/upload"))
6023 .body("hello streaming world")
6024 .send();
6025
6026 let (http_result, _) = tokio::join!(send_fut, async {
6027 if let Some(mut envelope) = rx.recv().await {
6028 assert!(
6030 matches!(envelope.exchange.input.body, Body::Stream(_)),
6031 "expected Body::Stream, got discriminant {:?}",
6032 std::mem::discriminant(&envelope.exchange.input.body)
6033 );
6034 let bytes = envelope
6036 .exchange
6037 .input
6038 .body
6039 .into_bytes(1024 * 1024)
6040 .await
6041 .unwrap();
6042 assert_eq!(&bytes[..], b"hello streaming world");
6043
6044 envelope.exchange.input.body = camel_component_api::Body::Empty;
6045 if let Some(reply_tx) = envelope.reply_tx {
6046 let _ = reply_tx.send(Ok(envelope.exchange));
6047 }
6048 }
6049 });
6050
6051 let resp = http_result.unwrap();
6052 assert_eq!(resp.status().as_u16(), 200);
6053
6054 token.cancel();
6055 }
6056
6057 #[tokio::test]
6062 async fn test_streaming_response_chunked() {
6063 use bytes::Bytes;
6064 use camel_component_api::Body;
6065 use camel_component_api::CamelError;
6066 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6067 use camel_component_api::{StreamBody, StreamMetadata};
6068 use futures::stream;
6069 use std::sync::Arc;
6070 use tokio::sync::Mutex;
6071
6072 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6073 let port = listener.local_addr().unwrap().port();
6074 drop(listener);
6075
6076 let component = HttpComponent::new();
6077 let endpoint_ctx = NoOpComponentContext;
6078 let endpoint = component
6079 .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
6080 .unwrap();
6081 let mut consumer = endpoint.create_consumer(rt()).unwrap();
6082
6083 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6084 let token = tokio_util::sync::CancellationToken::new();
6085 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6086
6087 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6088 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6089
6090 let client = reqwest::Client::new();
6091 let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
6092
6093 let (http_result, _) = tokio::join!(send_fut, async {
6094 if let Some(mut envelope) = rx.recv().await {
6095 let chunks: Vec<Result<Bytes, CamelError>> =
6097 vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
6098 let stream = Box::pin(stream::iter(chunks));
6099 envelope.exchange.input.body = Body::Stream(StreamBody {
6100 stream: Arc::new(Mutex::new(Some(stream))),
6101 metadata: StreamMetadata::default(),
6102 });
6103 if let Some(reply_tx) = envelope.reply_tx {
6104 let _ = reply_tx.send(Ok(envelope.exchange));
6105 }
6106 }
6107 });
6108
6109 let resp = http_result.unwrap();
6110 assert_eq!(resp.status().as_u16(), 200);
6111 let body = resp.text().await.unwrap();
6112 assert_eq!(body, "chunk1chunk2");
6113
6114 token.cancel();
6115 }
6116
6117 #[tokio::test]
6122 async fn test_413_when_content_length_exceeds_limit() {
6123 use camel_component_api::ConsumerContext;
6124
6125 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6126 let port = listener.local_addr().unwrap().port();
6127 drop(listener);
6128
6129 let component = HttpComponent::new();
6131 let endpoint_ctx = NoOpComponentContext;
6132 let endpoint = component
6133 .create_endpoint(
6134 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
6135 &endpoint_ctx,
6136 )
6137 .unwrap();
6138 let mut consumer = endpoint.create_consumer(rt()).unwrap();
6139
6140 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6141 let token = tokio_util::sync::CancellationToken::new();
6142 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6143
6144 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6145 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6146
6147 let client = reqwest::Client::new();
6148 let resp = client
6149 .post(format!("http://127.0.0.1:{port}/upload"))
6150 .header("Content-Length", "1000") .body("x".repeat(1000))
6152 .send()
6153 .await
6154 .unwrap();
6155
6156 assert_eq!(resp.status().as_u16(), 413);
6157
6158 token.cancel();
6159 }
6160
6161 #[tokio::test]
6165 async fn test_chunked_upload_without_content_length_bypasses_limit() {
6166 use bytes::Bytes;
6167 use camel_component_api::Body;
6168 use camel_component_api::ConsumerContext;
6169 use futures::stream;
6170
6171 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6172 let port = listener.local_addr().unwrap().port();
6173 drop(listener);
6174
6175 let component = HttpComponent::new();
6177 let endpoint_ctx = NoOpComponentContext;
6178 let endpoint = component
6179 .create_endpoint(
6180 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
6181 &endpoint_ctx,
6182 )
6183 .unwrap();
6184 let mut consumer = endpoint.create_consumer(rt()).unwrap();
6185
6186 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6187 let token = tokio_util::sync::CancellationToken::new();
6188 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6189
6190 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6191 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6192
6193 let client = reqwest::Client::new();
6194
6195 let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
6199 Ok(Bytes::from("y".repeat(50))),
6200 Ok(Bytes::from("y".repeat(50))),
6201 ];
6202 let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
6203 let send_fut = client
6204 .post(format!("http://127.0.0.1:{port}/upload"))
6205 .body(stream_body)
6206 .send();
6207
6208 let consumer_fut = async {
6209 match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
6211 Ok(Some(mut envelope)) => {
6212 assert!(
6213 matches!(envelope.exchange.input.body, Body::Stream(_)),
6214 "expected Body::Stream"
6215 );
6216 envelope.exchange.input.body = camel_component_api::Body::Empty;
6217 if let Some(reply_tx) = envelope.reply_tx {
6218 let _ = reply_tx.send(Ok(envelope.exchange));
6219 }
6220 }
6221 Ok(None) => panic!("consumer channel closed unexpectedly"),
6222 Err(_) => {
6223 }
6226 }
6227 };
6228
6229 let (http_result, _) = tokio::join!(send_fut, consumer_fut);
6230
6231 let resp = http_result.unwrap();
6232 assert_ne!(
6234 resp.status().as_u16(),
6235 413,
6236 "chunked upload must not be rejected by maxRequestBody"
6237 );
6238 assert_eq!(resp.status().as_u16(), 200);
6239
6240 token.cancel();
6241 }
6242
6243 #[test]
6244 fn test_is_private_ip_ranges() {
6245 use camel_api::is_ssrf_blocked_ip;
6246 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(
6265 &"2001:4860:4860::8888".parse().unwrap()
6266 )); }
6268
6269 #[test]
6270 fn test_title_case_header() {
6271 assert_eq!(title_case_header("content-type"), "Content-Type");
6272 assert_eq!(title_case_header("authorization"), "Authorization");
6273 assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
6274 assert_eq!(title_case_header("host"), "Host");
6275 assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
6276 assert_eq!(title_case_header("single"), "Single");
6277 assert_eq!(title_case_header(""), "");
6278 }
6279
6280 #[test]
6281 fn test_resolve_url_combines_path_and_query_sources() {
6282 let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
6283 let mut exchange = Exchange::new(Message::default());
6284 exchange.input.set_header(
6285 "CamelHttpPath",
6286 serde_json::Value::String("next".to_string()),
6287 );
6288 let url = HttpProducer::resolve_url(&exchange, &cfg);
6289 assert!(url.starts_with("http://example.com/base/next?"));
6290 assert!(url.contains("foo=bar"));
6291
6292 exchange.input.set_header(
6293 "CamelHttpUri",
6294 serde_json::Value::String("http://other.test/root".to_string()),
6295 );
6296 exchange.input.set_header(
6297 "CamelHttpQuery",
6298 serde_json::Value::String("a=1&b=2".to_string()),
6299 );
6300
6301 let override_url = HttpProducer::resolve_url(&exchange, &cfg);
6302 assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
6303 }
6304
6305 fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
6306 let mut exchange = Exchange::new(Message::default());
6307 exchange
6308 .input
6309 .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
6310 exchange.input.set_header(
6311 "CamelHttpQuery",
6312 serde_json::Value::String(query.to_string()),
6313 );
6314 exchange
6315 }
6316
6317 #[test]
6318 fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
6319 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6320 cfg.bridge_endpoint = true;
6321 cfg.query_params
6322 .insert("token".to_string(), "secret".to_string());
6323 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
6324 let url = HttpProducer::resolve_url(&exchange, &cfg);
6325 assert_eq!(url, "http://x/?token=secret");
6326 assert!(!url.contains("/foo"));
6327 assert!(!url.contains("dropme"));
6328 }
6329
6330 #[test]
6331 fn resolve_url_bridge_endpoint_false_merges_path() {
6332 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6333 cfg.bridge_endpoint = false;
6334 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
6335 let url = HttpProducer::resolve_url(&exchange, &cfg);
6336 assert!(url.contains("/foo"), "url should contain /foo: {url}");
6337 assert!(
6338 url.contains("dropme=1"),
6339 "url should contain dropme=1: {url}"
6340 );
6341 }
6342
6343 #[test]
6344 fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
6345 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6346 cfg.bridge_endpoint = true;
6347 let mut exchange = Exchange::new(Message::default());
6348 exchange.input.set_header(
6349 "CamelHttpPath",
6350 serde_json::Value::String("/foo".to_string()),
6351 );
6352 let url = HttpProducer::resolve_url(&exchange, &cfg);
6353 assert_eq!(url, "http://x");
6354 assert!(!url.contains("/foo"));
6355 }
6356
6357 #[test]
6358 fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
6359 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6360 cfg.bridge_endpoint = true;
6361 let mut exchange = Exchange::new(Message::default());
6363 exchange.input.set_header(
6364 "CamelHttpUri",
6365 serde_json::Value::String("http://dest/explicit".to_string()),
6366 );
6367 exchange.input.set_header(
6368 "CamelHttpPath",
6369 serde_json::Value::String("/foo".to_string()),
6370 );
6371 exchange.input.set_header(
6372 "CamelHttpQuery",
6373 serde_json::Value::String("x=1".to_string()),
6374 );
6375 let url = HttpProducer::resolve_url(&exchange, &cfg);
6376 assert_eq!(url, "http://x");
6380 }
6381
6382 #[test]
6383 fn test_http_producer_helpers_status_and_size_boundaries() {
6384 assert!(HttpProducer::is_ok_status(200, (200, 299)));
6385 assert!(HttpProducer::is_ok_status(299, (200, 299)));
6386 assert!(!HttpProducer::is_ok_status(199, (200, 299)));
6387 assert!(!HttpProducer::is_ok_status(300, (200, 299)));
6388
6389 assert!(!exceeds_max_response_body(10, 10));
6390 assert!(exceeds_max_response_body(11, 10));
6391 }
6392
6393 async fn setup_consumer_on_free_port(
6398 path: &str,
6399 ) -> (
6400 u16,
6401 tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
6402 tokio_util::sync::CancellationToken,
6403 ) {
6404 use camel_component_api::ConsumerContext;
6405
6406 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6407 let port = listener.local_addr().unwrap().port();
6408 drop(listener);
6409
6410 let consumer_cfg = HttpServerConfig {
6411 scheme: "http".to_string(),
6412 host: "127.0.0.1".to_string(),
6413 port,
6414 path: path.to_string(),
6415 max_request_body: 2 * 1024 * 1024,
6416 max_response_body: 10 * 1024 * 1024,
6417 max_inflight_requests: 1024,
6418 method: None,
6419 tls_config: None,
6420 };
6421 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6422
6423 let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6424 let token = tokio_util::sync::CancellationToken::new();
6425 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6426
6427 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6428 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6429
6430 (port, rx, token)
6431 }
6432
6433 #[tokio::test]
6434 async fn test_content_type_inferred_for_json_body() {
6435 let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
6436
6437 let client = reqwest::Client::new();
6438 let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
6439
6440 let (http_result, _) = tokio::join!(send_fut, async {
6441 if let Some(mut envelope) = rx.recv().await {
6442 envelope.exchange.input.body =
6443 camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
6444 if let Some(reply_tx) = envelope.reply_tx {
6445 let _ = reply_tx.send(Ok(envelope.exchange));
6446 }
6447 }
6448 });
6449
6450 let resp = http_result.unwrap();
6451 assert_eq!(resp.status().as_u16(), 200);
6452 let ct = resp
6453 .headers()
6454 .get("content-type")
6455 .expect("Content-Type header should be present");
6456 assert_eq!(ct, "application/json");
6457 let body = resp.text().await.unwrap();
6458 assert_eq!(body, r#"{"message":"hello"}"#);
6459
6460 token.cancel();
6461 }
6462
6463 #[tokio::test]
6464 async fn test_content_type_inferred_for_text_body() {
6465 let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
6466
6467 let client = reqwest::Client::new();
6468 let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
6469
6470 let (http_result, _) = tokio::join!(send_fut, async {
6471 if let Some(mut envelope) = rx.recv().await {
6472 envelope.exchange.input.body =
6473 camel_component_api::Body::Text("plain text response".to_string());
6474 if let Some(reply_tx) = envelope.reply_tx {
6475 let _ = reply_tx.send(Ok(envelope.exchange));
6476 }
6477 }
6478 });
6479
6480 let resp = http_result.unwrap();
6481 assert_eq!(resp.status().as_u16(), 200);
6482 let ct = resp
6483 .headers()
6484 .get("content-type")
6485 .expect("Content-Type header should be present");
6486 assert_eq!(ct, "text/plain; charset=utf-8");
6487 let body = resp.text().await.unwrap();
6488 assert_eq!(body, "plain text response");
6489
6490 token.cancel();
6491 }
6492
6493 #[tokio::test]
6494 async fn test_content_type_inferred_for_xml_body() {
6495 let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
6496
6497 let client = reqwest::Client::new();
6498 let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
6499
6500 let (http_result, _) = tokio::join!(send_fut, async {
6501 if let Some(mut envelope) = rx.recv().await {
6502 envelope.exchange.input.body =
6503 camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
6504 if let Some(reply_tx) = envelope.reply_tx {
6505 let _ = reply_tx.send(Ok(envelope.exchange));
6506 }
6507 }
6508 });
6509
6510 let resp = http_result.unwrap();
6511 assert_eq!(resp.status().as_u16(), 200);
6512 let ct = resp
6513 .headers()
6514 .get("content-type")
6515 .expect("Content-Type header should be present");
6516 assert_eq!(ct, "application/xml");
6517 let body = resp.text().await.unwrap();
6518 assert_eq!(body, "<root><item>value</item></root>");
6519
6520 token.cancel();
6521 }
6522
6523 #[tokio::test]
6524 async fn test_no_content_type_for_empty_body() {
6525 let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
6526
6527 let client = reqwest::Client::new();
6528 let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
6529
6530 let (http_result, _) = tokio::join!(send_fut, async {
6531 if let Some(mut envelope) = rx.recv().await {
6532 envelope.exchange.input.body = camel_component_api::Body::Empty;
6533 if let Some(reply_tx) = envelope.reply_tx {
6534 let _ = reply_tx.send(Ok(envelope.exchange));
6535 }
6536 }
6537 });
6538
6539 let resp = http_result.unwrap();
6540 assert_eq!(resp.status().as_u16(), 200);
6541 assert!(
6542 resp.headers().get("content-type").is_none(),
6543 "Empty body should not set Content-Type"
6544 );
6545
6546 token.cancel();
6547 }
6548
6549 #[tokio::test]
6550 async fn test_no_content_type_for_raw_bytes_body() {
6551 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
6552
6553 let client = reqwest::Client::new();
6554 let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
6555
6556 let (http_result, _) = tokio::join!(send_fut, async {
6557 if let Some(mut envelope) = rx.recv().await {
6558 envelope.exchange.input.body =
6559 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
6560 if let Some(reply_tx) = envelope.reply_tx {
6561 let _ = reply_tx.send(Ok(envelope.exchange));
6562 }
6563 }
6564 });
6565
6566 let resp = http_result.unwrap();
6567 assert_eq!(resp.status().as_u16(), 200);
6568 assert!(
6569 resp.headers().get("content-type").is_none(),
6570 "Raw Bytes body should not set Content-Type"
6571 );
6572
6573 token.cancel();
6574 }
6575
6576 #[tokio::test]
6577 async fn test_content_type_from_stream_metadata() {
6578 use camel_component_api::{StreamBody, StreamMetadata};
6579 use futures::stream;
6580
6581 let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
6582
6583 let client = reqwest::Client::new();
6584 let send_fut = client
6585 .get(format!("http://127.0.0.1:{port}/stream-ct"))
6586 .send();
6587
6588 let (http_result, _) = tokio::join!(send_fut, async {
6589 if let Some(mut envelope) = rx.recv().await {
6590 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
6591 vec![Ok(bytes::Bytes::from("audio data"))];
6592 let stream = Box::pin(stream::iter(chunks));
6593 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
6594 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
6595 metadata: StreamMetadata {
6596 size_hint: None,
6597 content_type: Some("audio/mpeg".to_string()),
6598 origin: None,
6599 },
6600 });
6601 if let Some(reply_tx) = envelope.reply_tx {
6602 let _ = reply_tx.send(Ok(envelope.exchange));
6603 }
6604 }
6605 });
6606
6607 let resp = http_result.unwrap();
6608 assert_eq!(resp.status().as_u16(), 200);
6609 let ct = resp
6610 .headers()
6611 .get("content-type")
6612 .expect("Content-Type header should be present");
6613 assert_eq!(ct, "audio/mpeg");
6614 let body = resp.text().await.unwrap();
6615 assert_eq!(body, "audio data");
6616
6617 token.cancel();
6618 }
6619
6620 #[tokio::test]
6621 async fn test_user_content_type_overrides_inferred() {
6622 let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
6623
6624 let client = reqwest::Client::new();
6625 let send_fut = client
6626 .get(format!("http://127.0.0.1:{port}/override-ct"))
6627 .send();
6628
6629 let (http_result, _) = tokio::join!(send_fut, async {
6630 if let Some(mut envelope) = rx.recv().await {
6631 envelope.exchange.input.body =
6632 camel_component_api::Body::Json(serde_json::json!({"ok": true}));
6633 envelope.exchange.input.set_header(
6634 "Content-Type",
6635 serde_json::Value::String("text/html".to_string()),
6636 );
6637 if let Some(reply_tx) = envelope.reply_tx {
6638 let _ = reply_tx.send(Ok(envelope.exchange));
6639 }
6640 }
6641 });
6642
6643 let resp = http_result.unwrap();
6644 assert_eq!(resp.status().as_u16(), 200);
6645 let ct = resp
6646 .headers()
6647 .get("content-type")
6648 .expect("Content-Type header should be present");
6649 assert_eq!(
6650 ct, "text/html",
6651 "User-set Content-Type should take precedence over inferred type"
6652 );
6653
6654 token.cancel();
6655 }
6656
6657 #[tokio::test]
6658 async fn test_user_content_type_with_bytes_body() {
6659 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
6660
6661 let client = reqwest::Client::new();
6662 let send_fut = client
6663 .get(format!("http://127.0.0.1:{port}/bytes-ct"))
6664 .send();
6665
6666 let (http_result, _) = tokio::join!(send_fut, async {
6667 if let Some(mut envelope) = rx.recv().await {
6668 envelope.exchange.input.body =
6669 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
6670 envelope.exchange.input.set_header(
6671 "Content-Type",
6672 serde_json::Value::String("application/json".to_string()),
6673 );
6674 if let Some(reply_tx) = envelope.reply_tx {
6675 let _ = reply_tx.send(Ok(envelope.exchange));
6676 }
6677 }
6678 });
6679
6680 let resp = http_result.unwrap();
6681 assert_eq!(resp.status().as_u16(), 200);
6682 let ct = resp
6683 .headers()
6684 .get("content-type")
6685 .expect("Content-Type header should be present for Bytes body with user header");
6686 assert_eq!(
6687 ct, "application/json",
6688 "User Content-Type should be sent for Bytes body"
6689 );
6690
6691 token.cancel();
6692 }
6693
6694 #[tokio::test]
6699 async fn monitor_task_silent_on_clean_exit() {
6700 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
6701 monitor_axum_task(
6703 handle,
6704 "127.0.0.1:0".to_string(),
6705 noop_rt(),
6706 "test-monitor".into(),
6707 )
6708 .await;
6709 }
6710
6711 #[tokio::test]
6712 async fn monitor_task_handles_panicked_task() {
6713 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
6714 panic!("simulated server crash");
6715 });
6716 monitor_axum_task(
6718 handle,
6719 "127.0.0.1:9999".to_string(),
6720 noop_rt(),
6721 "test-monitor".into(),
6722 )
6723 .await;
6724 }
6725
6726 #[test]
6731 fn http_auth_basic_debug_redacts_password() {
6732 let auth = HttpAuth::Basic {
6733 username: "admin".to_string(),
6734 password: "hunter2".to_string(),
6735 };
6736 let debug = format!("{:?}", auth);
6737 assert!(
6738 !debug.contains("hunter2"),
6739 "password must be redacted: {debug}"
6740 );
6741 assert!(debug.contains("admin"), "username should appear: {debug}");
6742 }
6743
6744 #[test]
6745 fn http_auth_bearer_debug_redacts_token() {
6746 let auth = HttpAuth::Bearer {
6747 token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
6748 };
6749 let debug = format!("{:?}", auth);
6750 assert!(
6751 !debug.contains("eyJhbGci"),
6752 "token must be redacted: {debug}"
6753 );
6754 }
6755
6756 #[test]
6757 fn http_auth_none_debug_shows_variant() {
6758 let debug = format!("{:?}", HttpAuth::None);
6759 assert!(
6760 debug.contains("None"),
6761 "None variant should appear: {debug}"
6762 );
6763 }
6764
6765 #[test]
6766 fn http_endpoint_config_debug_redacts_auth_credentials() {
6767 let config = HttpEndpointConfig::from_uri(
6768 "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
6769 )
6770 .unwrap();
6771 let debug = format!("{:?}", config);
6772 assert!(
6773 !debug.contains("secret123"),
6774 "password must be redacted in HttpEndpointConfig debug: {debug}"
6775 );
6776 }
6777
6778 use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
6783 use tower_http::services::ServeDir;
6784
6785 fn make_test_registry() -> HttpRouteRegistry {
6786 HttpRouteRegistry::new()
6787 }
6788
6789 fn make_test_state(registry: HttpRouteRegistry) -> AppState {
6790 AppState {
6791 registry,
6792 max_request_body: 2 * 1024 * 1024,
6793 max_response_body: 10 * 1024 * 1024,
6794 inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
6795 }
6796 }
6797
6798 #[allow(clippy::await_holding_lock)]
6799 #[tokio::test]
6800 async fn test_static_file_serving_serves_file_contents() {
6801 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6802 ServerRegistry::reset();
6803
6804 let temp_dir =
6806 std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
6807 std::fs::create_dir_all(&temp_dir).unwrap();
6808 std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
6809 std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
6810
6811 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
6812
6813 let registry = make_test_registry();
6814 let serve_dir = ServeDir::new(&canonical_dir)
6815 .precompressed_gzip()
6816 .precompressed_br()
6817 .append_index_html_on_directories(true);
6818
6819 let mount = StaticMount {
6820 mount_path: "/".to_string(),
6821 mode: MountMode::Static,
6822 dir: canonical_dir.clone(),
6823 cache_control: "public, max-age=3600".to_string(),
6824 error_pages: std::collections::HashMap::new(),
6825 serve_dir,
6826 };
6827 registry.register_static_mount(mount).await.unwrap();
6828
6829 let state = make_test_state(registry);
6830
6831 let req = Request::builder()
6833 .uri("/hello.txt")
6834 .body(AxumBody::empty())
6835 .unwrap();
6836 let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
6837 assert_eq!(resp.status(), StatusCode::OK);
6838 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6839 .await
6840 .unwrap();
6841 assert_eq!(&body[..], b"Hello, static world!");
6842
6843 let req = Request::builder()
6845 .uri("/style.css")
6846 .body(AxumBody::empty())
6847 .unwrap();
6848 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
6849 assert_eq!(resp.status(), StatusCode::OK);
6850 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6851 .await
6852 .unwrap();
6853 assert_eq!(&body[..], b"body { color: red; }");
6854
6855 let req = Request::builder()
6857 .uri("/missing.txt")
6858 .body(AxumBody::empty())
6859 .unwrap();
6860 let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
6861 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
6862
6863 std::fs::remove_dir_all(&temp_dir).ok();
6865 }
6866
6867 #[allow(clippy::await_holding_lock)]
6868 #[tokio::test]
6869 async fn test_spa_fallback_serves_index_for_unknown_paths() {
6870 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6871 ServerRegistry::reset();
6872
6873 let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
6874 std::fs::create_dir_all(&temp_dir).unwrap();
6875 std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
6876 std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
6877
6878 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
6879
6880 let registry = make_test_registry();
6881 let serve_dir = ServeDir::new(&canonical_dir)
6882 .precompressed_gzip()
6883 .precompressed_br()
6884 .append_index_html_on_directories(true);
6885
6886 let mount = StaticMount {
6887 mount_path: "/".to_string(),
6888 mode: MountMode::Spa,
6889 dir: canonical_dir.clone(),
6890 cache_control: "public, max-age=0".to_string(),
6891 error_pages: std::collections::HashMap::new(),
6892 serve_dir,
6893 };
6894 registry.register_static_mount(mount).await.unwrap();
6896
6897 let state = make_test_state(registry);
6898
6899 let req = Request::builder()
6901 .method("GET")
6902 .uri("/dashboard")
6903 .header("Accept", "text/html")
6904 .body(AxumBody::empty())
6905 .unwrap();
6906 let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
6907 assert_eq!(resp.status(), StatusCode::OK);
6908 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6909 .await
6910 .unwrap();
6911 assert_eq!(&body[..], b"<h1>SPA App</h1>");
6912
6913 let req = Request::builder()
6915 .method("GET")
6916 .uri("/app.js")
6917 .body(AxumBody::empty())
6918 .unwrap();
6919 let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
6920 assert_eq!(resp.status(), StatusCode::OK);
6921 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6922 .await
6923 .unwrap();
6924 assert_eq!(&body[..], b"console.log('app')");
6925
6926 let req = Request::builder()
6928 .method("GET")
6929 .uri("/api/data")
6930 .header("Accept", "application/json")
6931 .body(AxumBody::empty())
6932 .unwrap();
6933 let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
6934 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
6935
6936 let req = Request::builder()
6938 .method("GET")
6939 .uri("/style.css")
6940 .header("Accept", "text/html")
6941 .body(AxumBody::empty())
6942 .unwrap();
6943 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
6944 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
6945
6946 std::fs::remove_dir_all(&temp_dir).ok();
6948 }
6949
6950 #[allow(clippy::await_holding_lock)]
6957 async fn run_conditional_get_returns_304(mode: MountMode) {
6958 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6959 ServerRegistry::reset();
6960
6961 let temp_dir = std::env::temp_dir().join(format!(
6962 "http_cond_get_{}_{}",
6963 if mode == MountMode::Spa {
6964 "spa"
6965 } else {
6966 "static"
6967 },
6968 std::process::id()
6969 ));
6970 std::fs::create_dir_all(&temp_dir).unwrap();
6971 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
6972
6973 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
6974
6975 let registry = make_test_registry();
6976 let serve_dir = ServeDir::new(&canonical_dir)
6977 .precompressed_gzip()
6978 .precompressed_br()
6979 .append_index_html_on_directories(true);
6980
6981 let mount = StaticMount {
6982 mount_path: "/".to_string(),
6983 mode,
6984 dir: canonical_dir.clone(),
6985 cache_control: "public, max-age=3600".to_string(),
6986 error_pages: std::collections::HashMap::new(),
6987 serve_dir,
6988 };
6989 registry.register_static_mount(mount).await.unwrap();
6990
6991 let state = make_test_state(registry);
6992
6993 let req = Request::builder()
6995 .method("GET")
6996 .uri("/index.html")
6997 .body(AxumBody::empty())
6998 .unwrap();
6999 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
7000 assert_eq!(
7001 resp.status(),
7002 StatusCode::OK,
7003 "first GET should return 200, got {}",
7004 resp.status()
7005 );
7006 assert!(
7008 resp.headers().contains_key(http::header::CACHE_CONTROL),
7009 "200 response missing Cache-Control"
7010 );
7011 let etag = resp
7012 .headers()
7013 .get(http::header::ETAG)
7014 .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
7015 .clone();
7016 let last_modified = resp
7017 .headers()
7018 .get(http::header::LAST_MODIFIED)
7019 .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
7020 .clone();
7021 let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
7023 .await
7024 .unwrap();
7025
7026 let req = Request::builder()
7030 .method("GET")
7031 .uri("/index.html")
7032 .header(http::header::IF_NONE_MATCH, etag.clone())
7033 .body(AxumBody::empty())
7034 .unwrap();
7035 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
7036 assert_eq!(
7037 resp.status(),
7038 StatusCode::NOT_MODIFIED,
7039 "If-None-Match with matching ETag should return 304, got {}",
7040 resp.status()
7041 );
7042 assert!(
7044 resp.headers().contains_key(http::header::CACHE_CONTROL),
7045 "304 (If-None-Match) missing Cache-Control"
7046 );
7047 assert_eq!(
7050 resp.headers().get(http::header::ETAG),
7051 Some(&etag),
7052 "304 (If-None-Match) must echo the ETag validator"
7053 );
7054 assert_eq!(
7055 resp.headers().get(http::header::LAST_MODIFIED),
7056 Some(&last_modified),
7057 "304 (If-None-Match) must carry Last-Modified"
7058 );
7059
7060 let req = Request::builder()
7062 .method("GET")
7063 .uri("/index.html")
7064 .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
7065 .body(AxumBody::empty())
7066 .unwrap();
7067 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
7068 assert_eq!(
7069 resp.status(),
7070 StatusCode::NOT_MODIFIED,
7071 "If-Modified-Since with matching timestamp should return 304, got {}",
7072 resp.status()
7073 );
7074 assert!(
7075 resp.headers().contains_key(http::header::CACHE_CONTROL),
7076 "304 (If-Modified-Since) missing Cache-Control"
7077 );
7078 assert_eq!(
7079 resp.headers().get(http::header::ETAG),
7080 Some(&etag),
7081 "304 (If-Modified-Since) must carry the ETag validator"
7082 );
7083 assert_eq!(
7084 resp.headers().get(http::header::LAST_MODIFIED),
7085 Some(&last_modified),
7086 "304 (If-Modified-Since) must echo Last-Modified"
7087 );
7088
7089 let req = Request::builder()
7095 .method("GET")
7096 .uri("/index.html")
7097 .header(
7098 http::header::IF_MODIFIED_SINCE,
7099 "Wed, 21 Oct 2000 07:28:00 GMT",
7100 )
7101 .body(AxumBody::empty())
7102 .unwrap();
7103 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
7104 assert_eq!(
7105 resp.status(),
7106 StatusCode::OK,
7107 "past If-Modified-Since should return 200 (file modified after it), got {}",
7108 resp.status()
7109 );
7110
7111 std::fs::remove_dir_all(&temp_dir).ok();
7113 }
7114
7115 #[tokio::test]
7116 async fn test_conditional_get_returns_304_static_mode() {
7117 run_conditional_get_returns_304(MountMode::Static).await;
7118 }
7119
7120 #[tokio::test]
7121 async fn test_conditional_get_returns_304_spa_mode() {
7122 run_conditional_get_returns_304(MountMode::Spa).await;
7123 }
7124
7125 #[allow(clippy::await_holding_lock)]
7126 #[tokio::test]
7127 async fn test_error_page_mapping_serves_custom_404() {
7128 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7129 ServerRegistry::reset();
7130
7131 let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
7132 let errors_dir = temp_dir.join("errors");
7133 std::fs::create_dir_all(&errors_dir).unwrap();
7134 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
7135 std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
7136
7137 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
7138 let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
7139
7140 let registry = make_test_registry();
7141 let serve_dir = ServeDir::new(&canonical_dir)
7142 .precompressed_gzip()
7143 .precompressed_br()
7144 .append_index_html_on_directories(true);
7145
7146 let mut error_pages = std::collections::HashMap::new();
7147 error_pages.insert(404, canonical_404);
7148
7149 let mount = StaticMount {
7150 mount_path: "/".to_string(),
7151 mode: MountMode::Static,
7152 dir: canonical_dir.clone(),
7153 cache_control: "public, max-age=0".to_string(),
7154 error_pages,
7155 serve_dir,
7156 };
7157 registry.register_static_mount(mount).await.unwrap();
7158
7159 let state = make_test_state(registry);
7160
7161 let req = Request::builder()
7163 .method("GET")
7164 .uri("/missing.html")
7165 .body(AxumBody::empty())
7166 .unwrap();
7167 let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
7168 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
7169 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7170 .await
7171 .unwrap();
7172 assert_eq!(&body[..], b"<h1>Custom 404</h1>");
7173
7174 let req = Request::builder()
7176 .method("GET")
7177 .uri("/index.html")
7178 .body(AxumBody::empty())
7179 .unwrap();
7180 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
7181 assert_eq!(resp.status(), StatusCode::OK);
7182 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7183 .await
7184 .unwrap();
7185 assert_eq!(&body[..], b"<h1>Home</h1>");
7186
7187 std::fs::remove_dir_all(&temp_dir).ok();
7189 }
7190
7191 #[tokio::test]
7192 async fn http_consumer_returns_body_and_code_on_stop() {
7193 use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
7194 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
7195 use tower::ServiceExt;
7196
7197 let set_body_step = CompiledStep::Process {
7199 kind_hint: camel_api::SpanKindHint::Internal,
7200 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
7201 ex.input.body = Body::Text("nope".into());
7202 Box::pin(async move { Ok(ex) })
7203 }),
7204 body_contract: None,
7205 lifecycle: None,
7206 label: None,
7207 };
7208 let set_status_step = CompiledStep::Process {
7209 kind_hint: camel_api::SpanKindHint::Internal,
7210 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
7211 ex.input.set_header(
7212 "CamelHttpResponseCode",
7213 serde_json::Value::Number(409.into()),
7214 );
7215 Box::pin(async move { Ok(ex) })
7216 }),
7217 body_contract: None,
7218 lifecycle: None,
7219 label: None,
7220 };
7221 let pipeline = compose_pipeline_with_handler(
7222 vec![set_body_step, set_status_step, CompiledStep::Stop],
7223 None,
7224 PipelineRuntimeCtx::compile_time(),
7225 );
7226
7227 let ex = Exchange::new(Message::default());
7228 let result = pipeline.oneshot(ex).await;
7229 assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
7230 let returned = result.unwrap();
7231 assert_eq!(returned.input.body.as_text(), Some("nope"));
7232 assert_eq!(
7233 returned
7234 .input
7235 .header("CamelHttpResponseCode")
7236 .and_then(|v| v.as_u64()),
7237 Some(409)
7238 );
7239 }
7240
7241 #[tokio::test]
7242 async fn http_consumer_returns_200_when_body_empty_on_stop() {
7243 use camel_api::{Exchange, Message};
7251 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
7252 use tower::ServiceExt;
7253
7254 let pipeline = compose_pipeline_with_handler(
7255 vec![CompiledStep::Stop],
7256 None,
7257 PipelineRuntimeCtx::compile_time(),
7258 );
7259 let ex = Exchange::new(Message::default());
7260 let result = pipeline.oneshot(ex).await;
7261 assert!(result.is_ok(), "Stop with empty body arrives as Ok");
7262 }
7265
7266 async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
7274 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7275 let port = listener.local_addr().unwrap().port();
7276 let registry = HttpRouteRegistry::new();
7277 tokio::spawn(run_axum_server(
7278 listener,
7279 registry.clone(),
7280 2 * 1024 * 1024,
7281 10 * 1024 * 1024,
7282 Arc::new(tokio::sync::Semaphore::new(1024)),
7283 test_rt(),
7284 "test-route".into(),
7285 ));
7286 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7288 (port, registry)
7289 }
7290
7291 fn spawn_responder(
7296 mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
7297 status: u16,
7298 body: String,
7299 ) -> tokio::task::JoinHandle<()> {
7300 tokio::spawn(async move {
7301 if let Some(envelope) = rx.recv().await {
7302 let _ = envelope.reply_tx.send(HttpReply {
7303 status,
7304 headers: vec![],
7305 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
7306 });
7307 }
7308 })
7309 }
7310
7311 #[tokio::test]
7312 async fn method_aware_dispatch_same_path_different_verbs() {
7313 let (port, registry) = spawn_test_server().await;
7314
7315 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7320 registry
7321 .register_rest_endpoint(
7322 "GET".into(),
7323 vec![PathSegment::Literal("users".into())],
7324 get_tx,
7325 )
7326 .await;
7327
7328 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7329 registry
7330 .register_rest_endpoint(
7331 "POST".into(),
7332 vec![PathSegment::Literal("users".into())],
7333 post_tx,
7334 )
7335 .await;
7336
7337 let get_handle = spawn_responder(get_rx, 200, "list".into());
7338 let post_handle = spawn_responder(post_rx, 201, "create".into());
7339
7340 let client = reqwest::Client::new();
7341
7342 let resp = client
7344 .get(format!("http://127.0.0.1:{port}/users"))
7345 .send()
7346 .await
7347 .unwrap();
7348 assert_eq!(resp.status().as_u16(), 200);
7349 let body = resp.text().await.unwrap();
7350 assert_eq!(body, "list");
7351
7352 let resp = client
7354 .post(format!("http://127.0.0.1:{port}/users"))
7355 .send()
7356 .await
7357 .unwrap();
7358 assert_eq!(resp.status().as_u16(), 201);
7359 let body = resp.text().await.unwrap();
7360 assert_eq!(body, "create");
7361
7362 let _ = tokio::join!(get_handle, post_handle);
7363 }
7364
7365 #[tokio::test]
7366 async fn method_aware_dispatch_templated_path_extracts_params() {
7367 let (port, registry) = spawn_test_server().await;
7368
7369 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7373 registry
7374 .register_rest_endpoint(
7375 "GET".into(),
7376 vec![
7377 PathSegment::Literal("users".into()),
7378 PathSegment::Param("id".into()),
7379 ],
7380 tx,
7381 )
7382 .await;
7383
7384 let handle = tokio::spawn(async move {
7387 if let Some(envelope) = rx.recv().await {
7388 let id = envelope.path_params.get("id").cloned().unwrap_or_default();
7389 let _ = envelope.reply_tx.send(HttpReply {
7390 status: 200,
7391 headers: vec![],
7392 body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
7393 });
7394 }
7395 });
7396
7397 let client = reqwest::Client::new();
7398 let resp = client
7399 .get(format!("http://127.0.0.1:{port}/users/42"))
7400 .send()
7401 .await
7402 .unwrap();
7403 assert_eq!(resp.status().as_u16(), 200);
7404 let body = resp.text().await.unwrap();
7405 assert_eq!(body, "id=42");
7406
7407 let _ = handle.await;
7408 }
7409
7410 #[tokio::test]
7411 async fn method_aware_dispatch_unmatched_method_falls_through() {
7412 let (port, _registry) = spawn_test_server().await;
7417
7418 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7420 _registry
7421 .register_rest_endpoint(
7422 "GET".into(),
7423 vec![PathSegment::Literal("users".into())],
7424 get_tx,
7425 )
7426 .await;
7427
7428 let drain = tokio::spawn(async move {
7431 let mut get_rx = get_rx;
7432 while get_rx.recv().await.is_some() {}
7433 });
7434
7435 let client = reqwest::Client::new();
7436 let resp = client
7437 .delete(format!("http://127.0.0.1:{port}/users"))
7438 .send()
7439 .await
7440 .unwrap();
7441 assert_eq!(resp.status().as_u16(), 404);
7442
7443 drop(drain);
7444 }
7445
7446 #[tokio::test]
7447 async fn regression_legacy_exact_api_route_still_works() {
7448 let (port, registry) = spawn_test_server().await;
7453
7454 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7455 registry.register_api_route("/legacy/path".into(), tx).await;
7456
7457 let handle = tokio::spawn(async move {
7458 if let Some(envelope) = rx.recv().await {
7459 let _ = envelope.reply_tx.send(HttpReply {
7460 status: 200,
7461 headers: vec![],
7462 body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
7463 });
7464 }
7465 });
7466
7467 let client = reqwest::Client::new();
7468 let resp = client
7469 .get(format!("http://127.0.0.1:{port}/legacy/path"))
7470 .send()
7471 .await
7472 .unwrap();
7473 assert_eq!(resp.status().as_u16(), 200);
7474 let body = resp.text().await.unwrap();
7475 assert_eq!(body, "legacy ok");
7476
7477 let _ = handle.await;
7478 }
7479
7480 #[allow(clippy::await_holding_lock)]
7481 #[tokio::test]
7482 async fn regression_static_mount_still_works() {
7483 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7487 ServerRegistry::reset();
7488
7489 let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
7490 std::fs::create_dir_all(&temp_dir).unwrap();
7491 std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
7492 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
7493
7494 let registry = make_test_registry();
7495 let serve_dir = ServeDir::new(&canonical_dir)
7496 .precompressed_gzip()
7497 .precompressed_br()
7498 .append_index_html_on_directories(true);
7499 let mount = StaticMount {
7500 mount_path: "/".to_string(),
7501 mode: MountMode::Static,
7502 dir: canonical_dir.clone(),
7503 cache_control: "public, max-age=3600".to_string(),
7504 error_pages: std::collections::HashMap::new(),
7505 serve_dir,
7506 };
7507 registry.register_static_mount(mount).await.unwrap();
7508
7509 let state = make_test_state(registry);
7510 let req = Request::builder()
7511 .uri("/regress.txt")
7512 .body(AxumBody::empty())
7513 .unwrap();
7514 let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
7515 assert_eq!(resp.status(), StatusCode::OK);
7516 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7517 .await
7518 .unwrap();
7519 assert_eq!(&body[..], b"static works");
7520
7521 std::fs::remove_dir_all(&temp_dir).ok();
7522 }
7523
7524 #[tokio::test]
7533 async fn deregister_one_method_keeps_sibling_verbs() {
7534 let (port, registry) = spawn_test_server().await;
7538
7539 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7540 registry
7541 .register_rest_endpoint(
7542 "GET".into(),
7543 vec![PathSegment::Literal("users".into())],
7544 get_tx,
7545 )
7546 .await;
7547
7548 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7549 registry
7550 .register_rest_endpoint(
7551 "POST".into(),
7552 vec![PathSegment::Literal("users".into())],
7553 post_tx,
7554 )
7555 .await;
7556
7557 let drain = tokio::spawn(async move {
7559 let mut get_rx = get_rx;
7560 while get_rx.recv().await.is_some() {}
7561 });
7562
7563 registry.unregister_rest_endpoint("GET", "/users").await;
7565 drop(drain);
7566
7567 let post_handle = spawn_responder(post_rx, 201, "create".into());
7568
7569 let client = reqwest::Client::new();
7570 let resp = client
7572 .post(format!("http://127.0.0.1:{port}/users"))
7573 .send()
7574 .await
7575 .unwrap();
7576 assert_eq!(resp.status().as_u16(), 201);
7577 assert_eq!(resp.text().await.unwrap(), "create");
7578
7579 let _ = post_handle.await;
7580 }
7581
7582 #[tokio::test]
7583 async fn dispatch_exact_legacy_beats_rest_template() {
7584 let (port, registry) = spawn_test_server().await;
7589
7590 let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7592 registry
7593 .register_api_route("/api/users".into(), exact_tx)
7594 .await;
7595 let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
7596
7597 let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7599 registry
7600 .register_rest_endpoint(
7601 "GET".into(),
7602 vec![
7603 PathSegment::Literal("api".into()),
7604 PathSegment::Param("resource".into()),
7605 ],
7606 tpl_tx,
7607 )
7608 .await;
7609 let _tpl_drain = tokio::spawn(async move {
7615 let mut tpl_rx = tpl_rx;
7616 if let Some(env) = tpl_rx.recv().await {
7617 let _ = env.reply_tx.send(HttpReply {
7618 status: 200,
7619 headers: vec![],
7620 body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
7621 });
7622 }
7623 });
7624
7625 let client = reqwest::Client::new();
7626 let resp = client
7627 .get(format!("http://127.0.0.1:{port}/api/users"))
7628 .send()
7629 .await
7630 .unwrap();
7631 assert_eq!(resp.status().as_u16(), 200);
7632 assert_eq!(resp.text().await.unwrap(), "exact");
7634
7635 let _ = exact_handle.await;
7636 }
7637
7638 #[tokio::test]
7639 async fn ambiguous_rest_templates_return_500_not_silent_404() {
7640 let (port, registry) = spawn_test_server().await;
7645
7646 let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7647 registry
7648 .register_rest_endpoint(
7649 "GET".into(),
7650 vec![
7651 PathSegment::Literal("users".into()),
7652 PathSegment::Param("id".into()),
7653 ],
7654 a_tx,
7655 )
7656 .await;
7657
7658 let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7659 registry
7660 .register_rest_endpoint(
7661 "GET".into(),
7662 vec![
7663 PathSegment::Literal("users".into()),
7664 PathSegment::Param("name".into()),
7665 ],
7666 b_tx,
7667 )
7668 .await;
7669
7670 let client = reqwest::Client::new();
7671 let resp = client
7672 .get(format!("http://127.0.0.1:{port}/users/42"))
7673 .send()
7674 .await
7675 .unwrap();
7676 assert_eq!(resp.status().as_u16(), 500);
7678 }
7679
7680 #[test]
7681 fn from_uri_round_trips_templated_path_with_http_method() {
7682 use crate::UriConfig;
7688 let cfg =
7689 HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
7690 assert_eq!(cfg.host, "0.0.0.0");
7691 assert_eq!(cfg.port, 8080);
7692 assert_eq!(cfg.path, "/users/{id}");
7693 assert_eq!(cfg.method.as_deref(), Some("GET"));
7694
7695 let cfg_lc =
7697 HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
7698 assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
7699 assert_eq!(cfg_lc.path, "/orders");
7700 }
7701
7702 #[test]
7707 fn type_conversion_failed_maps_to_400() {
7708 let reply = pipeline_error_to_reply(
7709 CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
7710 "/api/users",
7711 );
7712 assert_eq!(reply.status, 400);
7713 let ct = reply
7715 .headers
7716 .iter()
7717 .find(|(k, _)| k == "Content-Type")
7718 .map(|(_, v)| v.as_str());
7719 assert_eq!(ct, Some("application/json"));
7720 let body = match &reply.body {
7722 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
7723 _ => panic!("expected bytes body"),
7724 };
7725 assert!(body.contains("\"error\""));
7726 assert!(body.contains("bad_request"));
7727 assert!(body.contains("invalid JSON at line 1"));
7728 }
7729
7730 #[test]
7731 fn other_error_still_maps_to_500() {
7732 let reply =
7733 pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
7734 assert_eq!(reply.status, 500);
7735 }
7736
7737 #[test]
7738 fn unauthenticated_maps_to_401() {
7739 let reply = pipeline_error_to_reply(
7740 CamelError::Unauthenticated("no token".to_string()),
7741 "/api/users",
7742 );
7743 assert_eq!(reply.status, 401);
7744 }
7745
7746 #[test]
7747 fn unauthorized_maps_to_403() {
7748 let reply = pipeline_error_to_reply(
7749 CamelError::Unauthorized("forbidden".to_string()),
7750 "/api/users",
7751 );
7752 assert_eq!(reply.status, 403);
7753 }
7754
7755 #[test]
7756 fn validation_error_maps_to_400() {
7757 let reply = pipeline_error_to_reply(
7758 CamelError::ValidationError("body does not match schema".to_string()),
7759 "/api/users",
7760 );
7761 assert_eq!(reply.status, 400);
7762 let ct = reply
7763 .headers
7764 .iter()
7765 .find(|(k, _)| k == "Content-Type")
7766 .map(|(_, v)| v.as_str());
7767 assert_eq!(ct, Some("application/json"));
7768 let body = match &reply.body {
7769 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
7770 _ => panic!("expected bytes body"),
7771 };
7772 assert!(body.contains("\"error\""));
7773 assert!(body.contains("validation_error"));
7774 assert!(body.contains("body does not match schema"));
7775 }
7776
7777 #[test]
7778 fn https_consumer_without_tls_cert_errors() {
7779 let endpoint = HttpEndpoint {
7780 uri: "https://0.0.0.0:8443/api".to_string(),
7781 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
7782 server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
7783 client: reqwest::Client::new(),
7784 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
7785 PINNED_CLIENT_TTL,
7786 PINNED_CLIENT_MAX_ENTRIES,
7787 )),
7788 http_config: HttpConfig::default(),
7789 };
7790 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7791 let result = endpoint.create_consumer(rt);
7792 assert!(result.is_err(), "expected error for https without tls cert");
7793 if let Err(e) = result {
7794 let msg = e.to_string();
7795 assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
7796 }
7797 }
7798
7799 #[test]
7800 fn http_consumer_with_tls_config_errors() {
7801 let endpoint = HttpEndpoint {
7802 uri: "http://0.0.0.0:8080/api".to_string(),
7803 config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
7804 server_config: HttpServerConfig::from_uri(
7805 "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
7806 )
7807 .unwrap(),
7808 client: reqwest::Client::new(),
7809 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
7810 PINNED_CLIENT_TTL,
7811 PINNED_CLIENT_MAX_ENTRIES,
7812 )),
7813 http_config: HttpConfig::default(),
7814 };
7815 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7816 let result = endpoint.create_consumer(rt);
7817 assert!(result.is_err(), "expected error for http with tls config");
7818 if let Err(e) = result {
7819 let msg = e.to_string();
7820 assert!(msg.contains("https"), "error must mention https: {msg}");
7821 }
7822 }
7823
7824 #[test]
7825 fn https_consumer_with_partial_tls_cert_only_errors() {
7826 let server_config =
7829 HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
7830 assert!(
7831 server_config.tls_config.is_none(),
7832 "partial tlsCert must not create ServerTlsConfig"
7833 );
7834 let endpoint = HttpEndpoint {
7835 uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
7836 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
7837 .unwrap(),
7838 server_config,
7839 client: reqwest::Client::new(),
7840 pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
7841 PINNED_CLIENT_TTL,
7842 PINNED_CLIENT_MAX_ENTRIES,
7843 )),
7844 http_config: HttpConfig::default(),
7845 };
7846 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7847 let result = endpoint.create_consumer(rt);
7848 assert!(
7849 result.is_err(),
7850 "must error: https:// requires both tlsCert and tlsKey"
7851 );
7852 }
7853
7854 #[test]
7855 fn load_tls_config_parses_valid_pem() {
7856 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7858 use camel_component_api::test_support::tls;
7859 let (_, cert_pem, key_pem) = tls::gen_server_cert();
7860 let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
7861 let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
7862
7863 let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
7864 assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
7865 }
7866
7867 #[tokio::test(flavor = "multi_thread")]
7868 #[allow(clippy::await_holding_lock)]
7869 async fn consumer_tls_handshake_roundtrip() {
7870 use camel_component_api::test_support::tls;
7871 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7872
7873 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7875
7876 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7878
7879 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
7881 let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
7882 let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
7883 let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
7884
7885 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7887 let port = probe.local_addr().unwrap().port();
7888 drop(probe);
7889
7890 ServerRegistry::reset();
7891
7892 let component = HttpComponent::new();
7894 let endpoint_ctx = NoOpComponentContext;
7895 let uri = format!(
7896 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
7897 cert_path.to_string_lossy(),
7898 key_path.to_string_lossy(),
7899 );
7900 let endpoint = component
7901 .create_endpoint(&uri, &endpoint_ctx)
7902 .expect("create TLS endpoint");
7903 let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
7904
7905 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7907 let token = tokio_util::sync::CancellationToken::new();
7908 let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
7909 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7910
7911 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
7913
7914 let ca_bytes = std::fs::read(&ca_path).unwrap();
7916 let client = reqwest::Client::builder()
7917 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
7918 .build()
7919 .unwrap();
7920
7921 let send_fut = client
7922 .post(format!("https://localhost:{port}/test"))
7923 .body("ping")
7924 .send();
7925
7926 let (http_result, _) = tokio::join!(send_fut, async {
7928 if let Some(mut envelope) = rx.recv().await {
7929 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
7930 if let Some(reply_tx) = envelope.reply_tx {
7931 let _ = reply_tx.send(Ok(envelope.exchange));
7932 }
7933 }
7934 });
7935
7936 let resp = http_result.expect("TLS handshake + request must succeed");
7937
7938 assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
7939 let body = resp.text().await.unwrap();
7940 assert_eq!(body, "pong");
7941
7942 token.cancel();
7943 }
7944
7945 #[tokio::test(flavor = "multi_thread")]
7946 #[allow(clippy::await_holding_lock)]
7947 async fn consumer_tls_rejects_client_without_ca() {
7948 use camel_component_api::test_support::tls;
7949 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7950
7951 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7952
7953 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7955
7956 let (_, cert_pem, key_pem) = tls::gen_server_cert();
7957 let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
7958 let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
7959
7960 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7961 let port = probe.local_addr().unwrap().port();
7962 drop(probe);
7963
7964 ServerRegistry::reset();
7965
7966 let component = HttpComponent::new();
7968 let endpoint_ctx = NoOpComponentContext;
7969 let uri = format!(
7970 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
7971 cert_path.to_string_lossy(),
7972 key_path.to_string_lossy(),
7973 );
7974 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
7975 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7976 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7977 let token = tokio_util::sync::CancellationToken::new();
7978 let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
7979 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7980
7981 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
7982
7983 let client = reqwest::Client::builder().build().unwrap();
7985
7986 let result = client
7987 .get(format!("https://localhost:{port}/test"))
7988 .send()
7989 .await;
7990
7991 assert!(
7992 result.is_err(),
7993 "must reject without CA — proves real verification"
7994 );
7995
7996 token.cancel();
7997 }
7998
7999 #[test]
8000 fn server_config_partial_tls_cert_without_key() {
8001 let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
8003 assert!(cfg.tls_config.is_none());
8005 }
8006
8007 #[test]
8008 fn endpoint_uri_options_count_parity() {
8009 assert_eq!(
8011 HttpEndpointConfig::uri_options().len(),
8012 20,
8013 "HttpEndpointUriConfig #[uri_param] count drifted from parser"
8014 );
8015 }
8016
8017 fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
8018 pairs
8019 .iter()
8020 .map(|(k, v)| {
8021 (
8022 (*k).to_string(),
8023 serde_json::Value::String((*v).to_string()),
8024 )
8025 })
8026 .collect()
8027 }
8028
8029 #[test]
8030 fn response_emits_cache_control_via_pragma_warning() {
8031 let headers = make_headers(&[
8032 ("Cache-Control", "public, max-age=3600"),
8033 ("Via", "1.1 myproxy"),
8034 ("Pragma", "no-cache"),
8035 ("Warning", "199 misc"),
8036 ]);
8037 let selected = select_response_headers(&headers, None, None);
8038 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
8039 for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
8040 assert!(
8041 names.contains(&expected),
8042 "{expected} should pass through to the response"
8043 );
8044 }
8045 }
8046
8047 #[test]
8048 fn response_excludes_request_only_and_server_owned() {
8049 let headers = make_headers(&[
8050 ("User-Agent", "x"),
8051 ("Accept", "*/*"),
8052 ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
8053 ]);
8054 let selected = select_response_headers(&headers, None, None);
8055 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
8056 for excluded in ["User-Agent", "Accept", "Date"] {
8057 assert!(
8058 !names.contains(&excluded),
8059 "{excluded} should NOT appear in the response"
8060 );
8061 }
8062 }
8063
8064 #[test]
8065 fn response_re_derives_content_type() {
8066 let headers = make_headers(&[("Content-Type", "text/plain")]);
8067 let selected = select_response_headers(&headers, Some("application/json".into()), None);
8068 let ct_entries: Vec<&str> = selected
8069 .iter()
8070 .filter(|(k, _)| k == "Content-Type")
8071 .map(|(_, v)| v.as_str())
8072 .collect();
8073 assert_eq!(
8074 ct_entries,
8075 ["application/json"],
8076 "exactly one Content-Type entry, re-derived from user_content_type"
8077 );
8078 }
8079
8080 #[test]
8081 fn response_excludes_camel_headers() {
8082 let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
8083 let selected = select_response_headers(&headers, None, None);
8084 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
8085 assert!(
8086 !names.contains(&"CamelHttpPath"),
8087 "Camel-namespace headers must be excluded"
8088 );
8089 assert!(
8090 names.contains(&"Cache-Control"),
8091 "Cache-Control must pass through"
8092 );
8093 }
8094
8095 async fn start_host_capturing_destination() -> (
8107 String,
8108 Arc<std::sync::Mutex<Option<(String, String)>>>,
8109 tokio::task::JoinHandle<()>,
8110 ) {
8111 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8112 let port = listener.local_addr().unwrap().port();
8113 let url = format!("http://127.0.0.1:{port}");
8114 let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
8115 Arc::new(std::sync::Mutex::new(None));
8116 let captured_clone = Arc::clone(&captured);
8117 let handle = tokio::spawn(async move {
8118 use tokio::io::{AsyncReadExt, AsyncWriteExt};
8119 if let Ok((mut stream, _)) = listener.accept().await {
8120 let mut buf = vec![0u8; 16384];
8121 let n = stream.read(&mut buf).await.unwrap_or(0);
8122 let request = String::from_utf8_lossy(&buf[..n]).to_string();
8123 if request.contains("\r\n\r\n") {
8124 let request_line = request.lines().next().unwrap_or("").to_string();
8125 let host_value = request
8126 .lines()
8127 .find(|l| l.to_lowercase().starts_with("host:"))
8128 .and_then(|l| l.split_once(':'))
8129 .map(|(_, v)| v.trim().to_string())
8130 .unwrap_or_default();
8131 *captured_clone.lock().unwrap() = Some((host_value, request_line));
8132 }
8133 let body = r#"{"echo":"ok"}"#;
8134 let resp = format!(
8135 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
8136 body.len(),
8137 body
8138 );
8139 let _ = stream.write_all(resp.as_bytes()).await;
8140 }
8141 });
8142 (url, captured, handle)
8143 }
8144
8145 #[tokio::test]
8150 async fn bridge_proxy_outbound_host_matches_destination() {
8151 use tower::ServiceExt;
8152
8153 let (url, captured, _handle) = start_host_capturing_destination().await;
8154 let expected_host = url.strip_prefix("http://").unwrap();
8157
8158 let ctx = test_producer_ctx();
8159 let component = HttpComponent::new();
8160 let endpoint_ctx = NoOpComponentContext;
8161 let endpoint = component
8162 .create_endpoint(
8163 &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
8164 &endpoint_ctx,
8165 )
8166 .unwrap();
8167 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
8168
8169 let mut exchange = Exchange::new(Message::default());
8172 exchange.input.set_header("Host", "localhost");
8173 exchange.input.set_header("CamelHttpPath", "/foo");
8174
8175 let result = producer.oneshot(exchange).await;
8176 assert!(result.is_ok(), "producer call failed: {:?}", result);
8177
8178 tokio::time::sleep(Duration::from_millis(100)).await;
8179 let (host_value, request_line) = captured
8180 .lock()
8181 .unwrap()
8182 .take()
8183 .expect("destination capture mutex empty — producer did not reach the destination");
8184
8185 assert_ne!(
8186 host_value, "localhost",
8187 "bridge producer must not forward the exchange Host: localhost"
8188 );
8189 assert_eq!(
8190 host_value, expected_host,
8191 "Host must be derived from the destination authority (no scheme)"
8192 );
8193 assert!(
8194 !request_line.contains("/foo"),
8195 "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
8196 );
8197 }
8198
8199 #[tokio::test]
8204 async fn bridge_proxy_route_set_response_header_survives() {
8205 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
8206
8207 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8208 let port = listener.local_addr().unwrap().port();
8209 drop(listener);
8210
8211 let component = HttpComponent::new();
8212 let endpoint_ctx = NoOpComponentContext;
8213 let endpoint = component
8214 .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
8215 .unwrap();
8216 let mut consumer = endpoint.create_consumer(rt()).unwrap();
8217
8218 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
8219 let token = tokio_util::sync::CancellationToken::new();
8220 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
8221
8222 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
8223 tokio::time::sleep(Duration::from_millis(50)).await;
8224
8225 let client = reqwest::Client::new();
8226 let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
8227
8228 let (http_result, _) = tokio::join!(send_fut, async {
8232 if let Some(mut envelope) = rx.recv().await {
8233 envelope
8234 .exchange
8235 .input
8236 .set_header("Cache-Control", "public, max-age=3600");
8237 if let Some(reply_tx) = envelope.reply_tx {
8238 let _ = reply_tx.send(Ok(envelope.exchange));
8239 }
8240 }
8241 });
8242
8243 let resp = http_result.unwrap();
8244 assert_eq!(resp.status().as_u16(), 200);
8245
8246 let cache_control = resp.headers().get("cache-control");
8247 assert!(
8248 cache_control.is_some(),
8249 "Cache-Control header must survive to the wire response"
8250 );
8251 assert_eq!(
8252 cache_control.unwrap().to_str().unwrap(),
8253 "public, max-age=3600"
8254 );
8255
8256 token.cancel();
8257 }
8258
8259 use camel_api::security_policy::CredentialSource;
8278 use camel_auth::credential_source::extract_token_from_exchange;
8279 use camel_auth::native_auth::NativeCredentialStore;
8280 use camel_auth::{StaticTokenAuthenticator, TokenAuthenticator};
8281
8282 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 {
8290 let mut msg = Message::default();
8291 msg.set_header(
8292 "CamelHttpMethod",
8293 serde_json::Value::String(envelope.method.clone()),
8294 );
8295 msg.set_header(
8296 "CamelHttpPath",
8297 serde_json::Value::String(envelope.path.clone()),
8298 );
8299 msg.set_header(
8300 "CamelHttpQuery",
8301 serde_json::Value::String(envelope.query.clone()),
8302 );
8303 for (k, v) in &envelope.headers {
8304 if let Ok(val_str) = v.to_str() {
8305 msg.set_header(
8306 title_case_header(k.as_str()),
8307 serde_json::Value::String(val_str.to_string()),
8308 );
8309 }
8310 }
8311 Exchange::new(msg)
8312 }
8313
8314 async fn spawn_failing_auth_route(
8321 registry: &HttpRouteRegistry,
8322 path: &str,
8323 sources: Vec<CredentialSource>,
8324 ) {
8325 let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
8326 NativeCredentialStore::try_new(vec![]).unwrap(),
8327 ));
8328 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
8329 registry.register_api_route(path.to_string(), tx).await;
8330 let path_owned = path.to_string();
8331 tokio::spawn(async move {
8332 while let Some(envelope) = rx.recv().await {
8333 let exchange = envelope_to_exchange(&envelope);
8334 let reply_tx = envelope.reply_tx;
8335 let result: Result<(), CamelError> = async {
8336 let token = extract_token_from_exchange(&exchange, &sources)
8337 .map(|extracted| extracted.token)
8338 .ok_or_else(|| {
8339 CamelError::Unauthenticated("no credential in any source".into())
8340 })?;
8341 authenticator.authenticate_bearer(&token).await?;
8342 Ok(())
8343 }
8344 .await;
8345 let reply = match result {
8346 Ok(()) => HttpReply {
8347 status: 200,
8348 headers: vec![],
8349 body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
8350 },
8351 Err(e) => pipeline_error_to_reply(e, &path_owned),
8352 };
8353 let _ = reply_tx.send(reply);
8354 }
8355 });
8356 }
8357
8358 fn captured_logs_contain(needle: &str) -> bool {
8362 let buf = tracing_test::internal::global_buf().lock().unwrap();
8363 String::from_utf8_lossy(&buf).contains(needle)
8364 }
8365
8366 #[tracing_test::traced_test]
8367 #[tokio::test]
8368 async fn error_context_redacts_query_sentinel() {
8369 let (port, registry) = spawn_test_server().await;
8370 spawn_failing_auth_route(
8371 ®istry,
8372 "/secure-query",
8373 vec![CredentialSource::QueryParam {
8374 param: "token".to_string(),
8375 }],
8376 )
8377 .await;
8378
8379 let client = reqwest::Client::new();
8380 let resp = client
8381 .get(format!(
8383 "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
8384 ))
8385 .send()
8386 .await
8387 .unwrap();
8388
8389 assert_eq!(resp.status().as_u16(), 401);
8390 let body = resp.text().await.unwrap();
8391 assert_eq!(body, "Unauthorized");
8392 assert!(
8393 !body.contains(SENTINEL_QRY_42),
8394 "reply body must not contain the query credential"
8395 );
8396 assert!(
8397 !captured_logs_contain(SENTINEL_QRY_42),
8398 "no tracing record during request handling may render the query credential"
8399 );
8400 assert!(
8404 captured_logs_contain("Authentication failed"),
8405 "positive control: the failed-auth warn! must be captured by the test subscriber"
8406 );
8407 }
8408
8409 #[tracing_test::traced_test]
8410 #[tokio::test]
8411 async fn error_context_redacts_cookie_sentinel() {
8412 let (port, registry) = spawn_test_server().await;
8413 spawn_failing_auth_route(
8414 ®istry,
8415 "/secure-cookie",
8416 vec![CredentialSource::Cookie {
8417 name: "session".to_string(),
8418 }],
8419 )
8420 .await;
8421
8422 let client = reqwest::Client::new();
8423 let resp = client
8424 .get(format!("http://127.0.0.1:{port}/secure-cookie"))
8425 .header("Cookie", format!("session={SENTINEL_CKY_7}"))
8426 .send()
8427 .await
8428 .unwrap();
8429
8430 assert_eq!(resp.status().as_u16(), 401);
8431 let body = resp.text().await.unwrap();
8432 assert_eq!(body, "Unauthorized");
8433 assert!(
8434 !body.contains(SENTINEL_CKY_7),
8435 "reply body must not contain the cookie credential"
8436 );
8437 assert!(
8438 !captured_logs_contain(SENTINEL_CKY_7),
8439 "no tracing record during request handling may render the cookie credential"
8440 );
8441 }
8442
8443 #[tracing_test::traced_test]
8444 #[tokio::test]
8445 async fn error_reply_no_credential_value() {
8446 let (port, registry) = spawn_test_server().await;
8447 spawn_failing_auth_route(
8448 ®istry,
8449 "/secure-bad",
8450 vec![CredentialSource::Cookie {
8451 name: "session".to_string(),
8452 }],
8453 )
8454 .await;
8455
8456 let client = reqwest::Client::new();
8457 let resp = client
8458 .get(format!("http://127.0.0.1:{port}/secure-bad"))
8459 .header("Cookie", format!("session={SENTINEL_BAD_1}"))
8460 .send()
8461 .await
8462 .unwrap();
8463
8464 assert_eq!(resp.status().as_u16(), 401);
8465 let body = resp.text().await.unwrap();
8466 assert_eq!(body, "Unauthorized");
8467 assert!(
8468 !body.contains(SENTINEL_BAD_1),
8469 "reply body must not contain the credential value"
8470 );
8471 assert!(
8472 !captured_logs_contain(SENTINEL_BAD_1),
8473 "error logs must not render the credential value"
8474 );
8475 }
8476
8477 async fn spawn_multi_accept_200() -> (String, tokio::task::JoinHandle<()>) {
8491 use tokio::io::AsyncWriteExt;
8492
8493 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
8494 .await
8495 .expect("bind ephemeral 127.0.0.1 listener");
8496 let port = listener.local_addr().expect("local addr").port();
8497 let base_url = format!("http://localhost:{port}");
8498 let handle = tokio::spawn(async move {
8499 while let Ok((mut conn, _)) = listener.accept().await {
8500 let _ = conn
8501 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
8502 .await;
8503 let _ = conn.shutdown().await;
8504 }
8505 });
8506 (base_url, handle)
8507 }
8508
8509 fn responder_port(base_url: &str) -> u16 {
8513 url::Url::parse(base_url)
8514 .expect("responder base URL parses")
8515 .port()
8516 .expect("responder base URL carries an explicit port")
8517 }
8518
8519 fn endpoint_with_shared_cache(
8524 base_url: &str,
8525 pinned_cache: &Arc<PinnedClientCache>,
8526 ) -> HttpEndpoint {
8527 let uri = format!("{base_url}?allowInternal=true");
8528 HttpEndpoint {
8529 uri: uri.clone(),
8530 config: HttpEndpointConfig::from_uri(&uri).expect("producer endpoint config parses"),
8531 server_config: HttpServerConfig::from_uri(&uri).expect("server config parses"),
8532 client: reqwest::Client::new(),
8533 pinned_cache: Arc::clone(pinned_cache),
8534 http_config: HttpConfig::default(),
8535 }
8536 }
8537
8538 #[tokio::test]
8539 async fn producers_share_endpoint_cache() {
8540 use tower::ServiceExt;
8541
8542 let (base_url, _handle) = spawn_multi_accept_200().await;
8543 let pinned_cache = Arc::new(PinnedClientCache::new(
8544 PINNED_CLIENT_TTL,
8545 PINNED_CLIENT_MAX_ENTRIES,
8546 ));
8547
8548 let ctx = test_producer_ctx();
8549 let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
8550 let producer_a = endpoint.create_producer(rt(), &ctx);
8551 let producer_b = endpoint.create_producer(rt(), &ctx);
8552
8553 for producer in [producer_a, producer_b] {
8556 let producer = producer.expect("create producer");
8557 let exchange = Exchange::new(Message::default());
8558 let reply = producer.oneshot(exchange).await;
8559 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
8560 }
8561
8562 assert_eq!(
8563 pinned_cache.build_count(),
8564 1,
8565 "both producers must hit the same shared cache entry; a second \
8566 build means sharing is broken"
8567 );
8568 }
8569
8570 #[tokio::test]
8571 async fn producer_repeated_hostname_requests_build_one_client() {
8572 use tower::ServiceExt;
8573
8574 let (base_url, _handle) = spawn_multi_accept_200().await;
8575 let pinned_cache = Arc::new(PinnedClientCache::new(
8576 PINNED_CLIENT_TTL,
8577 PINNED_CLIENT_MAX_ENTRIES,
8578 ));
8579 let ctx = test_producer_ctx();
8580 let endpoint = endpoint_with_shared_cache(&format!("{base_url}/"), &pinned_cache);
8581 let producer = endpoint
8582 .create_producer(rt(), &ctx)
8583 .expect("create producer");
8584
8585 for i in 0..2 {
8588 let exchange = Exchange::new(Message::default());
8589 let reply = producer.clone().oneshot(exchange).await;
8590 assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
8591 }
8592
8593 assert_eq!(
8594 pinned_cache.build_count(),
8595 1,
8596 "repeated hostname requests must reuse the one pinned client; \
8597 0 builds means the producer bypassed the cache, more than 1 \
8598 means the entry was dropped"
8599 );
8600 }
8601
8602 #[tokio::test]
8603 async fn ip_literal_request_never_enters_cache() {
8604 use tower::ServiceExt;
8605
8606 let (base_url, _handle) = spawn_multi_accept_200().await;
8607 let pinned_cache = Arc::new(PinnedClientCache::new(
8608 PINNED_CLIENT_TTL,
8609 PINNED_CLIENT_MAX_ENTRIES,
8610 ));
8611
8612 let ctx = test_producer_ctx();
8613 let destination = format!("http://127.0.0.1:{}/ping", responder_port(&base_url));
8614 let endpoint = endpoint_with_shared_cache(&destination, &pinned_cache);
8615 let producer = endpoint
8616 .create_producer(rt(), &ctx)
8617 .expect("create producer");
8618
8619 let exchange = Exchange::new(Message::default());
8620 let reply = producer.oneshot(exchange).await;
8621 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
8622
8623 assert_eq!(
8624 pinned_cache.build_count(),
8625 0,
8626 "an IP-literal URL must use the shared unpinned client and \
8627 never enter the pinned cache"
8628 );
8629 }
8630
8631 #[tokio::test]
8632 async fn test_component_endpoints_share_pinned_cache() {
8633 use tower::ServiceExt;
8634
8635 let component = HttpComponent::new();
8636 let (base_url, _handle) = spawn_multi_accept_200().await;
8637 let baseline = component.pinned_cache.build_count();
8638
8639 let ctx = test_producer_ctx();
8640 let endpoint_ctx = NoOpComponentContext;
8641 for uri in [
8642 format!("{base_url}/a?allowInternal=true&k=a"),
8643 format!("{base_url}/b?allowInternal=true&k=b"),
8644 ] {
8645 let endpoint = component
8646 .create_endpoint(&uri, &endpoint_ctx)
8647 .expect("create endpoint");
8648 let producer = endpoint
8649 .create_producer(rt(), &ctx)
8650 .expect("create producer");
8651 let exchange = Exchange::new(Message::default());
8652 let reply = producer.oneshot(exchange).await;
8653 assert!(reply.is_ok(), "producer call failed: {:?}", reply);
8654 }
8655
8656 assert_eq!(
8657 component.pinned_cache.build_count() - baseline,
8658 1,
8659 "endpoints created by one component must share its pinned cache; \
8660 0 builds means the endpoints bypassed it, more than 1 means \
8661 per-endpoint caches came back"
8662 );
8663 }
8664
8665 #[tokio::test]
8666 async fn test_dynamic_resolution_sequence_hits_shared_cache() {
8667 use tower::ServiceExt;
8668
8669 let component = HttpComponent::new();
8670 let (base_url, _handle) = spawn_multi_accept_200().await;
8671 let baseline = component.pinned_cache.build_count();
8672
8673 let ctx = test_producer_ctx();
8674 let endpoint_ctx = NoOpComponentContext;
8675 for i in 0..3 {
8676 let endpoint = component
8677 .create_endpoint(
8678 &format!("{base_url}/r{i}?allowInternal=true&k={i}"),
8679 &endpoint_ctx,
8680 )
8681 .expect("create endpoint");
8682 let producer = endpoint
8683 .create_producer(rt(), &ctx)
8684 .expect("create producer");
8685 let exchange = Exchange::new(Message::default());
8686 let reply = producer.oneshot(exchange).await;
8687 assert!(reply.is_ok(), "request {i} failed: {:?}", reply);
8688 }
8689
8690 assert_eq!(
8691 component.pinned_cache.build_count() - baseline,
8692 1,
8693 "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
8694 must reuse the component's one pinned cache entry; 0 builds \
8695 means the endpoints bypassed it, more than 1 means \
8696 per-endpoint caches came back"
8697 );
8698 }
8699
8700 #[test]
8701 fn test_https_component_owns_distinct_cache() {
8702 let http = HttpComponent::new();
8703 let https = HttpsComponent::new();
8704
8705 assert!(
8706 !Arc::ptr_eq(&http.pinned_cache, &https.pinned_cache),
8707 "http and https components must each own their own pinned cache"
8708 );
8709
8710 let endpoint_ctx = NoOpComponentContext;
8711 let _ = http
8712 .create_endpoint("http://localhost:1/?allowInternal=true", &endpoint_ctx)
8713 .expect("http endpoint");
8714 let _ = https
8715 .create_endpoint("https://localhost:1/?allowInternal=true", &endpoint_ctx)
8716 .expect("https endpoint");
8717
8718 assert_eq!(
8719 http.pinned_cache.build_count(),
8720 0,
8721 "endpoint creation must not build a pinned client"
8722 );
8723 assert_eq!(
8724 https.pinned_cache.build_count(),
8725 0,
8726 "endpoint creation must not build a pinned client"
8727 );
8728 }
8729
8730 #[test]
8731 fn test_component_constructor_builds_one_unpinned_client() {
8732 let baseline = build_client_call_count();
8733
8734 let _http = HttpComponent::new();
8735 assert_eq!(
8736 build_client_call_count() - baseline,
8737 1,
8738 "HttpComponent::new() must build exactly one shared unpinned client"
8739 );
8740
8741 let _https = HttpsComponent::new();
8742 assert_eq!(
8743 build_client_call_count() - baseline,
8744 2,
8745 "HttpsComponent::new() must build exactly one more shared unpinned client"
8746 );
8747 }
8748
8749 #[test]
8750 fn test_component_endpoints_share_unpinned_client() {
8751 let component = HttpComponent::new();
8752 let baseline = build_client_call_count();
8753
8754 let endpoint_ctx = NoOpComponentContext;
8755 for uri in [
8756 "http://localhost:1/a?allowInternal=true",
8757 "http://localhost:1/b?allowInternal=true",
8758 ] {
8759 let _endpoint = component
8760 .create_endpoint(uri, &endpoint_ctx)
8761 .expect("create endpoint");
8762 }
8763
8764 assert_eq!(
8765 build_client_call_count() - baseline,
8766 0,
8767 "create_endpoint must clone the component's shared unpinned client, \
8768 never build a fresh one"
8769 );
8770 }
8771
8772 #[test]
8773 fn test_dynamic_resolution_adds_no_unpinned_client_builds() {
8774 let component = HttpComponent::new();
8775 let baseline = build_client_call_count();
8776
8777 let ctx = test_producer_ctx();
8778 let endpoint_ctx = NoOpComponentContext;
8779 for i in 0..3 {
8780 let endpoint = component
8781 .create_endpoint(
8782 &format!("http://localhost:1/r{i}?allowInternal=true&k={i}"),
8783 &endpoint_ctx,
8784 )
8785 .expect("create endpoint");
8786 let _producer = endpoint
8787 .create_producer(rt(), &ctx)
8788 .expect("create producer");
8789 }
8790
8791 assert_eq!(
8792 build_client_call_count() - baseline,
8793 0,
8794 "a dynamic-resolution sequence (fresh endpoint+producer per URI) \
8795 must reuse the component's shared unpinned client and build \
8796 no additional clients"
8797 );
8798 }
8799}