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