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
1352fn envelope_channel_capacity(max_inflight_requests: usize) -> usize {
1370 max_inflight_requests.max(1)
1371}
1372
1373pub struct HttpConsumer {
1374 config: HttpServerConfig,
1375 runtime: Arc<dyn RuntimeObservability>,
1377}
1378
1379impl HttpConsumer {
1380 pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
1381 Self { config, runtime }
1382 }
1383}
1384
1385#[async_trait::async_trait]
1386impl Consumer for HttpConsumer {
1387 async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
1388 use camel_component_api::{Body, Exchange, Message};
1389
1390 let registry = ServerRegistry::global()
1391 .get_or_spawn(
1392 &self.config.host,
1393 self.config.port,
1394 self.config.max_request_body,
1395 self.config.max_response_body,
1396 self.config.max_inflight_requests,
1397 self.runtime.clone(),
1398 ctx.route_id().to_string(),
1399 self.config.tls_config.clone(),
1400 )
1401 .await?;
1402
1403 let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(
1407 envelope_channel_capacity(self.config.max_inflight_requests),
1408 );
1409 if let Some(method) = self.config.method.clone() {
1416 let segments = rest_match::parse_path_template(&self.config.path);
1417 registry
1418 .register_rest_endpoint(method, segments, env_tx)
1419 .await;
1420 } else {
1421 registry
1422 .register_api_route(self.config.path.clone(), env_tx)
1423 .await;
1424 }
1425
1426 ctx.mark_ready();
1435
1436 let path = self.config.path.clone();
1437 let registry_for_cleanup = registry.clone();
1438 let cancel_token = ctx.cancel_token();
1439 loop {
1440 tokio::select! {
1441 _ = ctx.cancelled() => {
1442 break;
1443 }
1444 envelope = env_rx.recv() => {
1445 let Some(envelope) = envelope else { break; };
1446
1447 let mut msg = Message::default();
1449
1450 msg.set_header("CamelHttpMethod",
1452 serde_json::Value::String(envelope.method.clone()));
1453 msg.set_header("CamelHttpPath",
1454 serde_json::Value::String(envelope.path.clone()));
1455 msg.set_header("CamelHttpQuery",
1456 serde_json::Value::String(envelope.query.clone()));
1457
1458 for (param_name, param_value) in &envelope.path_params {
1465 msg.set_header(
1466 format!("CamelHttpPath_{param_name}"),
1467 serde_json::Value::String(param_value.clone()),
1468 );
1469 }
1470
1471 for (k, v) in &envelope.headers {
1473 if let Ok(val_str) = v.to_str() {
1474 msg.set_header(
1475 title_case_header(k.as_str()),
1476 serde_json::Value::String(val_str.to_string()),
1477 );
1478 }
1479 }
1480
1481 msg.body = Body::Stream(envelope.body);
1484
1485 #[allow(unused_mut)]
1486 let mut exchange = Exchange::new(msg);
1487
1488 #[cfg(feature = "otel")]
1490 {
1491 let headers: HashMap<String, String> = envelope
1492 .headers
1493 .iter()
1494 .filter_map(|(k, v)| {
1495 Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1496 })
1497 .collect();
1498 camel_otel::extract_into_exchange(&mut exchange, &headers);
1499 }
1500
1501 let reply_tx = envelope.reply_tx;
1502 let sender = ctx.sender().clone();
1503 let path_clone = path.clone();
1504 let cancel = cancel_token.clone();
1505
1506 tokio::spawn(async move {
1526 if cancel.is_cancelled() {
1534 let _ = reply_tx.send(HttpReply {
1535 status: 503,
1536 headers: vec![],
1537 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
1538 });
1539 return;
1540 }
1541
1542 let (tx, rx) = tokio::sync::oneshot::channel();
1544 let envelope = camel_component_api::consumer::ExchangeEnvelope {
1545 exchange,
1546 reply_tx: Some(tx),
1547 };
1548
1549 let result = match sender.send(envelope).await {
1550 Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
1551 Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
1552 }
1553 .and_then(|r| r);
1554
1555 let reply = match result {
1556 Ok(out) => {
1557 let status = out
1558 .input
1559 .header("CamelHttpResponseCode")
1560 .and_then(|v| {
1561 let raw = v.as_u64()
1562 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
1563 let code = raw as u16;
1564 (100..1000).contains(&code).then_some(code)
1565 })
1566 .unwrap_or(200);
1567
1568 let user_content_type = out
1569 .input
1570 .header("Content-Type")
1571 .and_then(|v| v.as_str().map(|s| s.to_string()));
1572
1573 let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
1574 Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
1575 Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
1576 Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
1577 Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
1578 v.to_string().into_bytes(),
1579 )), Some("application/json".to_string())),
1580 Body::Stream(s) => {
1581 let ct = s.metadata.content_type.clone();
1582 match s.stream.lock().await.take() {
1583 Some(stream) => (
1584 HttpReplyBody::Stream(stream),
1585 ct,
1586 ),
1587 None => {
1588 tracing::error!(
1590 "Body::Stream already consumed before HTTP reply — returning 500"
1591 );
1592 let error_reply = HttpReply {
1593 status: 500,
1594 headers: vec![],
1595 body: HttpReplyBody::Bytes(bytes::Bytes::new()),
1596 };
1597 if reply_tx.send(error_reply).is_err() {
1598 debug!("reply_tx dropped before error reply could be sent");
1599 }
1600 return;
1601 }
1602 }
1603 }
1604 _ => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
1606 };
1607
1608 let resp_headers = select_response_headers(
1609 &out.input.headers,
1610 user_content_type,
1611 inferred_content_type,
1612 );
1613
1614 HttpReply {
1615 status,
1616 headers: resp_headers,
1617 body: reply_body,
1618 }
1619 }
1620 Err(e) => {
1621 pipeline_error_to_reply(e, &path_clone)
1622 }
1623 };
1624
1625 let _ = reply_tx.send(reply);
1627 });
1628 }
1629 }
1630 }
1631
1632 if let Some(method) = &self.config.method {
1637 registry_for_cleanup
1638 .unregister_rest_endpoint(method, &path)
1639 .await;
1640 } else {
1641 registry_for_cleanup.unregister_api_route(&path).await;
1642 }
1643
1644 ServerRegistry::global()
1648 .unregister(&self.config.host, self.config.port)
1649 .await;
1650
1651 Ok(())
1652 }
1653
1654 async fn stop(&mut self) -> Result<(), CamelError> {
1655 Ok(())
1656 }
1657
1658 fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
1659 camel_component_api::ConcurrencyModel::Concurrent { max: None }
1660 }
1661
1662 fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
1669 camel_component_api::ConsumerStartupMode::Explicit
1670 }
1671}
1672
1673pub struct HttpComponent {
1678 config: HttpConfig,
1679}
1680
1681pub(crate) fn build_client(
1682 config: &HttpConfig,
1683 resolve_override: Option<(&str, &[std::net::SocketAddr])>,
1684) -> reqwest::Client {
1685 let mut builder = reqwest::Client::builder()
1686 .no_proxy() .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
1688 .pool_max_idle_per_host(config.pool_max_idle_per_host)
1689 .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
1690
1691 builder = builder.redirect(reqwest::redirect::Policy::none());
1695
1696 if let Some((host, addrs)) = resolve_override {
1697 builder = builder.resolve_to_addrs(host, addrs);
1698 }
1699
1700 if let Some(tls) = &config.tls
1701 && tls.enabled
1702 {
1703 if tls.insecure || !tls.verify_peer {
1704 tracing::warn!("HTTP TLS verification disabled — connections are vulnerable to MitM");
1706 builder = builder.danger_accept_invalid_certs(true);
1707 }
1708
1709 if let Some(ca_path) = &tls.ca_cert_path
1710 && let Ok(ca_bytes) = std::fs::read(ca_path)
1711 {
1712 let cert = reqwest::Certificate::from_pem(&ca_bytes)
1713 .or_else(|_| reqwest::Certificate::from_der(&ca_bytes));
1714 if let Ok(ca_cert) = cert {
1715 builder = builder.add_root_certificate(ca_cert);
1716 }
1717 }
1718
1719 if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path)
1720 && let (Ok(cert_bytes), Ok(key_bytes)) =
1721 (std::fs::read(cert_path), std::fs::read(key_path))
1722 {
1723 let mut identity_pem = cert_bytes;
1724 identity_pem.extend_from_slice(&key_bytes);
1725 if let Ok(identity) = reqwest::Identity::from_pem(&identity_pem) {
1726 builder = builder.identity(identity);
1727 }
1728 }
1729 }
1730
1731 builder
1732 .build()
1733 .expect("reqwest::Client::build() with valid config should not fail") }
1735
1736impl HttpComponent {
1737 pub fn new() -> Self {
1738 let config = HttpConfig::default();
1739 Self { config }
1740 }
1741
1742 pub fn with_config(config: HttpConfig) -> Self {
1743 Self { config }
1744 }
1745
1746 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
1747 match config {
1748 Some(cfg) => Self::with_config(cfg),
1749 None => Self::new(),
1750 }
1751 }
1752}
1753
1754impl Default for HttpComponent {
1755 fn default() -> Self {
1756 Self::new()
1757 }
1758}
1759
1760impl Component for HttpComponent {
1761 fn scheme(&self) -> &str {
1762 "http"
1763 }
1764
1765 fn metadata(&self) -> ComponentMetadata {
1766 HttpEndpointConfig::metadata()
1767 }
1768
1769 fn create_endpoint(
1770 &self,
1771 uri: &str,
1772 ctx: &dyn camel_component_api::ComponentContext,
1773 ) -> Result<Box<dyn Endpoint>, CamelError> {
1774 self.config.validate()?;
1775 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
1776 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
1777 let client = build_client(&self.config, None);
1778 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
1779 server_config.host.clone(),
1780 server_config.port,
1781 )));
1782 Ok(Box::new(HttpEndpoint {
1783 uri: uri.to_string(),
1784 config,
1785 server_config,
1786 client,
1787 http_config: self.config.clone(),
1788 }))
1789 }
1790}
1791
1792pub struct HttpsComponent {
1793 config: HttpConfig,
1794}
1795
1796impl HttpsComponent {
1797 pub fn new() -> Self {
1798 let config = HttpConfig::default();
1799 Self { config }
1800 }
1801
1802 pub fn with_config(config: HttpConfig) -> Self {
1803 Self { config }
1804 }
1805
1806 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
1807 match config {
1808 Some(cfg) => Self::with_config(cfg),
1809 None => Self::new(),
1810 }
1811 }
1812}
1813
1814impl Default for HttpsComponent {
1815 fn default() -> Self {
1816 Self::new()
1817 }
1818}
1819
1820impl Component for HttpsComponent {
1821 fn scheme(&self) -> &str {
1822 "https"
1823 }
1824
1825 fn metadata(&self) -> ComponentMetadata {
1826 let mut meta = HttpEndpointConfig::metadata();
1829 meta.scheme = "https".to_string();
1830 meta.description = "HTTPS client and server component (TLS over HTTP)".to_string();
1831 meta
1832 }
1833
1834 fn create_endpoint(
1835 &self,
1836 uri: &str,
1837 ctx: &dyn camel_component_api::ComponentContext,
1838 ) -> Result<Box<dyn Endpoint>, CamelError> {
1839 self.config.validate()?;
1840 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
1841 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
1842 let client = build_client(&self.config, None);
1843 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
1844 server_config.host.clone(),
1845 server_config.port,
1846 )));
1847 Ok(Box::new(HttpEndpoint {
1848 uri: uri.to_string(),
1849 config,
1850 server_config,
1851 client,
1852 http_config: self.config.clone(),
1853 }))
1854 }
1855}
1856
1857struct HttpEndpoint {
1862 uri: String,
1863 config: HttpEndpointConfig,
1864 server_config: HttpServerConfig,
1865 client: reqwest::Client,
1866 http_config: HttpConfig,
1867}
1868
1869impl Endpoint for HttpEndpoint {
1870 fn uri(&self) -> &str {
1871 &self.uri
1872 }
1873
1874 fn create_consumer(
1875 &self,
1876 rt: Arc<dyn camel_component_api::RuntimeObservability>,
1877 ) -> Result<Box<dyn Consumer>, CamelError> {
1878 let scheme_is_https = self.server_config.scheme == "https";
1881 let has_tls = self.server_config.tls_config.is_some();
1882
1883 if scheme_is_https && !has_tls {
1884 return Err(CamelError::EndpointCreationFailed(
1885 "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
1886 ));
1887 }
1888 if !scheme_is_https && has_tls {
1889 return Err(CamelError::EndpointCreationFailed(
1890 "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
1891 ));
1892 }
1893 Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
1894 }
1895
1896 fn create_producer(
1897 &self,
1898 _rt: Arc<dyn camel_component_api::RuntimeObservability>,
1899 _ctx: &ProducerContext,
1900 ) -> Result<BoxProcessor, CamelError> {
1901 let producer = HttpProducer {
1902 config: Arc::new(self.config.clone()),
1903 client: self.client.clone(),
1904 http_config: Arc::new(self.http_config.clone()),
1905 };
1906 if let Some(ref provider) = self.config.token_provider {
1907 let layer = BearerTokenLayer::new(Arc::clone(provider));
1908 Ok(BoxProcessor::new(layer.layer(producer)))
1909 } else {
1910 Ok(BoxProcessor::new(producer))
1911 }
1912 }
1913}
1914
1915#[derive(Clone)]
1920struct HttpProducer {
1921 config: Arc<HttpEndpointConfig>,
1922 client: reqwest::Client,
1923 http_config: Arc<HttpConfig>,
1924}
1925
1926impl HttpProducer {
1927 fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
1928 if let Some(ref method) = config.http_method {
1929 return method.to_uppercase();
1930 }
1931 if let Some(method) = exchange
1932 .input
1933 .header("CamelHttpMethod")
1934 .and_then(|v| v.as_str())
1935 {
1936 return method.to_uppercase();
1937 }
1938 if !exchange.input.body.is_empty() {
1939 return "POST".to_string();
1940 }
1941 "GET".to_string()
1942 }
1943
1944 fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
1945 if config.bridge_endpoint {
1951 let url = config.base_url.clone();
1952 if config.query_params.is_empty() {
1953 return url;
1954 }
1955 let mut parsed = url::Url::parse(&url).expect("base URL must be valid"); for (k, v) in &config.query_params {
1957 parsed.query_pairs_mut().append_pair(k, v);
1958 }
1959 return parsed.to_string();
1960 }
1961
1962 if let Some(uri) = exchange
1963 .input
1964 .header("CamelHttpUri")
1965 .and_then(|v| v.as_str())
1966 {
1967 let mut url = uri.to_string();
1968 if let Some(path) = exchange
1969 .input
1970 .header("CamelHttpPath")
1971 .and_then(|v| v.as_str())
1972 {
1973 if !url.ends_with('/') && !path.starts_with('/') {
1974 url.push('/');
1975 }
1976 url.push_str(path);
1977 }
1978 if let Some(query) = exchange
1979 .input
1980 .header("CamelHttpQuery")
1981 .and_then(|v| v.as_str())
1982 {
1983 url.push('?');
1984 url.push_str(query);
1985 }
1986 return url;
1987 }
1988
1989 let mut url = config.base_url.clone();
1990
1991 if let Some(path) = exchange
1992 .input
1993 .header("CamelHttpPath")
1994 .and_then(|v| v.as_str())
1995 {
1996 if !url.ends_with('/') && !path.starts_with('/') {
1997 url.push('/');
1998 }
1999 url.push_str(path);
2000 }
2001
2002 if let Some(query) = exchange
2003 .input
2004 .header("CamelHttpQuery")
2005 .and_then(|v| v.as_str())
2006 {
2007 url.push('?');
2008 url.push_str(query);
2009 } else if !config.query_params.is_empty() {
2010 let mut parsed = url::Url::parse(&url).expect("base URL must be valid"); for (k, v) in &config.query_params {
2012 parsed.query_pairs_mut().append_pair(k, v);
2013 }
2014 url = parsed.to_string();
2015 }
2016
2017 url
2018 }
2019
2020 fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
2021 status >= range.0 && status <= range.1
2022 }
2023
2024 fn is_entity_enclosing(method: &str) -> bool {
2029 matches!(method, "POST" | "PUT" | "PATCH")
2030 }
2031}
2032
2033impl Service<Exchange> for HttpProducer {
2034 type Response = Exchange;
2035 type Error = CamelError;
2036 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
2037
2038 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
2039 Poll::Ready(Ok(()))
2040 }
2041
2042 fn call(&mut self, mut exchange: Exchange) -> Self::Future {
2043 let config = self.config.clone();
2044 let client = self.client.clone();
2045 let http_config = self.http_config.clone();
2046
2047 Box::pin(async move {
2048 let method_str = HttpProducer::resolve_method(&exchange, &config);
2049 let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
2054 let url = HttpProducer::resolve_url(&exchange, &config);
2055
2056 ssrf::validate_url_for_ssrf(&url, &config)?;
2058
2059 let resolved = ssrf::resolve_initial_url_for_ssrf(&url, config.allow_internal).await?;
2064 let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
2065 build_client(&http_config, Some((host.as_str(), addrs)))
2066 } else {
2067 client
2068 };
2069
2070 debug!(
2071 correlation_id = %exchange.correlation_id(),
2072 method = %method_str,
2073 url = %url,
2074 "HTTP request"
2075 );
2076
2077 let method = method_str.parse::<reqwest::Method>().map_err(|e| {
2078 CamelError::ProcessorError(format!("Invalid HTTP method '{}': {}", method_str, e))
2079 })?;
2080
2081 let mut collected_headers: Vec<(
2083 reqwest::header::HeaderName,
2084 reqwest::header::HeaderValue,
2085 )> = Vec::new();
2086
2087 if let Some(user_agent) = &config.user_agent
2088 && !config.bridge_endpoint
2089 && let Ok(val) = reqwest::header::HeaderValue::from_str(user_agent)
2090 {
2091 collected_headers.push((reqwest::header::USER_AGENT, val));
2092 }
2093
2094 #[cfg(feature = "otel")]
2096 let should_inject_otel = !config.bridge_endpoint;
2097 #[cfg(feature = "otel")]
2098 if should_inject_otel {
2099 let mut otel_headers = HashMap::new();
2100 camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
2101 for (k, v) in otel_headers {
2102 if let (Ok(name), Ok(val)) = (
2103 reqwest::header::HeaderName::from_bytes(k.as_bytes()),
2104 reqwest::header::HeaderValue::from_str(&v),
2105 ) {
2106 collected_headers.push((name, val));
2107 }
2108 }
2109 }
2110
2111 let conn_tokens = header_policy::connection_tokens(
2112 exchange
2113 .input
2114 .headers
2115 .iter()
2116 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
2117 .filter_map(|(_, v)| v.as_str()),
2118 );
2119
2120 for (key, value) in &exchange.input.headers {
2121 if !key.starts_with("Camel")
2122 && !config
2123 .skip_request_headers
2124 .iter()
2125 .any(|h| h.eq_ignore_ascii_case(key))
2126 && !header_policy::excluded_outbound(key, &conn_tokens)
2127 && let Some(val_str) = value.as_str()
2128 && let (Ok(name), Ok(val)) = (
2129 reqwest::header::HeaderName::from_bytes(key.as_bytes()),
2130 reqwest::header::HeaderValue::from_str(val_str),
2131 )
2132 {
2133 collected_headers.push((name, val));
2134 }
2135 }
2136
2137 if !config.bridge_endpoint {
2139 match &config.auth {
2140 HttpAuth::None => {}
2141 HttpAuth::Basic { username, password } => {
2142 use base64::Engine;
2143 let credentials = format!("{username}:{password}");
2145 let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
2146 if let Ok(val) =
2147 reqwest::header::HeaderValue::from_str(&format!("Basic {encoded}"))
2148 {
2149 collected_headers.push((reqwest::header::AUTHORIZATION, val));
2150 }
2151 }
2152 HttpAuth::Bearer { token } => {
2153 let bearer = format!("Bearer {token}");
2155 if let Ok(val) = reqwest::header::HeaderValue::from_str(&bearer) {
2156 collected_headers.push((reqwest::header::AUTHORIZATION, val));
2157 }
2158 }
2159 }
2160
2161 if config.connection_close
2162 && let Ok(val) = reqwest::header::HeaderValue::from_str("close")
2163 {
2164 collected_headers.push((reqwest::header::CONNECTION, val));
2165 }
2166 }
2167
2168 let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
2170 let materialized_body: Option<Vec<u8>> = if is_stream_body {
2171 if suppress_body {
2172 std::mem::take(&mut exchange.input.body);
2180 tracing::warn!(
2182 correlation_id = %exchange.correlation_id(),
2183 method = %method_str,
2184 "dropping request body for non-entity-enclosing HTTP method"
2185 );
2186 }
2187 None } else {
2189 let body = std::mem::take(&mut exchange.input.body);
2190 let bytes = body.into_bytes(config.max_body_size).await?;
2191 if bytes.is_empty() {
2192 None
2194 } else if suppress_body {
2195 tracing::warn!(
2197 correlation_id = %exchange.correlation_id(),
2198 method = %method_str,
2199 "dropping request body for non-entity-enclosing HTTP method"
2200 );
2201 None
2202 } else {
2203 Some(bytes.to_vec())
2204 }
2205 };
2206
2207 let response = if config.follow_redirects && !is_stream_body {
2208 ssrf::send_with_ssrf_safe_redirects(
2210 &client,
2211 &http_config,
2212 &config,
2213 method,
2214 &url,
2215 collected_headers,
2216 materialized_body,
2217 config.max_redirects,
2218 config.response_timeout,
2219 )
2220 .await?
2221 } else {
2222 let mut request = client.request(method, &url);
2224
2225 if let Some(timeout) = config.response_timeout {
2226 request = request.timeout(timeout);
2227 }
2228
2229 for (name, value) in &collected_headers {
2230 request = request.header(name, value);
2231 }
2232
2233 if is_stream_body {
2234 if let Body::Stream(ref s) = exchange.input.body {
2235 let mut stream_lock = s.stream.lock().await;
2236 if let Some(stream) = stream_lock.take() {
2237 request = request.body(reqwest::Body::wrap_stream(stream));
2238 } else {
2239 return Err(CamelError::AlreadyConsumed);
2240 }
2241 }
2242 } else if let Some(ref body_bytes) = materialized_body {
2243 request = request.body(body_bytes.clone());
2244 }
2245
2246 request
2247 .send()
2248 .await
2249 .map_err(|e| CamelError::ProcessorError(format!("HTTP request failed: {e}")))?
2250 };
2251
2252 let status_code = response.status().as_u16();
2253 let status_text = response
2254 .status()
2255 .canonical_reason()
2256 .unwrap_or("Unknown")
2257 .to_string();
2258
2259 for (key, value) in response.headers() {
2260 if config
2261 .skip_response_headers
2262 .iter()
2263 .any(|h| h.eq_ignore_ascii_case(key.as_str()))
2264 {
2265 continue;
2266 }
2267 if let Ok(val_str) = value.to_str() {
2268 exchange.input.set_header(
2269 title_case_header(key.as_str()),
2270 serde_json::Value::String(val_str.to_string()),
2271 );
2272 }
2273 }
2274
2275 exchange.input.set_header(
2276 "CamelHttpResponseCode",
2277 serde_json::Value::Number(status_code.into()),
2278 );
2279 exchange.input.set_header(
2280 "CamelHttpResponseText",
2281 serde_json::Value::String(status_text.clone()),
2282 );
2283
2284 let read_timeout = Duration::from_millis(config.read_timeout_ms);
2286 let response_body = tokio::time::timeout(read_timeout, async {
2287 if let Some(content_len) = response.content_length()
2289 && content_len > config.max_response_bytes as u64
2290 {
2291 return Err(CamelError::ProcessorError(format!(
2292 "Response body too large: {} bytes exceeds limit of {} bytes",
2293 content_len, config.max_response_bytes
2294 )));
2295 }
2296 use futures::TryStreamExt;
2298 let mut stream = response.bytes_stream();
2299 let mut total: usize = 0;
2300 let mut collected = Vec::new();
2301 while let Some(chunk) = stream.try_next().await.map_err(|e| {
2302 CamelError::ProcessorError(format!("Failed to read response body: {e}"))
2303 })? {
2304 total += chunk.len();
2305 if total > config.max_response_bytes {
2306 return Err(CamelError::ProcessorError(format!(
2307 "Response body too large: {} bytes exceeds limit of {} bytes",
2308 total, config.max_response_bytes
2309 )));
2310 }
2311 collected.push(chunk);
2312 }
2313 let mut result = bytes::BytesMut::with_capacity(total);
2314 for chunk in collected {
2315 result.extend_from_slice(&chunk);
2316 }
2317 Ok::<bytes::Bytes, CamelError>(result.freeze())
2318 })
2319 .await
2320 .map_err(|_| {
2321 CamelError::ProcessorError(format!(
2322 "Read timeout after {}ms",
2323 config.read_timeout_ms
2324 ))
2325 })??;
2326
2327 if config.throw_exception_on_failure
2328 && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
2329 {
2330 return Err(CamelError::HttpOperationFailed {
2331 method: method_str,
2332 url,
2333 status_code,
2334 status_text,
2335 response_body: Some(String::from_utf8_lossy(&response_body).to_string()),
2336 });
2337 }
2338
2339 if !response_body.is_empty() {
2340 exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
2341 }
2342
2343 debug!(
2344 correlation_id = %exchange.correlation_id(),
2345 status = status_code,
2346 url = %url,
2347 "HTTP response"
2348 );
2349 Ok(exchange)
2350 })
2351 }
2352}
2353
2354#[cfg(test)]
2364pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
2365
2366fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
2374 match e {
2375 CamelError::Unauthenticated(msg) => {
2376 tracing::warn!(error = %msg, path = %path, "Authentication failed");
2377 HttpReply {
2378 status: 401,
2379 headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
2380 body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
2381 }
2382 }
2383 CamelError::Unauthorized(msg) => {
2384 tracing::warn!(error = %msg, path = %path, "Authorization failed");
2385 HttpReply {
2386 status: 403,
2387 headers: vec![],
2388 body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
2389 }
2390 }
2391 CamelError::TypeConversionFailed(msg) => {
2392 tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
2393 let body = serde_json::to_string(&serde_json::json!({
2394 "error": "bad_request",
2395 "message": msg,
2396 }))
2397 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
2399 status: 400,
2400 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
2401 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
2402 }
2403 }
2404 CamelError::ValidationError(msg) => {
2405 tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
2406 let body = serde_json::to_string(&serde_json::json!({
2407 "error": "validation_error",
2408 "message": msg,
2409 }))
2410 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
2412 status: 400,
2413 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
2414 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
2415 }
2416 }
2417 CamelError::ConsumerStopping => {
2418 tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
2419 HttpReply {
2420 status: 503,
2421 headers: vec![],
2422 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
2423 }
2424 }
2425 e => {
2426 tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
2428 HttpReply {
2429 status: 500,
2430 headers: vec![],
2431 body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
2432 }
2433 }
2434 }
2435}
2436
2437fn select_response_headers(
2447 headers: &HashMap<String, serde_json::Value>,
2448 user_content_type: Option<String>,
2449 inferred_content_type: Option<String>,
2450) -> Vec<(String, String)> {
2451 let conn_tokens = header_policy::connection_tokens(
2452 headers
2453 .iter()
2454 .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
2455 .filter_map(|(_, v)| v.as_str()),
2456 );
2457 let mut selected: Vec<(String, String)> = headers
2458 .iter()
2459 .filter(|(k, _)| !k.starts_with("Camel"))
2460 .filter(|(k, _)| !header_policy::excluded_response(k, &conn_tokens))
2461 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
2462 .collect();
2463 if let Some(ct) = user_content_type.or(inferred_content_type) {
2464 selected.push(("Content-Type".to_string(), ct));
2465 }
2466 selected
2467}
2468
2469#[cfg(test)]
2470mod tests {
2471 use camel_component_api::test_support::{NoopRuntimeObservability, PanicRuntimeObservability};
2472 fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2473 std::sync::Arc::new(PanicRuntimeObservability)
2474 }
2475 fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2476 std::sync::Arc::new(PanicRuntimeObservability)
2477 }
2478 fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2479 std::sync::Arc::new(NoopRuntimeObservability)
2480 }
2481
2482 use super::*;
2483 use crate::rest_match::PathSegment;
2484 use camel_component_api::{Message, NoOpComponentContext};
2485 use std::sync::Arc;
2486 use std::time::Duration;
2487
2488 fn test_producer_ctx() -> ProducerContext {
2489 ProducerContext::new()
2490 }
2491
2492 #[test]
2493 fn test_http_config_defaults() {
2494 let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
2495 assert_eq!(config.base_url, "http://localhost:8080/api");
2496 assert!(config.http_method.is_none());
2497 assert!(config.throw_exception_on_failure);
2498 assert_eq!(config.ok_status_code_range, (200, 299));
2499 assert!(config.response_timeout.is_none());
2500 assert!(matches!(config.auth, HttpAuth::None));
2501 assert!(!config.bridge_endpoint);
2502 assert!(!config.connection_close);
2503 }
2504
2505 #[test]
2506 fn test_http_config_scheme() {
2507 assert_eq!(HttpEndpointConfig::scheme(), "http");
2509 }
2510
2511 #[test]
2512 fn test_http_config_from_components() {
2513 let components = camel_component_api::UriComponents {
2515 scheme: "https".to_string(),
2516 path: "//api.example.com/v1".to_string(),
2517 params: std::collections::HashMap::from([(
2518 "httpMethod".to_string(),
2519 "POST".to_string(),
2520 )]),
2521 };
2522 let config = HttpEndpointConfig::from_components(components).unwrap();
2523 assert_eq!(config.base_url, "https://api.example.com/v1");
2524 assert_eq!(config.http_method, Some("POST".to_string()));
2525 }
2526
2527 #[test]
2528 fn test_http_config_with_options() {
2529 let config = HttpEndpointConfig::from_uri(
2530 "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
2531 ).unwrap();
2532 assert_eq!(config.base_url, "https://api.example.com/v1");
2533 assert_eq!(config.http_method, Some("PUT".to_string()));
2534 assert!(!config.throw_exception_on_failure);
2535 assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
2536 }
2537
2538 #[test]
2539 fn test_http_endpoint_config_auth_and_headers_options() {
2540 let config = HttpEndpointConfig::from_uri(
2541 "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
2542 )
2543 .unwrap();
2544
2545 assert!(matches!(
2546 config.auth,
2547 HttpAuth::Basic { username, password } if username == "u" && password == "p"
2548 ));
2549 assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
2550 assert!(config.bridge_endpoint);
2551 assert!(config.connection_close);
2552 assert_eq!(
2553 config.skip_request_headers,
2554 vec!["authorization".to_string(), "x-secret".to_string()]
2555 );
2556 assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
2557 }
2558
2559 #[test]
2560 fn test_http_endpoint_config_bearer_auth() {
2561 let config = HttpEndpointConfig::from_uri(
2562 "http://localhost/api?authMethod=Bearer&authBearerToken=t",
2563 )
2564 .unwrap();
2565 assert!(matches!(
2566 config.auth,
2567 HttpAuth::Bearer { token } if token == "t"
2568 ));
2569 }
2570
2571 #[test]
2572 fn rejects_cookie_handling_inmemory() {
2573 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
2574 match result {
2575 Err(CamelError::InvalidUri(msg)) => {
2576 assert!(
2577 msg.contains("cookieHandling is not supported"),
2578 "expected rejection message, got: {msg}"
2579 );
2580 }
2581 other => panic!("expected InvalidUri error, got: {other:?}"),
2582 }
2583 }
2584
2585 #[test]
2586 fn rejects_cookie_handling_disabled() {
2587 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
2588 match result {
2589 Err(CamelError::InvalidUri(msg)) => {
2590 assert!(
2591 msg.contains("cookieHandling is not supported"),
2592 "expected rejection message, got: {msg}"
2593 );
2594 }
2595 other => panic!("expected InvalidUri error, got: {other:?}"),
2596 }
2597 }
2598
2599 #[test]
2600 fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
2601 let config = HttpConfig::default()
2602 .with_response_timeout_ms(999)
2603 .with_allow_internal(true)
2604 .with_blocked_hosts(vec!["evil.com".to_string()])
2605 .with_max_body_size(12345);
2606 let endpoint =
2607 HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
2608 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
2609 assert!(endpoint.allow_internal);
2610 assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
2611 assert_eq!(endpoint.max_body_size, 12345);
2612 }
2613
2614 #[test]
2615 fn test_from_uri_with_defaults_uri_overrides_config() {
2616 let config = HttpConfig::default()
2617 .with_response_timeout_ms(999)
2618 .with_allow_internal(true)
2619 .with_blocked_hosts(vec!["evil.com".to_string()])
2620 .with_max_body_size(12345);
2621 let endpoint = HttpEndpointConfig::from_uri_with_defaults(
2622 "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
2623 &config,
2624 )
2625 .unwrap();
2626 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
2627 assert!(!endpoint.allow_internal);
2628 assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
2629 assert_eq!(endpoint.max_body_size, 99);
2630 }
2631
2632 #[test]
2633 fn test_http_config_ok_status_range() {
2634 let config =
2635 HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
2636 assert_eq!(config.ok_status_code_range, (200, 204));
2637 }
2638
2639 #[test]
2640 fn test_http_config_wrong_scheme() {
2641 let result = HttpEndpointConfig::from_uri("file:/tmp");
2642 assert!(result.is_err());
2643 }
2644
2645 #[test]
2646 fn test_http_component_scheme() {
2647 let component = HttpComponent::new();
2648 assert_eq!(component.scheme(), "http");
2649 }
2650
2651 #[test]
2652 fn test_https_component_scheme() {
2653 let component = HttpsComponent::new();
2654 assert_eq!(component.scheme(), "https");
2655 }
2656
2657 #[test]
2658 fn test_http_endpoint_creates_consumer() {
2659 let component = HttpComponent::new();
2660 let ctx = NoOpComponentContext;
2661 let endpoint = component
2662 .create_endpoint("http://0.0.0.0:19100/test", &ctx)
2663 .unwrap();
2664 assert!(endpoint.create_consumer(rt()).is_ok());
2665 }
2666
2667 #[test]
2668 fn test_https_endpoint_creates_consumer_errors_without_tls() {
2669 let component = HttpsComponent::new();
2670 let ctx = NoOpComponentContext;
2671 let endpoint = component
2672 .create_endpoint("https://0.0.0.0:8443/test", &ctx)
2673 .unwrap();
2674 assert!(endpoint.create_consumer(rt()).is_err());
2676 }
2677
2678 #[test]
2679 fn test_http_endpoint_creates_producer() {
2680 let ctx = test_producer_ctx();
2681 let component = HttpComponent::new();
2682 let endpoint_ctx = NoOpComponentContext;
2683 let endpoint = component
2684 .create_endpoint("http://localhost/api", &endpoint_ctx)
2685 .unwrap();
2686 assert!(endpoint.create_producer(rt(), &ctx).is_ok());
2687 }
2688
2689 #[tokio::test]
2694 async fn test_producer_with_token_provider() {
2695 use camel_auth::oauth2::TokenProvider;
2696 use tower::ServiceExt;
2697
2698 let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
2699 Arc::new(std::sync::Mutex::new(None));
2700 let captured_clone = Arc::clone(&captured_auth);
2701
2702 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2703 let port = listener.local_addr().unwrap().port();
2704
2705 let _handle = tokio::spawn(async move {
2706 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2707 if let Ok((mut stream, _)) = listener.accept().await {
2708 let mut buf = vec![0u8; 8192];
2709 let n = stream.read(&mut buf).await.unwrap_or(0);
2710 let request = String::from_utf8_lossy(&buf[..n]).to_string();
2711 let auth = request
2712 .lines()
2713 .find(|l| l.to_lowercase().starts_with("authorization:"))
2714 .map(|l| {
2715 l.split(':')
2716 .nth(1)
2717 .map(|s| s.trim().to_string())
2718 .unwrap_or_default()
2719 });
2720 *captured_clone.lock().unwrap() = auth;
2721 let body = r#"{"echo":"ok"}"#;
2722 let resp = format!(
2723 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
2724 body.len(),
2725 body
2726 );
2727 let _ = stream.write_all(resp.as_bytes()).await;
2728 }
2729 });
2730
2731 #[derive(Debug)]
2732 struct StaticProvider;
2733 #[async_trait::async_trait]
2734 impl TokenProvider for StaticProvider {
2735 async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
2736 Ok("injected-token".into())
2737 }
2738 }
2739
2740 let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
2741 let ctx = test_producer_ctx();
2742 let component = HttpComponent::new();
2743 let endpoint_ctx = NoOpComponentContext;
2744 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
2745 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2746
2747 let exchange = Exchange::new(Message::new("hello"));
2748
2749 let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
2750 let mut layered = layer.layer(producer);
2751 let result = layered.ready().await.unwrap().call(exchange).await;
2752 assert!(result.is_ok(), "producer call failed: {:?}", result);
2753
2754 tokio::time::sleep(Duration::from_millis(100)).await;
2755 let auth = captured_auth.lock().unwrap().take();
2756 assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
2757 }
2758
2759 async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
2760 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2761 let addr = listener.local_addr().unwrap();
2762 let url = format!("http://127.0.0.1:{}", addr.port());
2763
2764 let handle = tokio::spawn(async move {
2765 loop {
2766 if let Ok((mut stream, _)) = listener.accept().await {
2767 tokio::spawn(async move {
2768 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2769 let mut buf = vec![0u8; 4096];
2770 let n = stream.read(&mut buf).await.unwrap_or(0);
2771 let request = String::from_utf8_lossy(&buf[..n]).to_string();
2772
2773 let method = request.split_whitespace().next().unwrap_or("GET");
2774
2775 let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
2776 let response = format!(
2777 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
2778 body.len(),
2779 body
2780 );
2781 let _ = stream.write_all(response.as_bytes()).await;
2782 });
2783 }
2784 }
2785 });
2786
2787 (url, handle)
2788 }
2789
2790 async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
2791 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2792 let addr = listener.local_addr().unwrap();
2793 let url = format!("http://127.0.0.1:{}", addr.port());
2794
2795 let handle = tokio::spawn(async move {
2796 loop {
2797 if let Ok((mut stream, _)) = listener.accept().await {
2798 let status = status;
2799 tokio::spawn(async move {
2800 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2801 let mut buf = vec![0u8; 4096];
2802 let _ = stream.read(&mut buf).await;
2803
2804 let status_text = match status {
2805 404 => "Not Found",
2806 500 => "Internal Server Error",
2807 _ => "Error",
2808 };
2809 let body = "error body";
2810 let response = format!(
2811 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
2812 status,
2813 status_text,
2814 body.len(),
2815 body
2816 );
2817 let _ = stream.write_all(response.as_bytes()).await;
2818 });
2819 }
2820 }
2821 });
2822
2823 (url, handle)
2824 }
2825
2826 async fn start_request_capturing_server() -> (
2827 String,
2828 Arc<std::sync::Mutex<Option<String>>>,
2829 tokio::task::JoinHandle<()>,
2830 ) {
2831 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2832 let port = listener.local_addr().unwrap().port();
2833 let url = format!("http://127.0.0.1:{port}");
2834 let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
2835 let captured_clone = Arc::clone(&captured);
2836 let handle = tokio::spawn(async move {
2837 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2838 if let Ok((mut stream, _)) = listener.accept().await {
2839 let mut buf = vec![0u8; 16384];
2840 let n = stream.read(&mut buf).await.unwrap_or(0);
2841 let request = String::from_utf8_lossy(&buf[..n]).to_string();
2842 if request.contains("\r\n\r\n") {
2843 *captured_clone.lock().unwrap() = Some(request);
2844 }
2845 let body = r#"{"echo":"ok"}"#;
2846 let resp = format!(
2847 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
2848 body.len(),
2849 body
2850 );
2851 let _ = stream.write_all(resp.as_bytes()).await;
2852 }
2853 });
2854 (url, captured, handle)
2855 }
2856
2857 #[tokio::test]
2858 async fn test_http_producer_get_request() {
2859 use tower::ServiceExt;
2860
2861 let (url, _handle) = start_test_server().await;
2862 let ctx = test_producer_ctx();
2863
2864 let component = HttpComponent::new();
2865 let endpoint_ctx = NoOpComponentContext;
2866 let endpoint = component
2867 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
2868 .unwrap();
2869 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2870
2871 let exchange = Exchange::new(Message::default());
2872 let result = producer.oneshot(exchange).await.unwrap();
2873
2874 let status = result
2875 .input
2876 .header("CamelHttpResponseCode")
2877 .and_then(|v| v.as_u64())
2878 .unwrap();
2879 assert_eq!(status, 200);
2880
2881 assert!(!result.input.body.is_empty());
2882 }
2883
2884 #[tokio::test]
2885 async fn producer_excludes_host_and_framing() {
2886 use tower::ServiceExt;
2887
2888 let (url, captured, _handle) = start_request_capturing_server().await;
2889 let ctx = test_producer_ctx();
2890 let component = HttpComponent::new();
2891 let endpoint_ctx = NoOpComponentContext;
2892 let endpoint = component
2893 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
2894 .unwrap();
2895 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2896
2897 let mut exchange = Exchange::new(Message::default());
2898 exchange.input.set_header("Host", "localhost");
2899 exchange.input.set_header("Content-Length", "42");
2900 exchange.input.set_header("Connection", "keep-alive");
2901 exchange.input.set_header("Upgrade", "h2c");
2902
2903 let result = producer.oneshot(exchange).await;
2904 assert!(result.is_ok(), "producer call failed: {:?}", result);
2905
2906 tokio::time::sleep(Duration::from_millis(100)).await;
2907 let request = captured
2908 .lock()
2909 .unwrap()
2910 .take()
2911 .expect("no outbound request captured");
2912 let lower = request.to_ascii_lowercase();
2913 assert!(
2914 !lower.contains("\r\nhost: localhost"),
2915 "forwarded Host: localhost must be stripped\n{request}"
2916 );
2917 assert!(
2918 !lower.contains("content-length: 42"),
2919 "exchange Content-Length must not be copied\n{request}"
2920 );
2921 assert!(
2922 !lower.lines().any(|l| l.starts_with("connection:")),
2923 "Connection header must not be forwarded\n{request}"
2924 );
2925 assert!(
2926 !lower.lines().any(|l| l.starts_with("upgrade:")),
2927 "Upgrade header must not be forwarded\n{request}"
2928 );
2929 let host_header = lower
2930 .lines()
2931 .find(|l| l.starts_with("host:"))
2932 .map(|l| l.split_once(':').map(|(_, v)| v).unwrap_or("").trim())
2933 .expect("outbound Host header must be set by reqwest");
2934 assert!(
2935 host_header.starts_with("127.0.0.1:"),
2936 "outbound Host '{host_header}' must match the capture-server address"
2937 );
2938 }
2939
2940 #[tokio::test]
2941 async fn producer_forwards_request_only_headers() {
2942 use tower::ServiceExt;
2943
2944 let (url, captured, _handle) = start_request_capturing_server().await;
2945 let ctx = test_producer_ctx();
2946 let component = HttpComponent::new();
2947 let endpoint_ctx = NoOpComponentContext;
2948 let endpoint = component
2949 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
2950 .unwrap();
2951 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2952
2953 let mut exchange = Exchange::new(Message::default());
2954 exchange.input.set_header("Accept", "application/json");
2955 exchange.input.set_header("User-Agent", "myclient/1.0");
2956
2957 let result = producer.oneshot(exchange).await;
2958 assert!(result.is_ok(), "producer call failed: {:?}", result);
2959
2960 tokio::time::sleep(Duration::from_millis(100)).await;
2961 let request = captured
2962 .lock()
2963 .unwrap()
2964 .take()
2965 .expect("no outbound request captured");
2966 let lower = request.to_ascii_lowercase();
2967 assert!(
2968 lower.contains("accept: application/json"),
2969 "request-only Accept header must be forwarded\n{request}"
2970 );
2971 assert!(
2972 lower.contains("user-agent: myclient/1.0"),
2973 "request-only User-Agent header must be forwarded\n{request}"
2974 );
2975 }
2976
2977 #[tokio::test]
2978 async fn producer_honours_skip_request_headers() {
2979 use tower::ServiceExt;
2980
2981 let (url, captured, _handle) = start_request_capturing_server().await;
2982 let ctx = test_producer_ctx();
2983 let component = HttpComponent::new();
2984 let endpoint_ctx = NoOpComponentContext;
2985 let endpoint = component
2986 .create_endpoint(
2987 &format!("{url}/api/test?allowInternal=true&skipRequestHeaders=Authorization"),
2988 &endpoint_ctx,
2989 )
2990 .unwrap();
2991 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2992
2993 let mut exchange = Exchange::new(Message::default());
2994 exchange.input.set_header("Authorization", "Bearer x");
2995
2996 let result = producer.oneshot(exchange).await;
2997 assert!(result.is_ok(), "producer call failed: {:?}", result);
2998
2999 tokio::time::sleep(Duration::from_millis(100)).await;
3000 let request = captured
3001 .lock()
3002 .unwrap()
3003 .take()
3004 .expect("no outbound request captured");
3005 assert!(
3006 !request.to_ascii_lowercase().contains("authorization"),
3007 "Authorization must be stripped by skipRequestHeaders\n{request}"
3008 );
3009 }
3010
3011 #[tokio::test]
3012 async fn test_http_producer_post_with_body() {
3013 use tower::ServiceExt;
3014
3015 let (url, _handle) = start_test_server().await;
3016 let ctx = test_producer_ctx();
3017
3018 let component = HttpComponent::new();
3019 let endpoint_ctx = NoOpComponentContext;
3020 let endpoint = component
3021 .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
3022 .unwrap();
3023 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3024
3025 let exchange = Exchange::new(Message::new("request body"));
3026 let result = producer.oneshot(exchange).await.unwrap();
3027
3028 let status = result
3029 .input
3030 .header("CamelHttpResponseCode")
3031 .and_then(|v| v.as_u64())
3032 .unwrap();
3033 assert_eq!(status, 200);
3034 }
3035
3036 #[tokio::test]
3037 async fn test_http_producer_method_from_header() {
3038 use tower::ServiceExt;
3039
3040 let (url, _handle) = start_test_server().await;
3041 let ctx = test_producer_ctx();
3042
3043 let component = HttpComponent::new();
3044 let endpoint_ctx = NoOpComponentContext;
3045 let endpoint = component
3046 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
3047 .unwrap();
3048 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3049
3050 let mut exchange = Exchange::new(Message::default());
3051 exchange.input.set_header(
3052 "CamelHttpMethod",
3053 serde_json::Value::String("DELETE".to_string()),
3054 );
3055
3056 let result = producer.oneshot(exchange).await.unwrap();
3057 let status = result
3058 .input
3059 .header("CamelHttpResponseCode")
3060 .and_then(|v| v.as_u64())
3061 .unwrap();
3062 assert_eq!(status, 200);
3063 }
3064
3065 #[tokio::test]
3066 async fn test_http_producer_forced_method() {
3067 use tower::ServiceExt;
3068
3069 let (url, _handle) = start_test_server().await;
3070 let ctx = test_producer_ctx();
3071
3072 let component = HttpComponent::new();
3073 let endpoint_ctx = NoOpComponentContext;
3074 let endpoint = component
3075 .create_endpoint(
3076 &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
3077 &endpoint_ctx,
3078 )
3079 .unwrap();
3080 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3081
3082 let exchange = Exchange::new(Message::default());
3083 let result = producer.oneshot(exchange).await.unwrap();
3084
3085 let status = result
3086 .input
3087 .header("CamelHttpResponseCode")
3088 .and_then(|v| v.as_u64())
3089 .unwrap();
3090 assert_eq!(status, 200);
3091 }
3092
3093 #[tokio::test]
3094 async fn test_http_producer_throw_exception_on_failure() {
3095 use tower::ServiceExt;
3096
3097 let (url, _handle) = start_status_server(404).await;
3098 let ctx = test_producer_ctx();
3099
3100 let component = HttpComponent::new();
3101 let endpoint_ctx = NoOpComponentContext;
3102 let endpoint = component
3103 .create_endpoint(
3104 &format!("{url}/not-found?allowInternal=true"),
3105 &endpoint_ctx,
3106 )
3107 .unwrap();
3108 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3109
3110 let exchange = Exchange::new(Message::default());
3111 let result = producer.oneshot(exchange).await;
3112 assert!(result.is_err());
3113
3114 match result.unwrap_err() {
3115 CamelError::HttpOperationFailed { status_code, .. } => {
3116 assert_eq!(status_code, 404);
3117 }
3118 e => panic!("Expected HttpOperationFailed, got: {e}"),
3119 }
3120 }
3121
3122 #[tokio::test]
3123 async fn test_http_producer_no_throw_on_failure() {
3124 use tower::ServiceExt;
3125
3126 let (url, _handle) = start_status_server(500).await;
3127 let ctx = test_producer_ctx();
3128
3129 let component = HttpComponent::new();
3130 let endpoint_ctx = NoOpComponentContext;
3131 let endpoint = component
3132 .create_endpoint(
3133 &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
3134 &endpoint_ctx,
3135 )
3136 .unwrap();
3137 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3138
3139 let exchange = Exchange::new(Message::default());
3140 let result = producer.oneshot(exchange).await.unwrap();
3141
3142 let status = result
3143 .input
3144 .header("CamelHttpResponseCode")
3145 .and_then(|v| v.as_u64())
3146 .unwrap();
3147 assert_eq!(status, 500);
3148 }
3149
3150 #[tokio::test]
3151 async fn test_http_producer_uri_override() {
3152 use tower::ServiceExt;
3153
3154 let (url, _handle) = start_test_server().await;
3155 let ctx = test_producer_ctx();
3156
3157 let component = HttpComponent::new();
3158 let endpoint_ctx = NoOpComponentContext;
3159 let endpoint = component
3160 .create_endpoint(
3161 "http://localhost:1/does-not-exist?allowInternal=true",
3162 &endpoint_ctx,
3163 )
3164 .unwrap();
3165 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3166
3167 let mut exchange = Exchange::new(Message::default());
3168 exchange.input.set_header(
3169 "CamelHttpUri",
3170 serde_json::Value::String(format!("{url}/api")),
3171 );
3172
3173 let result = producer.oneshot(exchange).await.unwrap();
3174 let status = result
3175 .input
3176 .header("CamelHttpResponseCode")
3177 .and_then(|v| v.as_u64())
3178 .unwrap();
3179 assert_eq!(status, 200);
3180 }
3181
3182 #[tokio::test]
3183 async fn test_http_producer_response_headers_mapped() {
3184 use tower::ServiceExt;
3185
3186 let (url, _handle) = start_test_server().await;
3187 let ctx = test_producer_ctx();
3188
3189 let component = HttpComponent::new();
3190 let endpoint_ctx = NoOpComponentContext;
3191 let endpoint = component
3192 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
3193 .unwrap();
3194 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3195
3196 let exchange = Exchange::new(Message::default());
3197 let result = producer.oneshot(exchange).await.unwrap();
3198
3199 assert!(
3200 result.input.header("Content-Type").is_some(),
3201 "Response should have Content-Type header"
3202 );
3203 assert!(result.input.header("CamelHttpResponseText").is_some());
3204 }
3205
3206 async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
3211 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3212 let addr = listener.local_addr().unwrap();
3213 let url = format!("http://127.0.0.1:{}", addr.port());
3214
3215 let handle = tokio::spawn(async move {
3216 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3217 loop {
3218 if let Ok((mut stream, _)) = listener.accept().await {
3219 tokio::spawn(async move {
3220 let mut buf = vec![0u8; 4096];
3221 let n = stream.read(&mut buf).await.unwrap_or(0);
3222 let request = String::from_utf8_lossy(&buf[..n]).to_string();
3223
3224 if request.contains("GET /final") {
3226 let body = r#"{"status":"final"}"#;
3227 let response = format!(
3228 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
3229 body.len(),
3230 body
3231 );
3232 let _ = stream.write_all(response.as_bytes()).await;
3233 } else {
3234 let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\n\r\n";
3236 let _ = stream.write_all(response.as_bytes()).await;
3237 }
3238 });
3239 }
3240 }
3241 });
3242
3243 (url, handle)
3244 }
3245
3246 struct CapturedRequest {
3247 method: String,
3248 path: String,
3249 body: Vec<u8>,
3250 content_length: Option<String>,
3251 transfer_encoding: Option<String>,
3252 }
3253
3254 async fn capture_request(stream: &mut tokio::net::TcpStream) -> Option<CapturedRequest> {
3260 use tokio::io::AsyncReadExt;
3261
3262 let mut buf: Vec<u8> = Vec::new();
3264 let mut chunk = [0u8; 4096];
3265 let head_end: usize;
3266 loop {
3267 let n = stream.read(&mut chunk).await.unwrap_or(0);
3268 if n == 0 {
3269 return None;
3270 }
3271 buf.extend_from_slice(&chunk[..n]);
3272 if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
3273 head_end = pos + 4;
3274 break;
3275 }
3276 }
3277
3278 let head = String::from_utf8_lossy(&buf[..head_end]);
3280 let mut lines = head.split("\r\n");
3281 let request_line = lines.next().unwrap_or("");
3282 let mut parts = request_line.split_whitespace();
3283 let method = parts.next().unwrap_or("").to_string();
3284 let path = parts.next().unwrap_or("").to_string();
3285
3286 let mut content_length: Option<String> = None;
3287 let mut transfer_encoding: Option<String> = None;
3288 for line in lines {
3289 if let Some((name, value)) = line.split_once(':') {
3290 let name = name.trim().to_ascii_lowercase();
3291 let value = value.trim().to_string();
3292 if name == "content-length" {
3293 content_length = Some(value);
3294 } else if name == "transfer-encoding" {
3295 transfer_encoding = Some(value);
3296 }
3297 }
3298 }
3299
3300 let body_len: usize = content_length
3302 .as_deref()
3303 .and_then(|v| v.parse::<usize>().ok())
3304 .unwrap_or(0);
3305
3306 let mut body: Vec<u8> = buf[head_end..].to_vec();
3307 while body.len() < body_len {
3308 let n = stream.read(&mut chunk).await.unwrap_or(0);
3309 if n == 0 {
3310 break;
3311 }
3312 body.extend_from_slice(&chunk[..n]);
3313 }
3314 body.truncate(body_len);
3315
3316 Some(CapturedRequest {
3317 method,
3318 path,
3319 body,
3320 content_length,
3321 transfer_encoding,
3322 })
3323 }
3324
3325 async fn start_capture_server() -> (
3330 String,
3331 tokio::task::JoinHandle<()>,
3332 Arc<Mutex<Vec<CapturedRequest>>>,
3333 ) {
3334 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3335 let addr = listener.local_addr().unwrap();
3336 let url = format!("http://127.0.0.1:{}", addr.port());
3337
3338 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
3339 let captured_for_return = Arc::clone(&captured);
3340
3341 let handle = tokio::spawn(async move {
3342 use tokio::io::AsyncWriteExt;
3343 loop {
3344 if let Ok((mut stream, _)) = listener.accept().await {
3345 let captured = Arc::clone(&captured);
3346 tokio::spawn(async move {
3347 let Some(req) = capture_request(&mut stream).await else {
3348 return;
3349 };
3350 captured.lock().unwrap().push(req);
3351
3352 let response = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
3355 let _ = stream.write_all(response.as_bytes()).await;
3356 });
3357 }
3358 }
3359 });
3360
3361 (url, handle, captured_for_return)
3362 }
3363
3364 async fn start_redirect_capture_server() -> (
3370 String,
3371 tokio::task::JoinHandle<()>,
3372 Arc<Mutex<Vec<CapturedRequest>>>,
3373 ) {
3374 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3375 let addr = listener.local_addr().unwrap();
3376 let url = format!("http://127.0.0.1:{}", addr.port());
3377
3378 let captured: Arc<Mutex<Vec<CapturedRequest>>> = Arc::new(Mutex::new(Vec::new()));
3379 let captured_for_return = Arc::clone(&captured);
3380
3381 let handle = tokio::spawn(async move {
3382 use tokio::io::AsyncWriteExt;
3383 loop {
3384 if let Ok((mut stream, _)) = listener.accept().await {
3385 let captured = Arc::clone(&captured);
3386 tokio::spawn(async move {
3387 let Some(req) = capture_request(&mut stream).await else {
3388 return;
3389 };
3390 let path = req.path.clone();
3391 captured.lock().unwrap().push(req);
3392
3393 let (status_line, location) = match path.as_str() {
3394 "/hop307" => ("HTTP/1.1 307 Temporary Redirect", Some("/final")),
3395 "/hop308" => ("HTTP/1.1 308 Permanent Redirect", Some("/final")),
3396 "/final" => ("HTTP/1.1 200 OK", None),
3397 _ => ("HTTP/1.1 404 Not Found", None),
3398 };
3399
3400 let response = match location {
3401 Some(loc) => format!(
3402 "{status_line}\r\nLocation: {loc}\r\nContent-Length: 0\r\n\r\n"
3403 ),
3404 None => format!("{status_line}\r\nContent-Length: 0\r\n\r\n"),
3405 };
3406 let _ = stream.write_all(response.as_bytes()).await;
3407 });
3408 }
3409 }
3410 });
3411
3412 (url, handle, captured_for_return)
3413 }
3414
3415 #[tokio::test]
3416 async fn test_get_with_body_sends_no_body_and_no_framing_headers() {
3417 use tower::ServiceExt;
3418
3419 let (url, _handle, captured) = start_capture_server().await;
3420 let ctx = test_producer_ctx();
3421
3422 let component = HttpComponent::with_config(HttpConfig::default());
3423 let endpoint_ctx = NoOpComponentContext;
3424 let endpoint = component
3425 .create_endpoint(
3426 &format!("{url}?httpMethod=GET&allowInternal=true"),
3427 &endpoint_ctx,
3428 )
3429 .unwrap();
3430 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3431
3432 let mut exchange = Exchange::new(Message::default());
3433 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3434
3435 let result = producer.oneshot(exchange).await.unwrap();
3436
3437 let status = result
3438 .input
3439 .header("CamelHttpResponseCode")
3440 .and_then(|v| v.as_u64())
3441 .unwrap();
3442 assert_eq!(status, 200);
3443
3444 let captured = captured.lock().unwrap();
3445 assert_eq!(captured.len(), 1, "expected exactly one captured request");
3446 let req = &captured[0];
3447 assert_eq!(req.method, "GET");
3448 assert_eq!(req.path, "/");
3451 assert!(req.body.is_empty(), "GET must not carry a body");
3452 assert!(
3453 req.content_length.is_none(),
3454 "suppressed request must not carry Content-Length"
3455 );
3456 assert!(
3457 req.transfer_encoding.is_none(),
3458 "suppressed request must not carry Transfer-Encoding"
3459 );
3460
3461 assert!(
3463 result.input.body.is_empty(),
3464 "exchange body must be consumed"
3465 );
3466 }
3467
3468 #[tokio::test]
3469 async fn test_head_with_body_suppressed_via_header() {
3470 use tower::ServiceExt;
3471
3472 let (url, _handle, captured) = start_capture_server().await;
3473 let ctx = test_producer_ctx();
3474
3475 let component = HttpComponent::with_config(HttpConfig::default());
3476 let endpoint_ctx = NoOpComponentContext;
3477 let endpoint = component
3478 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
3479 .unwrap();
3480 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3481
3482 let mut exchange = Exchange::new(Message::default());
3483 exchange.input.set_header(
3484 "CamelHttpMethod",
3485 serde_json::Value::String("HEAD".to_string()),
3486 );
3487 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3488
3489 let result = producer.oneshot(exchange).await.unwrap();
3490 let status = result
3491 .input
3492 .header("CamelHttpResponseCode")
3493 .and_then(|v| v.as_u64())
3494 .unwrap();
3495 assert_eq!(status, 200);
3496
3497 let captured = captured.lock().unwrap();
3498 assert_eq!(captured.len(), 1);
3499 let req = &captured[0];
3500 assert_eq!(req.method, "HEAD");
3501 assert!(req.body.is_empty(), "HEAD must not carry a body");
3502 }
3503
3504 #[tokio::test]
3505 async fn test_delete_options_trace_with_body_suppressed() {
3506 use tower::ServiceExt;
3507
3508 let (url, _handle, captured) = start_capture_server().await;
3509 let ctx = test_producer_ctx();
3510 let component = HttpComponent::with_config(HttpConfig::default());
3511 let endpoint_ctx = NoOpComponentContext;
3512
3513 for method in ["DELETE", "OPTIONS", "TRACE"] {
3514 let endpoint = component
3515 .create_endpoint(
3516 &format!("{url}?httpMethod={method}&allowInternal=true"),
3517 &endpoint_ctx,
3518 )
3519 .unwrap();
3520 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3521
3522 let mut exchange = Exchange::new(Message::default());
3523 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3524
3525 let result = producer.oneshot(exchange).await.unwrap();
3526 let status = result
3527 .input
3528 .header("CamelHttpResponseCode")
3529 .and_then(|v| v.as_u64())
3530 .unwrap();
3531 assert_eq!(status, 200, "method {method} should succeed");
3532 }
3533
3534 let captured = captured.lock().unwrap();
3535 assert_eq!(captured.len(), 3, "expected three captured requests");
3536 for method in ["DELETE", "OPTIONS", "TRACE"] {
3537 let req = captured
3538 .iter()
3539 .find(|r| r.method == method)
3540 .unwrap_or_else(|| panic!("missing captured request for {method}"));
3541 assert!(req.body.is_empty(), "{} must not carry a body", req.method);
3542 }
3543 }
3544
3545 #[tokio::test]
3546 async fn test_post_put_patch_with_body_still_sent() {
3547 use tower::ServiceExt;
3548
3549 let (url, _handle, captured) = start_capture_server().await;
3550 let ctx = test_producer_ctx();
3551 let component = HttpComponent::with_config(HttpConfig::default());
3552 let endpoint_ctx = NoOpComponentContext;
3553
3554 for method in ["POST", "PUT", "PATCH"] {
3555 let endpoint = component
3556 .create_endpoint(
3557 &format!("{url}?httpMethod={method}&allowInternal=true"),
3558 &endpoint_ctx,
3559 )
3560 .unwrap();
3561 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3562
3563 let payload = format!("body-for-{method}");
3564 let mut exchange = Exchange::new(Message::default());
3565 exchange.input.body = Body::Bytes(bytes::Bytes::from(payload.as_bytes().to_vec()));
3566
3567 let result = producer.oneshot(exchange).await.unwrap();
3568 let status = result
3569 .input
3570 .header("CamelHttpResponseCode")
3571 .and_then(|v| v.as_u64())
3572 .unwrap();
3573 assert_eq!(status, 200, "method {method} should succeed");
3574 }
3575
3576 let captured = captured.lock().unwrap();
3577 assert_eq!(captured.len(), 3, "expected three captured requests");
3578 for method in ["POST", "PUT", "PATCH"] {
3579 let req = captured
3580 .iter()
3581 .find(|r| r.method == method)
3582 .unwrap_or_else(|| panic!("missing captured request for {method}"));
3583 let expected = format!("body-for-{method}");
3584 assert!(!req.body.is_empty(), "{method} must still carry its body");
3585 assert_eq!(req.body, expected.as_bytes(), "{method} body mismatch");
3586 }
3587 }
3588
3589 #[tokio::test]
3593 async fn test_stream_body_under_get_not_attached() {
3594 use tower::ServiceExt;
3595
3596 let (url, _handle, captured) = start_capture_server().await;
3597 let ctx = test_producer_ctx();
3598
3599 let component = HttpComponent::with_config(HttpConfig::default());
3600 let endpoint_ctx = NoOpComponentContext;
3601 let endpoint = component
3602 .create_endpoint(
3603 &format!("{url}?httpMethod=GET&allowInternal=true"),
3604 &endpoint_ctx,
3605 )
3606 .unwrap();
3607 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3608
3609 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
3610 vec![Ok(bytes::Bytes::from_static(b"stream-body"))];
3611 let stream = Box::pin(futures::stream::iter(chunks));
3612 let mut exchange = Exchange::new(Message::default());
3613 exchange.input.body = Body::Stream(StreamBody {
3614 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
3615 metadata: StreamMetadata::default(),
3616 });
3617
3618 let result = producer.oneshot(exchange).await.unwrap();
3619
3620 let status = result
3621 .input
3622 .header("CamelHttpResponseCode")
3623 .and_then(|v| v.as_u64())
3624 .unwrap();
3625 assert_eq!(status, 200);
3626
3627 let captured = captured.lock().unwrap();
3628 assert_eq!(captured.len(), 1, "expected exactly one captured request");
3629 assert!(
3630 captured[0].body.is_empty(),
3631 "GET must not carry a stream body"
3632 );
3633 assert!(
3634 captured[0].transfer_encoding.is_none(),
3635 "suppressed request must not carry Transfer-Encoding"
3636 );
3637 assert!(
3638 captured[0].content_length.is_none(),
3639 "suppressed request must not carry Content-Length"
3640 );
3641 assert!(
3642 result.input.body.is_empty(),
3643 "exchange body must be consumed to Empty, not left as a stream"
3644 );
3645 }
3646
3647 #[tokio::test]
3651 async fn test_redirect_hops_never_replay_suppressed_body() {
3652 use tower::ServiceExt;
3653
3654 let (url, _handle, captured) = start_redirect_capture_server().await;
3655 let ctx = test_producer_ctx();
3656
3657 let component =
3658 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3659 let endpoint_ctx = NoOpComponentContext;
3660
3661 for path in ["/hop307", "/hop308"] {
3662 let endpoint = component
3663 .create_endpoint(
3664 &format!("{url}{path}?httpMethod=GET&allowInternal=true"),
3665 &endpoint_ctx,
3666 )
3667 .unwrap();
3668 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3669
3670 let mut exchange = Exchange::new(Message::default());
3671 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3672
3673 let result = producer.oneshot(exchange).await.unwrap();
3674 let status = result
3675 .input
3676 .header("CamelHttpResponseCode")
3677 .and_then(|v| v.as_u64())
3678 .unwrap();
3679 assert_eq!(
3680 status, 200,
3681 "redirect chain for {path} should end at /final"
3682 );
3683 }
3684
3685 let captured = captured.lock().unwrap();
3687 assert_eq!(captured.len(), 4, "expected 2 chains × 2 hops");
3688 for req in captured.iter() {
3689 assert!(
3690 req.body.is_empty(),
3691 "hop {} {} must not carry a body",
3692 req.method,
3693 req.path
3694 );
3695 }
3696 }
3697
3698 #[tracing_test::traced_test]
3707 #[tokio::test]
3708 async fn test_suppressed_body_logs_exactly_one_warn() {
3709 use tower::ServiceExt;
3710
3711 let (url, _handle, _captured) = start_capture_server().await;
3712 let ctx = test_producer_ctx();
3713
3714 let component = HttpComponent::with_config(HttpConfig::default());
3715 let endpoint_ctx = NoOpComponentContext;
3716 let endpoint = component
3717 .create_endpoint(
3718 &format!("{url}?httpMethod=GET&allowInternal=true"),
3719 &endpoint_ctx,
3720 )
3721 .unwrap();
3722 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3723
3724 let mut exchange = Exchange::new(Message::default());
3725 exchange.input.body = Body::Bytes(bytes::Bytes::from_static(b"payload"));
3726 let correlation_id = exchange.correlation_id().to_string();
3727
3728 let result = producer.oneshot(exchange).await.unwrap();
3729 let status = result
3730 .input
3731 .header("CamelHttpResponseCode")
3732 .and_then(|v| v.as_u64())
3733 .unwrap();
3734 assert_eq!(status, 200);
3735
3736 logs_assert(|lines: &[&str]| {
3737 let hits = lines
3738 .iter()
3739 .filter(|l| {
3740 l.contains("dropping request body")
3741 && l.contains("method=GET")
3742 && l.contains(&format!("correlation_id={correlation_id}"))
3743 })
3744 .count();
3745 match hits {
3746 1 => Ok(()),
3747 n => Err(format!("expected exactly one body-drop warn, found {n}")),
3748 }
3749 });
3750 }
3751
3752 #[tracing_test::traced_test]
3753 #[tokio::test]
3754 async fn test_empty_body_get_emits_no_warn() {
3755 use tower::ServiceExt;
3756
3757 let (url, _handle, _captured) = start_capture_server().await;
3758 let ctx = test_producer_ctx();
3759
3760 let component = HttpComponent::with_config(HttpConfig::default());
3761 let endpoint_ctx = NoOpComponentContext;
3762 let endpoint = component
3763 .create_endpoint(
3764 &format!("{url}?httpMethod=GET&allowInternal=true"),
3765 &endpoint_ctx,
3766 )
3767 .unwrap();
3768 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3769
3770 let exchange = Exchange::new(Message::default());
3771 let result = producer.oneshot(exchange).await.unwrap();
3772 let status = result
3773 .input
3774 .header("CamelHttpResponseCode")
3775 .and_then(|v| v.as_u64())
3776 .unwrap();
3777 assert_eq!(status, 200);
3778
3779 logs_assert(|lines: &[&str]| {
3780 let hits = lines
3781 .iter()
3782 .filter(|l| l.contains("dropping request body"))
3783 .count();
3784 match hits {
3785 0 => Ok(()),
3786 n => Err(format!("expected no body-drop warn, found {n}")),
3787 }
3788 });
3789 }
3790
3791 #[tokio::test]
3792 async fn test_follow_redirects_false_does_not_follow() {
3793 use tower::ServiceExt;
3794
3795 let (url, _handle) = start_redirect_server().await;
3796 let ctx = test_producer_ctx();
3797
3798 let component =
3799 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
3800 let endpoint_ctx = NoOpComponentContext;
3801 let endpoint = component
3802 .create_endpoint(
3803 &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
3804 &endpoint_ctx,
3805 )
3806 .unwrap();
3807 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3808
3809 let exchange = Exchange::new(Message::default());
3810 let result = producer.oneshot(exchange).await.unwrap();
3811
3812 let status = result
3814 .input
3815 .header("CamelHttpResponseCode")
3816 .and_then(|v| v.as_u64())
3817 .unwrap();
3818 assert_eq!(
3819 status, 302,
3820 "Should NOT follow redirect when followRedirects=false"
3821 );
3822 }
3823
3824 #[tokio::test]
3825 async fn test_follow_redirects_true_follows_redirect() {
3826 use tower::ServiceExt;
3827
3828 let (url, _handle) = start_redirect_server().await;
3829 let ctx = test_producer_ctx();
3830
3831 let component =
3832 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3833 let endpoint_ctx = NoOpComponentContext;
3834 let endpoint = component
3835 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
3836 .unwrap();
3837 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3838
3839 let exchange = Exchange::new(Message::default());
3840 let result = producer.oneshot(exchange).await.unwrap();
3841
3842 let status = result
3844 .input
3845 .header("CamelHttpResponseCode")
3846 .and_then(|v| v.as_u64())
3847 .unwrap();
3848 assert_eq!(
3849 status, 200,
3850 "Should follow redirect when followRedirects=true"
3851 );
3852 }
3853
3854 #[tokio::test]
3857 async fn test_redirect_to_private_ip_is_ssrf_blocked() {
3858 use tower::ServiceExt;
3859
3860 let (url, _handle) = start_redirect_server().await;
3862 let ctx = test_producer_ctx();
3863
3864 let component =
3865 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3866 let endpoint_ctx = NoOpComponentContext;
3867 let endpoint = component
3868 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
3869 .unwrap();
3870 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3871
3872 let exchange = Exchange::new(Message::default());
3873 let result = producer.oneshot(exchange).await;
3874
3875 assert!(
3877 result.is_ok(),
3878 "Redirect should succeed with allowInternal=true, got: {:?}",
3879 result
3880 );
3881 let exchange = result.unwrap();
3882 let status = exchange
3883 .input
3884 .header("CamelHttpResponseCode")
3885 .and_then(|v| v.as_u64())
3886 .unwrap();
3887 assert_eq!(status, 200, "Should follow redirect to /final");
3888 }
3889
3890 #[tokio::test]
3892 async fn test_redirect_to_private_ip_allowed_when_configured() {
3893 use tower::ServiceExt;
3894
3895 let (url, _handle) = start_redirect_server().await;
3897 let ctx = test_producer_ctx();
3898
3899 let component =
3900 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3901 let endpoint_ctx = NoOpComponentContext;
3902 let endpoint = component
3903 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
3904 .unwrap();
3905 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3906
3907 let exchange = Exchange::new(Message::default());
3908 let result = producer.oneshot(exchange).await.unwrap();
3909
3910 let status = result
3911 .input
3912 .header("CamelHttpResponseCode")
3913 .and_then(|v| v.as_u64())
3914 .unwrap();
3915 assert_eq!(
3916 status, 200,
3917 "Should follow redirect to private IP when allowInternal=true"
3918 );
3919 }
3920
3921 #[tokio::test]
3924 async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
3925 use tower::ServiceExt;
3926
3927 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3929 let addr = listener.local_addr().unwrap();
3930 let url = format!("http://127.0.0.1:{}", addr.port());
3931
3932 let handle = tokio::spawn(async move {
3933 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3934 loop {
3935 if let Ok((mut stream, _)) = listener.accept().await {
3936 tokio::spawn(async move {
3937 let mut buf = vec![0u8; 4096];
3938 let _ = stream.read(&mut buf).await;
3939 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";
3941 let _ = stream.write_all(response.as_bytes()).await;
3942 });
3943 }
3944 }
3945 });
3946
3947 let ctx = test_producer_ctx();
3948 let component =
3949 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3950 let endpoint_ctx = NoOpComponentContext;
3951 let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
3953 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3954
3955 let exchange = Exchange::new(Message::default());
3956 let result = producer.oneshot(exchange).await;
3957
3958 assert!(
3960 result.is_err(),
3961 "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
3962 );
3963 let err = result.unwrap_err().to_string();
3964 assert!(
3965 err.contains("blocked IP")
3966 || err.contains("private IP")
3967 || err.contains("SSRF")
3968 || err.contains("not allowed"),
3969 "Error should mention SSRF/IP blocking, got: {err}"
3970 );
3971
3972 handle.abort();
3973 }
3974
3975 #[tokio::test]
3977 async fn test_too_many_redirects_returns_error() {
3978 use tower::ServiceExt;
3979
3980 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3982 let addr = listener.local_addr().unwrap();
3983 let url = format!("http://127.0.0.1:{}", addr.port());
3984
3985 let handle = tokio::spawn(async move {
3986 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3987 loop {
3988 if let Ok((mut stream, _)) = listener.accept().await {
3989 tokio::spawn(async move {
3990 let mut buf = vec![0u8; 4096];
3991 let _ = stream.read(&mut buf).await;
3992 let response =
3994 "HTTP/1.1 302 Found\r\nLocation: /loop\r\nContent-Length: 0\r\n\r\n";
3995 let _ = stream.write_all(response.as_bytes()).await;
3996 });
3997 }
3998 }
3999 });
4000
4001 let ctx = test_producer_ctx();
4002 let component =
4003 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
4004 let endpoint_ctx = NoOpComponentContext;
4005 let endpoint = component
4006 .create_endpoint(
4007 &format!("{url}?allowInternal=true&maxRedirects=2"),
4008 &endpoint_ctx,
4009 )
4010 .unwrap();
4011 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4012
4013 let exchange = Exchange::new(Message::default());
4014 let result = producer.oneshot(exchange).await;
4015
4016 match result {
4024 Err(e) => {
4025 let msg = e.to_string();
4027 assert!(
4028 msg.contains("HTTP operation failed") || msg.contains("302"),
4029 "expected redirect-after-exhaustion error, got: {msg}"
4030 );
4031 }
4032 Ok(ex) => {
4033 let response_code = ex
4034 .input
4035 .header("CamelHttpResponseCode")
4036 .and_then(|v| v.as_u64());
4037 assert_eq!(
4038 response_code,
4039 Some(302),
4040 "expected 302 after exhausting redirects"
4041 );
4042 }
4043 }
4044
4045 handle.abort();
4046 }
4047
4048 #[tokio::test]
4049 async fn test_query_params_forwarded_to_http_request() {
4050 use tower::ServiceExt;
4051
4052 let (url, _handle) = start_test_server().await;
4053 let ctx = test_producer_ctx();
4054
4055 let component = HttpComponent::new();
4056 let endpoint_ctx = NoOpComponentContext;
4057 let endpoint = component
4059 .create_endpoint(
4060 &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
4061 &endpoint_ctx,
4062 )
4063 .unwrap();
4064 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4065
4066 let exchange = Exchange::new(Message::default());
4067 let result = producer.oneshot(exchange).await.unwrap();
4068
4069 let status = result
4072 .input
4073 .header("CamelHttpResponseCode")
4074 .and_then(|v| v.as_u64())
4075 .unwrap();
4076 assert_eq!(status, 200);
4077 }
4078
4079 #[tokio::test]
4080 async fn test_non_camel_query_params_are_forwarded() {
4081 let config = HttpEndpointConfig::from_uri(
4084 "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
4085 )
4086 .unwrap();
4087
4088 assert!(
4090 config.query_params.contains_key("apiKey"),
4091 "apiKey should be preserved"
4092 );
4093 assert!(
4094 config.query_params.contains_key("token"),
4095 "token should be preserved"
4096 );
4097 assert_eq!(config.query_params.get("apiKey").unwrap(), "secret123");
4098 assert_eq!(config.query_params.get("token").unwrap(), "abc456");
4099
4100 assert!(
4102 !config.query_params.contains_key("httpMethod"),
4103 "httpMethod should not be forwarded"
4104 );
4105 }
4106
4107 #[test]
4108 fn test_query_params_are_url_encoded_when_resolving_url() {
4109 let config =
4110 HttpEndpointConfig::from_uri("http://example.com/api?q=hello world&tag=a+b").unwrap();
4111 let exchange = Exchange::new(Message::default());
4112
4113 let url = HttpProducer::resolve_url(&exchange, &config);
4114
4115 assert!(url.contains("q=hello+world"), "url was: {url}");
4116 assert!(url.contains("tag=a%2Bb"), "url was: {url}");
4117 }
4118
4119 async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
4124 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4125 let addr = listener.local_addr().unwrap();
4126 let url = format!("http://127.0.0.1:{}", addr.port());
4127
4128 let handle = tokio::spawn(async move {
4129 loop {
4130 if let Ok((mut stream, _)) = listener.accept().await {
4131 let delay = delay_ms;
4132 tokio::spawn(async move {
4133 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4134 let mut buf = vec![0u8; 4096];
4135 let _ = stream.read(&mut buf).await;
4136 let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
4138 let _ = stream.write_all(headers.as_bytes()).await;
4139 tokio::time::sleep(Duration::from_millis(delay)).await;
4141 let body = r#"{"status":"slow"}"#;
4142 let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
4143 let _ = stream.write_all(chunk.as_bytes()).await;
4144 });
4145 }
4146 }
4147 });
4148
4149 (url, handle)
4150 }
4151
4152 #[tokio::test]
4153 async fn test_http_producer_timeout() {
4154 use tower::ServiceExt;
4155
4156 let (url, _handle) = start_slow_server(500).await;
4158 let ctx = test_producer_ctx();
4159
4160 let component = HttpComponent::with_config(
4161 HttpConfig::default()
4162 .with_read_timeout_ms(100)
4163 .with_response_timeout_ms(30_000), );
4165 let endpoint_ctx = NoOpComponentContext;
4166 let endpoint = component
4167 .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
4168 .unwrap();
4169 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4170
4171 let exchange = Exchange::new(Message::default());
4172 let result = producer.oneshot(exchange).await;
4173
4174 assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
4175 let err = result.unwrap_err().to_string();
4176 assert!(
4177 err.contains("Read timeout") || err.contains("timeout"),
4178 "Error should mention timeout, got: {}",
4179 err
4180 );
4181 }
4182
4183 #[tokio::test]
4184 async fn test_http_producer_no_timeout_when_fast() {
4185 use tower::ServiceExt;
4186
4187 let (url, _handle) = start_test_server().await;
4188 let ctx = test_producer_ctx();
4189
4190 let component =
4191 HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
4192 let endpoint_ctx = NoOpComponentContext;
4193 let endpoint = component
4194 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4195 .unwrap();
4196 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4197
4198 let exchange = Exchange::new(Message::default());
4199 let result = producer.oneshot(exchange).await.unwrap();
4200
4201 let status = result
4202 .input
4203 .header("CamelHttpResponseCode")
4204 .and_then(|v| v.as_u64())
4205 .unwrap();
4206 assert_eq!(status, 200);
4207 }
4208
4209 #[tokio::test]
4214 async fn test_http_producer_blocks_metadata_endpoint() {
4215 use tower::ServiceExt;
4216
4217 let ctx = test_producer_ctx();
4218 let component = HttpComponent::new();
4219 let endpoint_ctx = NoOpComponentContext;
4220 let endpoint = component
4221 .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
4222 .unwrap();
4223 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4224
4225 let mut exchange = Exchange::new(Message::default());
4226 exchange.input.set_header(
4227 "CamelHttpUri",
4228 serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
4229 );
4230
4231 let result = producer.oneshot(exchange).await;
4232 assert!(result.is_err(), "Should block AWS metadata endpoint");
4233
4234 let err = result.unwrap_err();
4235 assert!(
4236 err.to_string().contains("Private IP"),
4237 "Error should mention private IP blocking, got: {}",
4238 err
4239 );
4240 }
4241
4242 #[test]
4243 fn test_ssrf_config_defaults() {
4244 let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
4245 assert!(
4246 !config.allow_internal,
4247 "Private IPs should be blocked by default"
4248 );
4249 assert!(
4250 config.blocked_hosts.is_empty(),
4251 "Blocked hosts should be empty by default"
4252 );
4253 }
4254
4255 #[test]
4256 fn test_ssrf_config_allow_internal() {
4257 let config =
4258 HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
4259 assert!(
4260 config.allow_internal,
4261 "Private IPs should be allowed when explicitly set"
4262 );
4263 }
4264
4265 #[test]
4266 fn test_ssrf_config_blocked_hosts() {
4267 let config = HttpEndpointConfig::from_uri(
4268 "http://example.com/api?blockedHosts=evil.com,malware.net",
4269 )
4270 .unwrap();
4271 assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
4272 }
4273
4274 #[tokio::test]
4275 async fn test_http_producer_blocks_localhost() {
4276 use tower::ServiceExt;
4277
4278 let ctx = test_producer_ctx();
4279 let component = HttpComponent::new();
4280 let endpoint_ctx = NoOpComponentContext;
4281 let endpoint = component
4282 .create_endpoint("http://example.com/api", &endpoint_ctx)
4283 .unwrap();
4284 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4285
4286 let mut exchange = Exchange::new(Message::default());
4287 exchange.input.set_header(
4288 "CamelHttpUri",
4289 serde_json::Value::String("http://localhost:8080/internal".to_string()),
4290 );
4291
4292 let result = producer.oneshot(exchange).await;
4293 assert!(result.is_err(), "Should block localhost");
4294 }
4295
4296 #[tokio::test]
4297 async fn test_http_producer_blocks_loopback_ip() {
4298 use tower::ServiceExt;
4299
4300 let ctx = test_producer_ctx();
4301 let component = HttpComponent::new();
4302 let endpoint_ctx = NoOpComponentContext;
4303 let endpoint = component
4304 .create_endpoint("http://example.com/api", &endpoint_ctx)
4305 .unwrap();
4306 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4307
4308 let mut exchange = Exchange::new(Message::default());
4309 exchange.input.set_header(
4310 "CamelHttpUri",
4311 serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
4312 );
4313
4314 let result = producer.oneshot(exchange).await;
4315 assert!(result.is_err(), "Should block loopback IP");
4316 }
4317
4318 #[tokio::test]
4319 async fn test_http_producer_allows_private_ip_when_enabled() {
4320 use tower::ServiceExt;
4321
4322 let ctx = test_producer_ctx();
4323 let component = HttpComponent::new();
4324 let endpoint_ctx = NoOpComponentContext;
4325 let endpoint = component
4328 .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
4329 .unwrap();
4330 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4331
4332 let exchange = Exchange::new(Message::default());
4333
4334 let result = producer.oneshot(exchange).await;
4337 if let Err(ref e) = result {
4339 let err_str = e.to_string();
4340 assert!(
4341 !err_str.contains("Private IP") && !err_str.contains("not allowed"),
4342 "Should not be SSRF error, got: {}",
4343 err_str
4344 );
4345 }
4346 }
4347
4348 #[test]
4353 fn test_http_server_config_parse() {
4354 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
4355 assert_eq!(cfg.host, "0.0.0.0");
4356 assert_eq!(cfg.port, 8080);
4357 assert_eq!(cfg.path, "/orders");
4358 assert_eq!(cfg.max_inflight_requests, 1024);
4359 }
4360
4361 #[test]
4362 fn test_http_server_config_scheme() {
4363 assert_eq!(HttpServerConfig::scheme(), "http");
4365 }
4366
4367 #[test]
4368 fn test_http_server_config_from_components() {
4369 let components = camel_component_api::UriComponents {
4371 scheme: "https".to_string(),
4372 path: "//0.0.0.0:8443/api".to_string(),
4373 params: std::collections::HashMap::from([
4374 ("maxRequestBody".to_string(), "5242880".to_string()),
4375 ("maxInflightRequests".to_string(), "7".to_string()),
4376 ]),
4377 };
4378 let cfg = HttpServerConfig::from_components(components).unwrap();
4379 assert_eq!(cfg.host, "0.0.0.0");
4380 assert_eq!(cfg.port, 8443);
4381 assert_eq!(cfg.path, "/api");
4382 assert_eq!(cfg.max_request_body, 5242880);
4383 assert_eq!(cfg.max_inflight_requests, 7);
4384 }
4385
4386 #[test]
4387 fn test_http_server_config_default_path() {
4388 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
4389 assert_eq!(cfg.path, "/");
4390 }
4391
4392 #[test]
4393 fn test_http_server_config_wrong_scheme() {
4394 assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
4395 }
4396
4397 #[test]
4398 fn test_http_server_config_invalid_port() {
4399 assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
4400 }
4401
4402 #[test]
4403 fn test_http_server_config_default_port_by_scheme() {
4404 let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
4406 assert_eq!(cfg_http.port, 80);
4407
4408 let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
4410 assert_eq!(cfg_https.port, 443);
4411 }
4412
4413 #[test]
4414 fn test_request_envelope_and_reply_are_send() {
4415 fn assert_send<T: Send>() {}
4416 assert_send::<RequestEnvelope>();
4417 assert_send::<HttpReply>();
4418 }
4419
4420 #[test]
4425 fn test_server_registry_global_is_singleton() {
4426 let r1 = ServerRegistry::global();
4427 let r2 = ServerRegistry::global();
4428 assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
4429 }
4430
4431 #[allow(clippy::await_holding_lock)]
4432 #[tokio::test]
4433 async fn test_concurrent_get_or_spawn_returns_same_registry() {
4434 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4435 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4436 let port = listener.local_addr().unwrap().port();
4437 drop(listener);
4438
4439 let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
4440 Arc::new(std::sync::Mutex::new(Vec::new()));
4441
4442 let mut handles = Vec::new();
4443 for _ in 0..4 {
4444 let results = results.clone();
4445 handles.push(tokio::spawn(async move {
4446 let registry = ServerRegistry::global()
4447 .get_or_spawn(
4448 "127.0.0.1",
4449 port,
4450 2 * 1024 * 1024,
4451 10 * 1024 * 1024,
4452 1024,
4453 test_rt(),
4454 "test-route".into(),
4455 None,
4456 )
4457 .await
4458 .unwrap();
4459 results.lock().unwrap().push(registry);
4460 }));
4461 }
4462
4463 for h in handles {
4464 h.await.unwrap();
4465 }
4466
4467 let registries = results.lock().unwrap();
4468 assert_eq!(registries.len(), 4);
4469 for i in 1..registries.len() {
4470 assert!(
4471 Arc::ptr_eq(®istries[0].inner, ®istries[i].inner),
4472 "all concurrent callers should get same route registry"
4473 );
4474 }
4475 }
4476
4477 #[test]
4478 fn test_server_registry_distinguishes_host_and_port() {
4479 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4480 let rt = tokio::runtime::Runtime::new().expect("runtime");
4481 rt.block_on(async {
4482 let registry = ServerRegistry::global();
4483 let d1 = registry
4487 .get_or_spawn(
4488 "127.0.0.1",
4489 0,
4490 1024 * 1024,
4491 10 * 1024 * 1024,
4492 1024,
4493 test_rt(),
4494 "test-route-1".into(),
4495 None,
4496 )
4497 .await;
4498 let d2 = registry
4499 .get_or_spawn(
4500 "0.0.0.0",
4501 0,
4502 1024 * 1024,
4503 10 * 1024 * 1024,
4504 1024,
4505 test_rt(),
4506 "test-route-2".into(),
4507 None,
4508 )
4509 .await;
4510 assert!(d1.is_ok());
4511 assert!(d2.is_ok());
4512 assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
4513 });
4514 }
4515
4516 #[allow(clippy::await_holding_lock)]
4517 #[tokio::test]
4518 async fn test_shared_server_max_request_body_policy_is_deterministic() {
4519 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4520 let registry = ServerRegistry::global();
4521 let d1 = registry
4523 .get_or_spawn(
4524 "127.0.0.1",
4525 9991,
4526 1024 * 1024,
4527 10 * 1024 * 1024,
4528 1024,
4529 test_rt(),
4530 "test-route".into(),
4531 None,
4532 )
4533 .await;
4534 assert!(d1.is_ok());
4535
4536 let d2 = registry
4539 .get_or_spawn(
4540 "127.0.0.1",
4541 9991,
4542 2 * 1024 * 1024,
4543 10 * 1024 * 1024,
4544 1024,
4545 test_rt(),
4546 "test-route-2".into(),
4547 None,
4548 )
4549 .await;
4550 assert!(d2.is_err());
4551 let err = d2.unwrap_err();
4552 assert!(
4553 err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
4554 "Expected incompatible maxRequestBody error, got: {}",
4555 err
4556 );
4557 }
4558
4559 #[test]
4560 fn test_server_registry_reset_clears_entries() {
4561 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4562 let rt = tokio::runtime::Runtime::new().expect("runtime");
4563 rt.block_on(async {
4564 let d1 = ServerRegistry::global()
4566 .get_or_spawn(
4567 "127.0.0.1",
4568 9992,
4569 1024 * 1024,
4570 10 * 1024 * 1024,
4571 1024,
4572 test_rt(),
4573 "test-route".into(),
4574 None,
4575 )
4576 .await;
4577 assert!(d1.is_ok());
4578
4579 let guard = ServerRegistry::global().inner.lock().expect("lock");
4581 assert!(guard.contains_key(&("127.0.0.1".to_string(), 9992)));
4582 drop(guard);
4583
4584 ServerRegistry::reset();
4586
4587 let guard = ServerRegistry::global().inner.lock().expect("lock");
4589 assert!(
4590 guard.is_empty(),
4591 "registry should be empty after reset, has {} entries",
4592 guard.len()
4593 );
4594 });
4595 }
4596
4597 #[tokio::test]
4598 async fn registry_rejects_tls_on_plain_port() {
4599 ServerRegistry::reset();
4600 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
4601
4602 let _r1 = ServerRegistry::global()
4604 .get_or_spawn(
4605 "127.0.0.1",
4606 0,
4607 1024,
4608 1024,
4609 16,
4610 Arc::clone(&rt),
4611 "route-1".into(),
4612 None, )
4614 .await;
4615
4616 let result = ServerRegistry::global()
4618 .get_or_spawn(
4619 "127.0.0.1",
4620 0,
4621 1024,
4622 1024,
4623 16,
4624 Arc::clone(&rt),
4625 "route-2".into(),
4626 Some(crate::config::ServerTlsConfig {
4627 cert_path: "/x.pem".into(),
4628 key_path: "/y.pem".into(),
4629 }),
4630 )
4631 .await;
4632 assert!(result.is_err(), "must reject TLS on plain port");
4633 }
4634
4635 #[allow(clippy::await_holding_lock)]
4640 #[tokio::test]
4641 async fn test_unregister_last_http_route_keeps_server_alive() {
4642 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4643 ServerRegistry::reset();
4644 let registry = ServerRegistry::global();
4645
4646 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4647 let port = listener.local_addr().unwrap().port();
4648 drop(listener); let rt = test_rt();
4650
4651 let _r1 = registry
4654 .get_or_spawn(
4655 "127.0.0.1",
4656 port,
4657 1024 * 1024,
4658 10 * 1024 * 1024,
4659 16,
4660 rt.clone(),
4661 "test-route-1".into(),
4662 None,
4663 )
4664 .await
4665 .unwrap();
4666 let _r2 = registry
4667 .get_or_spawn(
4668 "127.0.0.1",
4669 port,
4670 1024 * 1024,
4671 10 * 1024 * 1024,
4672 16,
4673 rt,
4674 "test-route-2".into(),
4675 None,
4676 )
4677 .await
4678 .unwrap();
4679
4680 let key = ("127.0.0.1".to_string(), port);
4681 let cell = {
4682 let guard = registry.inner.lock().expect("lock");
4683 guard.get(&key).expect("entry should exist").clone()
4684 };
4685
4686 registry.unregister("127.0.0.1", port).await;
4688 {
4689 let handle = cell
4690 .get()
4691 .expect("handle should still exist after first unregister");
4692 assert!(
4693 !handle.monitor_task.is_finished(),
4694 "monitor task should still be alive after first unregister"
4695 );
4696 }
4697
4698 registry.unregister("127.0.0.1", port).await;
4700 tokio::time::sleep(Duration::from_millis(20)).await;
4701 {
4702 let handle = cell
4703 .get()
4704 .expect("handle should still exist after last unregister");
4705 assert!(
4706 !handle.monitor_task.is_finished(),
4707 "monitor task should still be alive — server is process-lifetime"
4708 );
4709 }
4710
4711 {
4713 let guard = registry.inner.lock().expect("lock");
4714 assert!(
4715 guard.get(&key).is_some(),
4716 "entry should remain in registry — server kept alive for restart"
4717 );
4718 }
4719 }
4720
4721 #[tokio::test]
4726 async fn test_dispatch_handler_returns_404_for_unknown_path() {
4727 let registry = HttpRouteRegistry::new();
4728 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4730 let port = listener.local_addr().unwrap().port();
4731 tokio::spawn(run_axum_server(
4732 listener,
4733 registry,
4734 2 * 1024 * 1024,
4735 10 * 1024 * 1024,
4736 Arc::new(tokio::sync::Semaphore::new(1024)),
4737 test_rt(),
4738 "test-route".into(),
4739 ));
4740
4741 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4743
4744 let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
4745 .await
4746 .unwrap();
4747 assert_eq!(resp.status().as_u16(), 404);
4748 }
4749
4750 #[tokio::test]
4755 async fn test_http_consumer_start_registers_path() {
4756 use camel_component_api::ConsumerContext;
4757
4758 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4760 let port = listener.local_addr().unwrap().port();
4761 drop(listener); let consumer_cfg = HttpServerConfig {
4764 scheme: "http".to_string(),
4765 host: "127.0.0.1".to_string(),
4766 port,
4767 path: "/ping".to_string(),
4768 max_request_body: 2 * 1024 * 1024,
4769 max_response_body: 10 * 1024 * 1024,
4770 max_inflight_requests: 1024,
4771 method: None,
4772 tls_config: None,
4773 };
4774 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4775
4776 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
4777 let token = tokio_util::sync::CancellationToken::new();
4778 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4779
4780 tokio::spawn(async move {
4781 consumer.start(ctx).await.unwrap();
4782 });
4783
4784 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4785
4786 let client = reqwest::Client::new();
4787 let resp_future = client
4788 .post(format!("http://127.0.0.1:{port}/ping"))
4789 .body("hello world")
4790 .send();
4791
4792 let (http_result, _) = tokio::join!(resp_future, async {
4793 if let Some(mut envelope) = rx.recv().await {
4794 envelope.exchange.input.set_header(
4796 "CamelHttpResponseCode",
4797 serde_json::Value::Number(201.into()),
4798 );
4799 if let Some(reply_tx) = envelope.reply_tx {
4800 let _ = reply_tx.send(Ok(envelope.exchange));
4801 }
4802 }
4803 });
4804
4805 let resp = http_result.unwrap();
4806 assert_eq!(resp.status().as_u16(), 201);
4807
4808 token.cancel();
4809 }
4810
4811 #[test]
4815 fn test_envelope_channel_capacity_follows_max_inflight() {
4816 assert_eq!(envelope_channel_capacity(0), 1);
4817 assert_eq!(envelope_channel_capacity(1), 1);
4818 assert_eq!(envelope_channel_capacity(7), 7);
4819 assert_eq!(envelope_channel_capacity(64), 64);
4820 assert_eq!(envelope_channel_capacity(1024), 1024);
4821 }
4822
4823 #[tokio::test]
4827 async fn test_http_consumer_start_with_zero_max_inflight_rejects_503() {
4828 use camel_component_api::ConsumerContext;
4829
4830 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4831 let port = listener.local_addr().unwrap().port();
4832 drop(listener);
4833
4834 let consumer_cfg = HttpServerConfig {
4835 scheme: "http".to_string(),
4836 host: "127.0.0.1".to_string(),
4837 port,
4838 path: "/ping".to_string(),
4839 max_request_body: 2 * 1024 * 1024,
4840 max_response_body: 10 * 1024 * 1024,
4841 max_inflight_requests: 0,
4842 method: None,
4843 tls_config: None,
4844 };
4845 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4846
4847 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
4848 let token = tokio_util::sync::CancellationToken::new();
4849 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4850
4851 let start_handle = tokio::spawn(async move {
4852 consumer.start(ctx).await.unwrap();
4853 });
4854
4855 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4856
4857 let client = reqwest::Client::new();
4858 let resp = client
4859 .post(format!("http://127.0.0.1:{port}/ping"))
4860 .body("hello world")
4861 .send()
4862 .await
4863 .unwrap();
4864 assert_eq!(resp.status().as_u16(), 503);
4865
4866 token.cancel();
4867 let _ = start_handle.await;
4868 }
4869
4870 #[test]
4873 fn test_http_consumer_startup_mode_is_explicit() {
4874 use camel_component_api::ConsumerStartupMode;
4875 let consumer_cfg = HttpServerConfig {
4876 scheme: "http".to_string(),
4877 host: "127.0.0.1".to_string(),
4878 port: 0,
4879 path: "/x".to_string(),
4880 max_request_body: 2 * 1024 * 1024,
4881 max_response_body: 10 * 1024 * 1024,
4882 max_inflight_requests: 1024,
4883 method: None,
4884 tls_config: None,
4885 };
4886 let consumer = HttpConsumer::new(consumer_cfg, test_rt());
4887 assert_eq!(
4888 consumer.startup_mode(),
4889 ConsumerStartupMode::Explicit,
4890 "HttpConsumer must opt into Explicit startup"
4891 );
4892 }
4893
4894 #[allow(clippy::await_holding_lock)]
4900 #[tokio::test]
4901 async fn test_http_consumer_emits_mark_ready_after_bind() {
4902 use camel_component_api::{ConsumerContext, StartupSignal};
4903
4904 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4905
4906 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4907 let port = listener.local_addr().unwrap().port();
4908 drop(listener);
4909
4910 let consumer_cfg = HttpServerConfig {
4911 scheme: "http".to_string(),
4912 host: "127.0.0.1".to_string(),
4913 port,
4914 path: "/ready-probe".to_string(),
4915 max_request_body: 2 * 1024 * 1024,
4916 max_response_body: 10 * 1024 * 1024,
4917 max_inflight_requests: 1024,
4918 method: None,
4919 tls_config: None,
4920 };
4921 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4922
4923 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
4924 let token = tokio_util::sync::CancellationToken::new();
4925 let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
4926
4927 let (signal, startup_rx) = StartupSignal::pair();
4929 let ctx = ctx.with_startup(signal);
4930
4931 tokio::spawn(async move {
4934 let _ = consumer.start(ctx).await;
4935 });
4936
4937 let result =
4942 tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
4943 .await
4944 .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
4945 assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
4946
4947 token.cancel();
4949 }
4950
4951 #[tokio::test]
4952 async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
4953 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4954
4955 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4956 let port = listener.local_addr().unwrap().port();
4957 drop(listener);
4958
4959 let consumer_cfg = HttpServerConfig {
4960 scheme: "http".to_string(),
4961 host: "127.0.0.1".to_string(),
4962 port,
4963 path: "/saturation".to_string(),
4964 max_request_body: 2 * 1024 * 1024,
4965 max_response_body: 10 * 1024 * 1024,
4966 max_inflight_requests: 1,
4967 method: None,
4968 tls_config: None,
4969 };
4970 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4971
4972 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4973 let token = tokio_util::sync::CancellationToken::new();
4974 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4975 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4976 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4977
4978 let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
4979 let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
4980
4981 tokio::spawn(async move {
4982 let mut first_seen_tx = Some(first_seen_tx);
4983 let mut unblock_first_rx = Some(unblock_first_rx);
4984
4985 while let Some(envelope) = rx.recv().await {
4986 if let Some(tx) = first_seen_tx.take() {
4987 let _ = tx.send(());
4988 if let Some(rx_unblock) = unblock_first_rx.take() {
4989 let _ = rx_unblock.await;
4990 }
4991 }
4992
4993 if let Some(reply_tx) = envelope.reply_tx {
4994 let _ = reply_tx.send(Ok(envelope.exchange));
4995 }
4996 }
4997 });
4998
4999 let client = reqwest::Client::new();
5000 let first_req = {
5001 let client = client.clone();
5002 async move {
5003 client
5004 .get(format!("http://127.0.0.1:{port}/saturation"))
5005 .send()
5006 .await
5007 .unwrap()
5008 }
5009 };
5010
5011 let first_handle = tokio::spawn(first_req);
5012 first_seen_rx.await.unwrap();
5013
5014 let second_resp = client
5015 .get(format!("http://127.0.0.1:{port}/saturation"))
5016 .send()
5017 .await
5018 .unwrap();
5019
5020 assert_eq!(second_resp.status().as_u16(), 503);
5021
5022 let _ = unblock_first_tx.send(());
5023 let first_resp = first_handle.await.unwrap();
5024 assert_eq!(first_resp.status().as_u16(), 200);
5025
5026 token.cancel();
5027 }
5028
5029 #[tokio::test]
5030 #[allow(clippy::await_holding_lock)]
5031 async fn test_http_consumer_enforces_max_response_body_for_bytes() {
5032 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5033
5034 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5035
5036 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5037 let port = listener.local_addr().unwrap().port();
5038 drop(listener);
5039
5040 let consumer_cfg = HttpServerConfig {
5041 scheme: "http".to_string(),
5042 host: "127.0.0.1".to_string(),
5043 port,
5044 path: "/limit-bytes".to_string(),
5045 max_request_body: 2 * 1024 * 1024,
5046 max_response_body: 16,
5047 max_inflight_requests: 1024,
5048 method: None,
5049 tls_config: None,
5050 };
5051 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5052
5053 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5054 let token = tokio_util::sync::CancellationToken::new();
5055 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5056 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5057 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5058
5059 let client = reqwest::Client::new();
5060 let send_fut = client
5061 .get(format!("http://127.0.0.1:{port}/limit-bytes"))
5062 .send();
5063
5064 let (http_result, _) = tokio::join!(send_fut, async {
5065 if let Some(mut envelope) = rx.recv().await {
5066 envelope.exchange.input.body =
5067 camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
5068 if let Some(reply_tx) = envelope.reply_tx {
5069 let _ = reply_tx.send(Ok(envelope.exchange));
5070 }
5071 }
5072 });
5073
5074 let resp = http_result.unwrap();
5075 assert_eq!(resp.status().as_u16(), 500);
5076 let body = resp.text().await.unwrap();
5077 assert_eq!(body, "Response body exceeds configured limit");
5078 token.cancel();
5079 }
5080
5081 #[tokio::test]
5082 #[allow(clippy::await_holding_lock)]
5083 async fn test_http_consumer_enforces_max_response_body_for_json() {
5084 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5085
5086 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5087
5088 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5089 let port = listener.local_addr().unwrap().port();
5090 drop(listener);
5091
5092 let consumer_cfg = HttpServerConfig {
5093 scheme: "http".to_string(),
5094 host: "127.0.0.1".to_string(),
5095 port,
5096 path: "/limit-json".to_string(),
5097 max_request_body: 2 * 1024 * 1024,
5098 max_response_body: 16,
5099 max_inflight_requests: 1024,
5100 method: None,
5101 tls_config: None,
5102 };
5103 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5104
5105 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5106 let token = tokio_util::sync::CancellationToken::new();
5107 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5108 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5109 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5110
5111 let client = reqwest::Client::new();
5112 let send_fut = client
5113 .get(format!("http://127.0.0.1:{port}/limit-json"))
5114 .send();
5115
5116 let (http_result, _) = tokio::join!(send_fut, async {
5117 if let Some(mut envelope) = rx.recv().await {
5118 envelope.exchange.input.body = camel_component_api::Body::Json(
5119 serde_json::json!({"message":"this response is bigger than sixteen"}),
5120 );
5121 if let Some(reply_tx) = envelope.reply_tx {
5122 let _ = reply_tx.send(Ok(envelope.exchange));
5123 }
5124 }
5125 });
5126
5127 let resp = http_result.unwrap();
5128 assert_eq!(resp.status().as_u16(), 500);
5129 let body = resp.text().await.unwrap();
5130 assert_eq!(body, "Response body exceeds configured limit");
5131 token.cancel();
5132 }
5133
5134 #[tokio::test]
5135 #[allow(clippy::await_holding_lock)]
5136 async fn test_http_consumer_enforces_max_response_body_for_xml() {
5137 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5138
5139 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5140
5141 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5142 let port = listener.local_addr().unwrap().port();
5143 drop(listener);
5144
5145 let consumer_cfg = HttpServerConfig {
5146 scheme: "http".to_string(),
5147 host: "127.0.0.1".to_string(),
5148 port,
5149 path: "/limit-xml".to_string(),
5150 max_request_body: 2 * 1024 * 1024,
5151 max_response_body: 16,
5152 max_inflight_requests: 1024,
5153 method: None,
5154 tls_config: None,
5155 };
5156 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5157
5158 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5159 let token = tokio_util::sync::CancellationToken::new();
5160 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5161 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5162 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5163
5164 let client = reqwest::Client::new();
5165 let send_fut = client
5166 .get(format!("http://127.0.0.1:{port}/limit-xml"))
5167 .send();
5168
5169 let (http_result, _) = tokio::join!(send_fut, async {
5170 if let Some(mut envelope) = rx.recv().await {
5171 envelope.exchange.input.body = camel_component_api::Body::Xml(
5172 "<root><value>way-too-large</value></root>".into(),
5173 );
5174 if let Some(reply_tx) = envelope.reply_tx {
5175 let _ = reply_tx.send(Ok(envelope.exchange));
5176 }
5177 }
5178 });
5179
5180 let resp = http_result.unwrap();
5181 assert_eq!(resp.status().as_u16(), 500);
5182 let body = resp.text().await.unwrap();
5183 assert_eq!(body, "Response body exceeds configured limit");
5184 token.cancel();
5185 }
5186
5187 #[tokio::test]
5188 #[allow(clippy::await_holding_lock)]
5189 async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
5190 use camel_component_api::{
5191 CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
5192 };
5193 use futures::stream;
5194
5195 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5196
5197 let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
5198 let port = listener.local_addr().unwrap().port();
5199 drop(listener);
5200
5201 let consumer_cfg = HttpServerConfig {
5202 scheme: "http".to_string(),
5203 host: "0.0.0.0".to_string(),
5204 port,
5205 path: "/limit-stream".to_string(),
5206 max_request_body: 2 * 1024 * 1024,
5207 max_response_body: 16,
5208 max_inflight_requests: 1024,
5209 method: None,
5210 tls_config: None,
5211 };
5212 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5213
5214 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5215 let token = tokio_util::sync::CancellationToken::new();
5216 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5217 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5218 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5219
5220 let client = reqwest::Client::new();
5221 let send_fut = client
5222 .get(format!("http://127.0.0.1:{port}/limit-stream"))
5223 .send();
5224
5225 let (http_result, _) = tokio::join!(send_fut, async {
5226 if let Some(mut envelope) = rx.recv().await {
5227 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
5228 vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
5229 let stream = Box::pin(stream::iter(chunks));
5230 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
5231 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
5232 metadata: StreamMetadata {
5233 size_hint: Some(32),
5234 content_type: Some("application/octet-stream".into()),
5235 origin: None,
5236 },
5237 });
5238 if let Some(reply_tx) = envelope.reply_tx {
5239 let _ = reply_tx.send(Ok(envelope.exchange));
5240 }
5241 }
5242 });
5243
5244 let resp = http_result.unwrap();
5245 assert_eq!(resp.status().as_u16(), 200);
5246 let body = resp.bytes().await.unwrap();
5247 assert_eq!(body.len(), 32);
5248 token.cancel();
5249 }
5250
5251 #[tokio::test]
5256 #[allow(clippy::await_holding_lock)]
5257 async fn test_integration_single_consumer_round_trip() {
5258 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5259
5260 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5264
5265 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5267 let port = listener.local_addr().unwrap().port();
5268 drop(listener); let component = HttpComponent::new();
5271 let endpoint_ctx = NoOpComponentContext;
5272 let endpoint = component
5273 .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
5274 .unwrap();
5275 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5276
5277 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5278 let token = tokio_util::sync::CancellationToken::new();
5279 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5280
5281 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5282 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5283
5284 let client = reqwest::Client::new();
5285 let send_fut = client
5286 .post(format!("http://127.0.0.1:{port}/echo"))
5287 .header("Content-Type", "text/plain")
5288 .body("ping")
5289 .send();
5290
5291 let (http_result, _) = tokio::join!(send_fut, async {
5292 if let Some(mut envelope) = rx.recv().await {
5293 assert_eq!(
5294 envelope.exchange.input.header("CamelHttpMethod"),
5295 Some(&serde_json::Value::String("POST".into()))
5296 );
5297 assert_eq!(
5298 envelope.exchange.input.header("CamelHttpPath"),
5299 Some(&serde_json::Value::String("/echo".into()))
5300 );
5301 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
5302 if let Some(reply_tx) = envelope.reply_tx {
5303 let _ = reply_tx.send(Ok(envelope.exchange));
5304 }
5305 }
5306 });
5307
5308 let resp = http_result.unwrap();
5309 assert_eq!(resp.status().as_u16(), 200);
5310 let body = resp.text().await.unwrap();
5311 assert_eq!(body, "pong");
5312
5313 token.cancel();
5314 }
5315
5316 #[tokio::test]
5317 #[allow(clippy::await_holding_lock)]
5318 async fn test_integration_two_consumers_shared_port() {
5319 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5320
5321 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5322
5323 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5325 let port = listener.local_addr().unwrap().port();
5326 drop(listener);
5327
5328 let component = HttpComponent::new();
5329 let endpoint_ctx = NoOpComponentContext;
5330
5331 let endpoint_a = component
5333 .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
5334 .unwrap();
5335 let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
5336
5337 let endpoint_b = component
5339 .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
5340 .unwrap();
5341 let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
5342
5343 let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5344 let token_a = tokio_util::sync::CancellationToken::new();
5345 let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
5346
5347 let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5348 let token_b = tokio_util::sync::CancellationToken::new();
5349 let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
5350
5351 tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
5352 tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
5353 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5354
5355 let client = reqwest::Client::new();
5356
5357 let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
5359 let (resp_hello, _) = tokio::join!(fut_hello, async {
5360 if let Some(mut envelope) = rx_a.recv().await {
5361 envelope.exchange.input.body =
5362 camel_component_api::Body::Text("hello-response".to_string());
5363 if let Some(reply_tx) = envelope.reply_tx {
5364 let _ = reply_tx.send(Ok(envelope.exchange));
5365 }
5366 }
5367 });
5368
5369 let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
5371 let (resp_world, _) = tokio::join!(fut_world, async {
5372 if let Some(mut envelope) = rx_b.recv().await {
5373 envelope.exchange.input.body =
5374 camel_component_api::Body::Text("world-response".to_string());
5375 if let Some(reply_tx) = envelope.reply_tx {
5376 let _ = reply_tx.send(Ok(envelope.exchange));
5377 }
5378 }
5379 });
5380
5381 let body_a = resp_hello.unwrap().text().await.unwrap();
5382 let body_b = resp_world.unwrap().text().await.unwrap();
5383
5384 assert_eq!(body_a, "hello-response");
5385 assert_eq!(body_b, "world-response");
5386
5387 token_a.cancel();
5388 token_b.cancel();
5389 }
5390
5391 #[tokio::test]
5392 #[allow(clippy::await_holding_lock)]
5393 async fn test_integration_unregistered_path_returns_404() {
5394 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5395
5396 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5397
5398 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5400 let port = listener.local_addr().unwrap().port();
5401 drop(listener);
5402
5403 let component = HttpComponent::new();
5404 let endpoint_ctx = NoOpComponentContext;
5405 let endpoint = component
5406 .create_endpoint(
5407 &format!("http://127.0.0.1:{port}/registered"),
5408 &endpoint_ctx,
5409 )
5410 .unwrap();
5411 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5412
5413 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5414 let token = tokio_util::sync::CancellationToken::new();
5415 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5416
5417 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5418
5419 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
5421 loop {
5422 if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
5423 .await
5424 .is_ok()
5425 {
5426 break;
5427 }
5428 if std::time::Instant::now() >= deadline {
5429 panic!("HTTP server did not start within 5s on port {port}");
5430 }
5431 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
5432 }
5433
5434 let client = reqwest::Client::new();
5435 let resp = client
5436 .get(format!("http://127.0.0.1:{port}/not-there"))
5437 .send()
5438 .await
5439 .unwrap();
5440 assert_eq!(resp.status().as_u16(), 404);
5441
5442 token.cancel();
5443 }
5444
5445 #[test]
5446 fn test_http_consumer_declares_concurrent() {
5447 use camel_component_api::ConcurrencyModel;
5448
5449 let config = HttpServerConfig {
5450 scheme: "http".to_string(),
5451 host: "127.0.0.1".to_string(),
5452 port: 19999,
5453 path: "/test".to_string(),
5454 max_request_body: 2 * 1024 * 1024,
5455 max_response_body: 10 * 1024 * 1024,
5456 max_inflight_requests: 1024,
5457 method: None,
5458 tls_config: None,
5459 };
5460 let consumer = HttpConsumer::new(config, test_rt());
5461 assert_eq!(
5462 consumer.concurrency_model(),
5463 ConcurrencyModel::Concurrent { max: None }
5464 );
5465 }
5466
5467 #[test]
5468 fn server_config_parses_tls_cert_and_key() {
5469 let cfg = HttpServerConfig::from_uri(
5470 "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
5471 )
5472 .unwrap();
5473 assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
5474 assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
5475 }
5476
5477 #[test]
5478 fn server_config_no_tls_when_params_absent() {
5479 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
5480 assert!(cfg.tls_config.is_none());
5481 }
5482
5483 #[tokio::test]
5488 async fn test_http_reply_body_stream_variant_exists() {
5489 use bytes::Bytes;
5490 use camel_component_api::CamelError;
5491 use futures::stream;
5492
5493 let chunks: Vec<Result<Bytes, CamelError>> =
5494 vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
5495 let stream = Box::pin(stream::iter(chunks));
5496 let reply_body = HttpReplyBody::Stream(stream);
5497 match reply_body {
5499 HttpReplyBody::Stream(_) => {}
5500 HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
5501 }
5502 }
5503
5504 #[cfg(feature = "otel")]
5509 mod otel_tests {
5510 use super::*;
5511 use camel_component_api::Message;
5512 use tower::ServiceExt;
5513
5514 #[tokio::test]
5515 async fn test_producer_injects_traceparent_header() {
5516 let (url, _handle) = start_test_server_with_header_capture().await;
5517 let ctx = test_producer_ctx();
5518
5519 let component = HttpComponent::new();
5520 let endpoint_ctx = NoOpComponentContext;
5521 let endpoint = component
5522 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5523 .unwrap();
5524 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5525
5526 let mut exchange = Exchange::new(Message::default());
5528 let mut headers = std::collections::HashMap::new();
5529 headers.insert(
5530 "traceparent".to_string(),
5531 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
5532 );
5533 camel_otel::extract_into_exchange(&mut exchange, &headers);
5534
5535 let result = producer.oneshot(exchange).await.unwrap();
5536
5537 let status = result
5539 .input
5540 .header("CamelHttpResponseCode")
5541 .and_then(|v| v.as_u64())
5542 .unwrap();
5543 assert_eq!(status, 200);
5544
5545 let traceparent = result.input.header("X-Received-Traceparent");
5547 assert!(
5548 traceparent.is_some(),
5549 "traceparent header should have been sent"
5550 );
5551
5552 let traceparent_str = traceparent.unwrap().as_str().unwrap();
5553 let parts: Vec<&str> = traceparent_str.split('-').collect();
5555 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
5556 assert_eq!(parts[0], "00", "version should be 00");
5557 assert_eq!(
5558 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
5559 "trace-id should match"
5560 );
5561 assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
5562 assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
5563 }
5564
5565 #[tokio::test]
5566 async fn test_consumer_extracts_traceparent_header() {
5567 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5568
5569 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5571 let port = listener.local_addr().unwrap().port();
5572 drop(listener);
5573
5574 let component = HttpComponent::new();
5575 let endpoint_ctx = NoOpComponentContext;
5576 let endpoint = component
5577 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
5578 .unwrap();
5579 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5580
5581 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5582 let token = tokio_util::sync::CancellationToken::new();
5583 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5584
5585 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5586 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5587
5588 let client = reqwest::Client::new();
5590 let send_fut = client
5591 .post(format!("http://127.0.0.1:{port}/trace"))
5592 .header(
5593 "traceparent",
5594 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
5595 )
5596 .body("test")
5597 .send();
5598
5599 let (http_result, _) = tokio::join!(send_fut, async {
5600 if let Some(envelope) = rx.recv().await {
5601 let mut injected_headers = std::collections::HashMap::new();
5604 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
5605
5606 assert!(
5607 injected_headers.contains_key("traceparent"),
5608 "Exchange should have traceparent after extraction"
5609 );
5610
5611 let traceparent = injected_headers.get("traceparent").unwrap();
5612 let parts: Vec<&str> = traceparent.split('-').collect();
5613 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
5614 assert_eq!(
5615 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
5616 "Trace ID should match the original traceparent header"
5617 );
5618
5619 if let Some(reply_tx) = envelope.reply_tx {
5620 let _ = reply_tx.send(Ok(envelope.exchange));
5621 }
5622 }
5623 });
5624
5625 let resp = http_result.unwrap();
5626 assert_eq!(resp.status().as_u16(), 200);
5627
5628 token.cancel();
5629 }
5630
5631 #[tokio::test]
5632 async fn test_consumer_extracts_mixed_case_traceparent_header() {
5633 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5634
5635 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5637 let port = listener.local_addr().unwrap().port();
5638 drop(listener);
5639
5640 let component = HttpComponent::new();
5641 let endpoint_ctx = NoOpComponentContext;
5642 let endpoint = component
5643 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
5644 .unwrap();
5645 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5646
5647 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5648 let token = tokio_util::sync::CancellationToken::new();
5649 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5650
5651 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5652 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5653
5654 let client = reqwest::Client::new();
5656 let send_fut = client
5657 .post(format!("http://127.0.0.1:{port}/trace"))
5658 .header(
5659 "TraceParent",
5660 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
5661 )
5662 .body("test")
5663 .send();
5664
5665 let (http_result, _) = tokio::join!(send_fut, async {
5666 if let Some(envelope) = rx.recv().await {
5667 let mut injected_headers = HashMap::new();
5670 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
5671
5672 assert!(
5673 injected_headers.contains_key("traceparent"),
5674 "Exchange should have traceparent after extraction from mixed-case header"
5675 );
5676
5677 let traceparent = injected_headers.get("traceparent").unwrap();
5678 let parts: Vec<&str> = traceparent.split('-').collect();
5679 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
5680 assert_eq!(
5681 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
5682 "Trace ID should match the original mixed-case TraceParent header"
5683 );
5684
5685 if let Some(reply_tx) = envelope.reply_tx {
5686 let _ = reply_tx.send(Ok(envelope.exchange));
5687 }
5688 }
5689 });
5690
5691 let resp = http_result.unwrap();
5692 assert_eq!(resp.status().as_u16(), 200);
5693
5694 token.cancel();
5695 }
5696
5697 #[tokio::test]
5698 async fn test_producer_no_trace_context_no_crash() {
5699 let (url, _handle) = start_test_server().await;
5700 let ctx = test_producer_ctx();
5701
5702 let component = HttpComponent::new();
5703 let endpoint_ctx = NoOpComponentContext;
5704 let endpoint = component
5705 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
5706 .unwrap();
5707 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
5708
5709 let exchange = Exchange::new(Message::default());
5711
5712 let result = producer.oneshot(exchange).await.unwrap();
5714
5715 let status = result
5717 .input
5718 .header("CamelHttpResponseCode")
5719 .and_then(|v| v.as_u64())
5720 .unwrap();
5721 assert_eq!(status, 200);
5722 }
5723
5724 async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
5726 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5727 let addr = listener.local_addr().unwrap();
5728 let url = format!("http://127.0.0.1:{}", addr.port());
5729
5730 let handle = tokio::spawn(async move {
5731 loop {
5732 if let Ok((mut stream, _)) = listener.accept().await {
5733 tokio::spawn(async move {
5734 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5735 let mut buf = vec![0u8; 8192];
5736 let n = stream.read(&mut buf).await.unwrap_or(0);
5737 let request = String::from_utf8_lossy(&buf[..n]).to_string();
5738
5739 let traceparent = request
5741 .lines()
5742 .find(|line| line.to_lowercase().starts_with("traceparent:"))
5743 .map(|line| {
5744 line.split(':')
5745 .nth(1)
5746 .map(|s| s.trim().to_string())
5747 .unwrap_or_default()
5748 })
5749 .unwrap_or_default();
5750
5751 let body =
5752 format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
5753 let response = format!(
5754 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
5755 body.len(),
5756 traceparent,
5757 body
5758 );
5759 let _ = stream.write_all(response.as_bytes()).await;
5760 });
5761 }
5762 }
5763 });
5764
5765 (url, handle)
5766 }
5767 }
5768
5769 #[tokio::test]
5778 async fn test_request_body_arrives_as_stream() {
5779 use camel_component_api::Body;
5780 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5781
5782 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5783 let port = listener.local_addr().unwrap().port();
5784 drop(listener);
5785
5786 let component = HttpComponent::new();
5787 let endpoint_ctx = NoOpComponentContext;
5788 let endpoint = component
5789 .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
5790 .unwrap();
5791 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5792
5793 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5794 let token = tokio_util::sync::CancellationToken::new();
5795 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5796
5797 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5798 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5799
5800 let client = reqwest::Client::new();
5801 let send_fut = client
5802 .post(format!("http://127.0.0.1:{port}/upload"))
5803 .body("hello streaming world")
5804 .send();
5805
5806 let (http_result, _) = tokio::join!(send_fut, async {
5807 if let Some(mut envelope) = rx.recv().await {
5808 assert!(
5810 matches!(envelope.exchange.input.body, Body::Stream(_)),
5811 "expected Body::Stream, got discriminant {:?}",
5812 std::mem::discriminant(&envelope.exchange.input.body)
5813 );
5814 let bytes = envelope
5816 .exchange
5817 .input
5818 .body
5819 .into_bytes(1024 * 1024)
5820 .await
5821 .unwrap();
5822 assert_eq!(&bytes[..], b"hello streaming world");
5823
5824 envelope.exchange.input.body = camel_component_api::Body::Empty;
5825 if let Some(reply_tx) = envelope.reply_tx {
5826 let _ = reply_tx.send(Ok(envelope.exchange));
5827 }
5828 }
5829 });
5830
5831 let resp = http_result.unwrap();
5832 assert_eq!(resp.status().as_u16(), 200);
5833
5834 token.cancel();
5835 }
5836
5837 #[tokio::test]
5842 async fn test_streaming_response_chunked() {
5843 use bytes::Bytes;
5844 use camel_component_api::Body;
5845 use camel_component_api::CamelError;
5846 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5847 use camel_component_api::{StreamBody, StreamMetadata};
5848 use futures::stream;
5849 use std::sync::Arc;
5850 use tokio::sync::Mutex;
5851
5852 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5853 let port = listener.local_addr().unwrap().port();
5854 drop(listener);
5855
5856 let component = HttpComponent::new();
5857 let endpoint_ctx = NoOpComponentContext;
5858 let endpoint = component
5859 .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
5860 .unwrap();
5861 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5862
5863 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5864 let token = tokio_util::sync::CancellationToken::new();
5865 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5866
5867 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5868 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5869
5870 let client = reqwest::Client::new();
5871 let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
5872
5873 let (http_result, _) = tokio::join!(send_fut, async {
5874 if let Some(mut envelope) = rx.recv().await {
5875 let chunks: Vec<Result<Bytes, CamelError>> =
5877 vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
5878 let stream = Box::pin(stream::iter(chunks));
5879 envelope.exchange.input.body = Body::Stream(StreamBody {
5880 stream: Arc::new(Mutex::new(Some(stream))),
5881 metadata: StreamMetadata::default(),
5882 });
5883 if let Some(reply_tx) = envelope.reply_tx {
5884 let _ = reply_tx.send(Ok(envelope.exchange));
5885 }
5886 }
5887 });
5888
5889 let resp = http_result.unwrap();
5890 assert_eq!(resp.status().as_u16(), 200);
5891 let body = resp.text().await.unwrap();
5892 assert_eq!(body, "chunk1chunk2");
5893
5894 token.cancel();
5895 }
5896
5897 #[tokio::test]
5902 async fn test_413_when_content_length_exceeds_limit() {
5903 use camel_component_api::ConsumerContext;
5904
5905 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5906 let port = listener.local_addr().unwrap().port();
5907 drop(listener);
5908
5909 let component = HttpComponent::new();
5911 let endpoint_ctx = NoOpComponentContext;
5912 let endpoint = component
5913 .create_endpoint(
5914 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
5915 &endpoint_ctx,
5916 )
5917 .unwrap();
5918 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5919
5920 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
5921 let token = tokio_util::sync::CancellationToken::new();
5922 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5923
5924 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5925 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5926
5927 let client = reqwest::Client::new();
5928 let resp = client
5929 .post(format!("http://127.0.0.1:{port}/upload"))
5930 .header("Content-Length", "1000") .body("x".repeat(1000))
5932 .send()
5933 .await
5934 .unwrap();
5935
5936 assert_eq!(resp.status().as_u16(), 413);
5937
5938 token.cancel();
5939 }
5940
5941 #[tokio::test]
5945 async fn test_chunked_upload_without_content_length_bypasses_limit() {
5946 use bytes::Bytes;
5947 use camel_component_api::Body;
5948 use camel_component_api::ConsumerContext;
5949 use futures::stream;
5950
5951 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5952 let port = listener.local_addr().unwrap().port();
5953 drop(listener);
5954
5955 let component = HttpComponent::new();
5957 let endpoint_ctx = NoOpComponentContext;
5958 let endpoint = component
5959 .create_endpoint(
5960 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
5961 &endpoint_ctx,
5962 )
5963 .unwrap();
5964 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5965
5966 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
5967 let token = tokio_util::sync::CancellationToken::new();
5968 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5969
5970 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5971 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5972
5973 let client = reqwest::Client::new();
5974
5975 let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
5979 Ok(Bytes::from("y".repeat(50))),
5980 Ok(Bytes::from("y".repeat(50))),
5981 ];
5982 let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
5983 let send_fut = client
5984 .post(format!("http://127.0.0.1:{port}/upload"))
5985 .body(stream_body)
5986 .send();
5987
5988 let consumer_fut = async {
5989 match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
5991 Ok(Some(mut envelope)) => {
5992 assert!(
5993 matches!(envelope.exchange.input.body, Body::Stream(_)),
5994 "expected Body::Stream"
5995 );
5996 envelope.exchange.input.body = camel_component_api::Body::Empty;
5997 if let Some(reply_tx) = envelope.reply_tx {
5998 let _ = reply_tx.send(Ok(envelope.exchange));
5999 }
6000 }
6001 Ok(None) => panic!("consumer channel closed unexpectedly"),
6002 Err(_) => {
6003 }
6006 }
6007 };
6008
6009 let (http_result, _) = tokio::join!(send_fut, consumer_fut);
6010
6011 let resp = http_result.unwrap();
6012 assert_ne!(
6014 resp.status().as_u16(),
6015 413,
6016 "chunked upload must not be rejected by maxRequestBody"
6017 );
6018 assert_eq!(resp.status().as_u16(), 200);
6019
6020 token.cancel();
6021 }
6022
6023 #[test]
6024 fn test_is_private_ip_ranges() {
6025 use camel_api::is_ssrf_blocked_ip;
6026 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(
6045 &"2001:4860:4860::8888".parse().unwrap()
6046 )); }
6048
6049 #[test]
6050 fn test_title_case_header() {
6051 assert_eq!(title_case_header("content-type"), "Content-Type");
6052 assert_eq!(title_case_header("authorization"), "Authorization");
6053 assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
6054 assert_eq!(title_case_header("host"), "Host");
6055 assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
6056 assert_eq!(title_case_header("single"), "Single");
6057 assert_eq!(title_case_header(""), "");
6058 }
6059
6060 #[test]
6061 fn test_resolve_url_combines_path_and_query_sources() {
6062 let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
6063 let mut exchange = Exchange::new(Message::default());
6064 exchange.input.set_header(
6065 "CamelHttpPath",
6066 serde_json::Value::String("next".to_string()),
6067 );
6068 let url = HttpProducer::resolve_url(&exchange, &cfg);
6069 assert!(url.starts_with("http://example.com/base/next?"));
6070 assert!(url.contains("foo=bar"));
6071
6072 exchange.input.set_header(
6073 "CamelHttpUri",
6074 serde_json::Value::String("http://other.test/root".to_string()),
6075 );
6076 exchange.input.set_header(
6077 "CamelHttpQuery",
6078 serde_json::Value::String("a=1&b=2".to_string()),
6079 );
6080
6081 let override_url = HttpProducer::resolve_url(&exchange, &cfg);
6082 assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
6083 }
6084
6085 fn exchange_with_path_and_query(path: &str, query: &str) -> Exchange {
6086 let mut exchange = Exchange::new(Message::default());
6087 exchange
6088 .input
6089 .set_header("CamelHttpPath", serde_json::Value::String(path.to_string()));
6090 exchange.input.set_header(
6091 "CamelHttpQuery",
6092 serde_json::Value::String(query.to_string()),
6093 );
6094 exchange
6095 }
6096
6097 #[test]
6098 fn resolve_url_bridge_endpoint_true_ignores_exchange_path() {
6099 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6100 cfg.bridge_endpoint = true;
6101 cfg.query_params
6102 .insert("token".to_string(), "secret".to_string());
6103 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
6104 let url = HttpProducer::resolve_url(&exchange, &cfg);
6105 assert_eq!(url, "http://x/?token=secret");
6106 assert!(!url.contains("/foo"));
6107 assert!(!url.contains("dropme"));
6108 }
6109
6110 #[test]
6111 fn resolve_url_bridge_endpoint_false_merges_path() {
6112 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6113 cfg.bridge_endpoint = false;
6114 let exchange = exchange_with_path_and_query("/foo", "dropme=1");
6115 let url = HttpProducer::resolve_url(&exchange, &cfg);
6116 assert!(url.contains("/foo"), "url should contain /foo: {url}");
6117 assert!(
6118 url.contains("dropme=1"),
6119 "url should contain dropme=1: {url}"
6120 );
6121 }
6122
6123 #[test]
6124 fn resolve_url_bridge_endpoint_true_keeps_base_when_no_query_params() {
6125 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6126 cfg.bridge_endpoint = true;
6127 let mut exchange = Exchange::new(Message::default());
6128 exchange.input.set_header(
6129 "CamelHttpPath",
6130 serde_json::Value::String("/foo".to_string()),
6131 );
6132 let url = HttpProducer::resolve_url(&exchange, &cfg);
6133 assert_eq!(url, "http://x");
6134 assert!(!url.contains("/foo"));
6135 }
6136
6137 #[test]
6138 fn resolve_url_bridge_endpoint_true_ignores_camel_http_uri() {
6139 let mut cfg = HttpEndpointConfig::from_uri("http://x").unwrap();
6140 cfg.bridge_endpoint = true;
6141 let mut exchange = Exchange::new(Message::default());
6143 exchange.input.set_header(
6144 "CamelHttpUri",
6145 serde_json::Value::String("http://dest/explicit".to_string()),
6146 );
6147 exchange.input.set_header(
6148 "CamelHttpPath",
6149 serde_json::Value::String("/foo".to_string()),
6150 );
6151 exchange.input.set_header(
6152 "CamelHttpQuery",
6153 serde_json::Value::String("x=1".to_string()),
6154 );
6155 let url = HttpProducer::resolve_url(&exchange, &cfg);
6156 assert_eq!(url, "http://x");
6160 }
6161
6162 #[test]
6163 fn test_http_producer_helpers_status_and_size_boundaries() {
6164 assert!(HttpProducer::is_ok_status(200, (200, 299)));
6165 assert!(HttpProducer::is_ok_status(299, (200, 299)));
6166 assert!(!HttpProducer::is_ok_status(199, (200, 299)));
6167 assert!(!HttpProducer::is_ok_status(300, (200, 299)));
6168
6169 assert!(!exceeds_max_response_body(10, 10));
6170 assert!(exceeds_max_response_body(11, 10));
6171 }
6172
6173 async fn setup_consumer_on_free_port(
6178 path: &str,
6179 ) -> (
6180 u16,
6181 tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
6182 tokio_util::sync::CancellationToken,
6183 ) {
6184 use camel_component_api::ConsumerContext;
6185
6186 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6187 let port = listener.local_addr().unwrap().port();
6188 drop(listener);
6189
6190 let consumer_cfg = HttpServerConfig {
6191 scheme: "http".to_string(),
6192 host: "127.0.0.1".to_string(),
6193 port,
6194 path: path.to_string(),
6195 max_request_body: 2 * 1024 * 1024,
6196 max_response_body: 10 * 1024 * 1024,
6197 max_inflight_requests: 1024,
6198 method: None,
6199 tls_config: None,
6200 };
6201 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
6202
6203 let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
6204 let token = tokio_util::sync::CancellationToken::new();
6205 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
6206
6207 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6208 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6209
6210 (port, rx, token)
6211 }
6212
6213 #[tokio::test]
6214 async fn test_content_type_inferred_for_json_body() {
6215 let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
6216
6217 let client = reqwest::Client::new();
6218 let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
6219
6220 let (http_result, _) = tokio::join!(send_fut, async {
6221 if let Some(mut envelope) = rx.recv().await {
6222 envelope.exchange.input.body =
6223 camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
6224 if let Some(reply_tx) = envelope.reply_tx {
6225 let _ = reply_tx.send(Ok(envelope.exchange));
6226 }
6227 }
6228 });
6229
6230 let resp = http_result.unwrap();
6231 assert_eq!(resp.status().as_u16(), 200);
6232 let ct = resp
6233 .headers()
6234 .get("content-type")
6235 .expect("Content-Type header should be present");
6236 assert_eq!(ct, "application/json");
6237 let body = resp.text().await.unwrap();
6238 assert_eq!(body, r#"{"message":"hello"}"#);
6239
6240 token.cancel();
6241 }
6242
6243 #[tokio::test]
6244 async fn test_content_type_inferred_for_text_body() {
6245 let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
6246
6247 let client = reqwest::Client::new();
6248 let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
6249
6250 let (http_result, _) = tokio::join!(send_fut, async {
6251 if let Some(mut envelope) = rx.recv().await {
6252 envelope.exchange.input.body =
6253 camel_component_api::Body::Text("plain text response".to_string());
6254 if let Some(reply_tx) = envelope.reply_tx {
6255 let _ = reply_tx.send(Ok(envelope.exchange));
6256 }
6257 }
6258 });
6259
6260 let resp = http_result.unwrap();
6261 assert_eq!(resp.status().as_u16(), 200);
6262 let ct = resp
6263 .headers()
6264 .get("content-type")
6265 .expect("Content-Type header should be present");
6266 assert_eq!(ct, "text/plain; charset=utf-8");
6267 let body = resp.text().await.unwrap();
6268 assert_eq!(body, "plain text response");
6269
6270 token.cancel();
6271 }
6272
6273 #[tokio::test]
6274 async fn test_content_type_inferred_for_xml_body() {
6275 let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
6276
6277 let client = reqwest::Client::new();
6278 let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
6279
6280 let (http_result, _) = tokio::join!(send_fut, async {
6281 if let Some(mut envelope) = rx.recv().await {
6282 envelope.exchange.input.body =
6283 camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
6284 if let Some(reply_tx) = envelope.reply_tx {
6285 let _ = reply_tx.send(Ok(envelope.exchange));
6286 }
6287 }
6288 });
6289
6290 let resp = http_result.unwrap();
6291 assert_eq!(resp.status().as_u16(), 200);
6292 let ct = resp
6293 .headers()
6294 .get("content-type")
6295 .expect("Content-Type header should be present");
6296 assert_eq!(ct, "application/xml");
6297 let body = resp.text().await.unwrap();
6298 assert_eq!(body, "<root><item>value</item></root>");
6299
6300 token.cancel();
6301 }
6302
6303 #[tokio::test]
6304 async fn test_no_content_type_for_empty_body() {
6305 let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
6306
6307 let client = reqwest::Client::new();
6308 let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
6309
6310 let (http_result, _) = tokio::join!(send_fut, async {
6311 if let Some(mut envelope) = rx.recv().await {
6312 envelope.exchange.input.body = camel_component_api::Body::Empty;
6313 if let Some(reply_tx) = envelope.reply_tx {
6314 let _ = reply_tx.send(Ok(envelope.exchange));
6315 }
6316 }
6317 });
6318
6319 let resp = http_result.unwrap();
6320 assert_eq!(resp.status().as_u16(), 200);
6321 assert!(
6322 resp.headers().get("content-type").is_none(),
6323 "Empty body should not set Content-Type"
6324 );
6325
6326 token.cancel();
6327 }
6328
6329 #[tokio::test]
6330 async fn test_no_content_type_for_raw_bytes_body() {
6331 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
6332
6333 let client = reqwest::Client::new();
6334 let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
6335
6336 let (http_result, _) = tokio::join!(send_fut, async {
6337 if let Some(mut envelope) = rx.recv().await {
6338 envelope.exchange.input.body =
6339 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
6340 if let Some(reply_tx) = envelope.reply_tx {
6341 let _ = reply_tx.send(Ok(envelope.exchange));
6342 }
6343 }
6344 });
6345
6346 let resp = http_result.unwrap();
6347 assert_eq!(resp.status().as_u16(), 200);
6348 assert!(
6349 resp.headers().get("content-type").is_none(),
6350 "Raw Bytes body should not set Content-Type"
6351 );
6352
6353 token.cancel();
6354 }
6355
6356 #[tokio::test]
6357 async fn test_content_type_from_stream_metadata() {
6358 use camel_component_api::{StreamBody, StreamMetadata};
6359 use futures::stream;
6360
6361 let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
6362
6363 let client = reqwest::Client::new();
6364 let send_fut = client
6365 .get(format!("http://127.0.0.1:{port}/stream-ct"))
6366 .send();
6367
6368 let (http_result, _) = tokio::join!(send_fut, async {
6369 if let Some(mut envelope) = rx.recv().await {
6370 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
6371 vec![Ok(bytes::Bytes::from("audio data"))];
6372 let stream = Box::pin(stream::iter(chunks));
6373 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
6374 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
6375 metadata: StreamMetadata {
6376 size_hint: None,
6377 content_type: Some("audio/mpeg".to_string()),
6378 origin: None,
6379 },
6380 });
6381 if let Some(reply_tx) = envelope.reply_tx {
6382 let _ = reply_tx.send(Ok(envelope.exchange));
6383 }
6384 }
6385 });
6386
6387 let resp = http_result.unwrap();
6388 assert_eq!(resp.status().as_u16(), 200);
6389 let ct = resp
6390 .headers()
6391 .get("content-type")
6392 .expect("Content-Type header should be present");
6393 assert_eq!(ct, "audio/mpeg");
6394 let body = resp.text().await.unwrap();
6395 assert_eq!(body, "audio data");
6396
6397 token.cancel();
6398 }
6399
6400 #[tokio::test]
6401 async fn test_user_content_type_overrides_inferred() {
6402 let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
6403
6404 let client = reqwest::Client::new();
6405 let send_fut = client
6406 .get(format!("http://127.0.0.1:{port}/override-ct"))
6407 .send();
6408
6409 let (http_result, _) = tokio::join!(send_fut, async {
6410 if let Some(mut envelope) = rx.recv().await {
6411 envelope.exchange.input.body =
6412 camel_component_api::Body::Json(serde_json::json!({"ok": true}));
6413 envelope.exchange.input.set_header(
6414 "Content-Type",
6415 serde_json::Value::String("text/html".to_string()),
6416 );
6417 if let Some(reply_tx) = envelope.reply_tx {
6418 let _ = reply_tx.send(Ok(envelope.exchange));
6419 }
6420 }
6421 });
6422
6423 let resp = http_result.unwrap();
6424 assert_eq!(resp.status().as_u16(), 200);
6425 let ct = resp
6426 .headers()
6427 .get("content-type")
6428 .expect("Content-Type header should be present");
6429 assert_eq!(
6430 ct, "text/html",
6431 "User-set Content-Type should take precedence over inferred type"
6432 );
6433
6434 token.cancel();
6435 }
6436
6437 #[tokio::test]
6438 async fn test_user_content_type_with_bytes_body() {
6439 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
6440
6441 let client = reqwest::Client::new();
6442 let send_fut = client
6443 .get(format!("http://127.0.0.1:{port}/bytes-ct"))
6444 .send();
6445
6446 let (http_result, _) = tokio::join!(send_fut, async {
6447 if let Some(mut envelope) = rx.recv().await {
6448 envelope.exchange.input.body =
6449 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
6450 envelope.exchange.input.set_header(
6451 "Content-Type",
6452 serde_json::Value::String("application/json".to_string()),
6453 );
6454 if let Some(reply_tx) = envelope.reply_tx {
6455 let _ = reply_tx.send(Ok(envelope.exchange));
6456 }
6457 }
6458 });
6459
6460 let resp = http_result.unwrap();
6461 assert_eq!(resp.status().as_u16(), 200);
6462 let ct = resp
6463 .headers()
6464 .get("content-type")
6465 .expect("Content-Type header should be present for Bytes body with user header");
6466 assert_eq!(
6467 ct, "application/json",
6468 "User Content-Type should be sent for Bytes body"
6469 );
6470
6471 token.cancel();
6472 }
6473
6474 #[tokio::test]
6479 async fn monitor_task_silent_on_clean_exit() {
6480 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
6481 monitor_axum_task(
6483 handle,
6484 "127.0.0.1:0".to_string(),
6485 noop_rt(),
6486 "test-monitor".into(),
6487 )
6488 .await;
6489 }
6490
6491 #[tokio::test]
6492 async fn monitor_task_handles_panicked_task() {
6493 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
6494 panic!("simulated server crash");
6495 });
6496 monitor_axum_task(
6498 handle,
6499 "127.0.0.1:9999".to_string(),
6500 noop_rt(),
6501 "test-monitor".into(),
6502 )
6503 .await;
6504 }
6505
6506 #[test]
6511 fn http_auth_basic_debug_redacts_password() {
6512 let auth = HttpAuth::Basic {
6513 username: "admin".to_string(),
6514 password: "hunter2".to_string(),
6515 };
6516 let debug = format!("{:?}", auth);
6517 assert!(
6518 !debug.contains("hunter2"),
6519 "password must be redacted: {debug}"
6520 );
6521 assert!(debug.contains("admin"), "username should appear: {debug}");
6522 }
6523
6524 #[test]
6525 fn http_auth_bearer_debug_redacts_token() {
6526 let auth = HttpAuth::Bearer {
6527 token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
6528 };
6529 let debug = format!("{:?}", auth);
6530 assert!(
6531 !debug.contains("eyJhbGci"),
6532 "token must be redacted: {debug}"
6533 );
6534 }
6535
6536 #[test]
6537 fn http_auth_none_debug_shows_variant() {
6538 let debug = format!("{:?}", HttpAuth::None);
6539 assert!(
6540 debug.contains("None"),
6541 "None variant should appear: {debug}"
6542 );
6543 }
6544
6545 #[test]
6546 fn http_endpoint_config_debug_redacts_auth_credentials() {
6547 let config = HttpEndpointConfig::from_uri(
6548 "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
6549 )
6550 .unwrap();
6551 let debug = format!("{:?}", config);
6552 assert!(
6553 !debug.contains("secret123"),
6554 "password must be redacted in HttpEndpointConfig debug: {debug}"
6555 );
6556 }
6557
6558 use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
6563 use tower_http::services::ServeDir;
6564
6565 fn make_test_registry() -> HttpRouteRegistry {
6566 HttpRouteRegistry::new()
6567 }
6568
6569 fn make_test_state(registry: HttpRouteRegistry) -> AppState {
6570 AppState {
6571 registry,
6572 max_request_body: 2 * 1024 * 1024,
6573 max_response_body: 10 * 1024 * 1024,
6574 inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
6575 }
6576 }
6577
6578 #[allow(clippy::await_holding_lock)]
6579 #[tokio::test]
6580 async fn test_static_file_serving_serves_file_contents() {
6581 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6582 ServerRegistry::reset();
6583
6584 let temp_dir =
6586 std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
6587 std::fs::create_dir_all(&temp_dir).unwrap();
6588 std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
6589 std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
6590
6591 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
6592
6593 let registry = make_test_registry();
6594 let serve_dir = ServeDir::new(&canonical_dir)
6595 .precompressed_gzip()
6596 .precompressed_br()
6597 .append_index_html_on_directories(true);
6598
6599 let mount = StaticMount {
6600 mount_path: "/".to_string(),
6601 mode: MountMode::Static,
6602 dir: canonical_dir.clone(),
6603 cache_control: "public, max-age=3600".to_string(),
6604 error_pages: std::collections::HashMap::new(),
6605 serve_dir,
6606 };
6607 registry.register_static_mount(mount).await.unwrap();
6608
6609 let state = make_test_state(registry);
6610
6611 let req = Request::builder()
6613 .uri("/hello.txt")
6614 .body(AxumBody::empty())
6615 .unwrap();
6616 let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
6617 assert_eq!(resp.status(), StatusCode::OK);
6618 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6619 .await
6620 .unwrap();
6621 assert_eq!(&body[..], b"Hello, static world!");
6622
6623 let req = Request::builder()
6625 .uri("/style.css")
6626 .body(AxumBody::empty())
6627 .unwrap();
6628 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
6629 assert_eq!(resp.status(), StatusCode::OK);
6630 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6631 .await
6632 .unwrap();
6633 assert_eq!(&body[..], b"body { color: red; }");
6634
6635 let req = Request::builder()
6637 .uri("/missing.txt")
6638 .body(AxumBody::empty())
6639 .unwrap();
6640 let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
6641 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
6642
6643 std::fs::remove_dir_all(&temp_dir).ok();
6645 }
6646
6647 #[allow(clippy::await_holding_lock)]
6648 #[tokio::test]
6649 async fn test_spa_fallback_serves_index_for_unknown_paths() {
6650 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6651 ServerRegistry::reset();
6652
6653 let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
6654 std::fs::create_dir_all(&temp_dir).unwrap();
6655 std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
6656 std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
6657
6658 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
6659
6660 let registry = make_test_registry();
6661 let serve_dir = ServeDir::new(&canonical_dir)
6662 .precompressed_gzip()
6663 .precompressed_br()
6664 .append_index_html_on_directories(true);
6665
6666 let mount = StaticMount {
6667 mount_path: "/".to_string(),
6668 mode: MountMode::Spa,
6669 dir: canonical_dir.clone(),
6670 cache_control: "public, max-age=0".to_string(),
6671 error_pages: std::collections::HashMap::new(),
6672 serve_dir,
6673 };
6674 registry.register_static_mount(mount).await.unwrap();
6676
6677 let state = make_test_state(registry);
6678
6679 let req = Request::builder()
6681 .method("GET")
6682 .uri("/dashboard")
6683 .header("Accept", "text/html")
6684 .body(AxumBody::empty())
6685 .unwrap();
6686 let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
6687 assert_eq!(resp.status(), StatusCode::OK);
6688 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6689 .await
6690 .unwrap();
6691 assert_eq!(&body[..], b"<h1>SPA App</h1>");
6692
6693 let req = Request::builder()
6695 .method("GET")
6696 .uri("/app.js")
6697 .body(AxumBody::empty())
6698 .unwrap();
6699 let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
6700 assert_eq!(resp.status(), StatusCode::OK);
6701 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6702 .await
6703 .unwrap();
6704 assert_eq!(&body[..], b"console.log('app')");
6705
6706 let req = Request::builder()
6708 .method("GET")
6709 .uri("/api/data")
6710 .header("Accept", "application/json")
6711 .body(AxumBody::empty())
6712 .unwrap();
6713 let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
6714 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
6715
6716 let req = Request::builder()
6718 .method("GET")
6719 .uri("/style.css")
6720 .header("Accept", "text/html")
6721 .body(AxumBody::empty())
6722 .unwrap();
6723 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
6724 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
6725
6726 std::fs::remove_dir_all(&temp_dir).ok();
6728 }
6729
6730 #[allow(clippy::await_holding_lock)]
6737 async fn run_conditional_get_returns_304(mode: MountMode) {
6738 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6739 ServerRegistry::reset();
6740
6741 let temp_dir = std::env::temp_dir().join(format!(
6742 "http_cond_get_{}_{}",
6743 if mode == MountMode::Spa {
6744 "spa"
6745 } else {
6746 "static"
6747 },
6748 std::process::id()
6749 ));
6750 std::fs::create_dir_all(&temp_dir).unwrap();
6751 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
6752
6753 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
6754
6755 let registry = make_test_registry();
6756 let serve_dir = ServeDir::new(&canonical_dir)
6757 .precompressed_gzip()
6758 .precompressed_br()
6759 .append_index_html_on_directories(true);
6760
6761 let mount = StaticMount {
6762 mount_path: "/".to_string(),
6763 mode,
6764 dir: canonical_dir.clone(),
6765 cache_control: "public, max-age=3600".to_string(),
6766 error_pages: std::collections::HashMap::new(),
6767 serve_dir,
6768 };
6769 registry.register_static_mount(mount).await.unwrap();
6770
6771 let state = make_test_state(registry);
6772
6773 let req = Request::builder()
6775 .method("GET")
6776 .uri("/index.html")
6777 .body(AxumBody::empty())
6778 .unwrap();
6779 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
6780 assert_eq!(
6781 resp.status(),
6782 StatusCode::OK,
6783 "first GET should return 200, got {}",
6784 resp.status()
6785 );
6786 assert!(
6788 resp.headers().contains_key(http::header::CACHE_CONTROL),
6789 "200 response missing Cache-Control"
6790 );
6791 let etag = resp
6792 .headers()
6793 .get(http::header::ETAG)
6794 .expect("ServeDir must emit ETag on 200 for If-None-Match coverage")
6795 .clone();
6796 let last_modified = resp
6797 .headers()
6798 .get(http::header::LAST_MODIFIED)
6799 .expect("ServeDir must emit Last-Modified on 200 for If-Modified-Since coverage")
6800 .clone();
6801 let _ = axum::body::to_bytes(resp.into_body(), usize::MAX)
6803 .await
6804 .unwrap();
6805
6806 let req = Request::builder()
6810 .method("GET")
6811 .uri("/index.html")
6812 .header(http::header::IF_NONE_MATCH, etag.clone())
6813 .body(AxumBody::empty())
6814 .unwrap();
6815 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
6816 assert_eq!(
6817 resp.status(),
6818 StatusCode::NOT_MODIFIED,
6819 "If-None-Match with matching ETag should return 304, got {}",
6820 resp.status()
6821 );
6822 assert!(
6824 resp.headers().contains_key(http::header::CACHE_CONTROL),
6825 "304 (If-None-Match) missing Cache-Control"
6826 );
6827 assert_eq!(
6830 resp.headers().get(http::header::ETAG),
6831 Some(&etag),
6832 "304 (If-None-Match) must echo the ETag validator"
6833 );
6834 assert_eq!(
6835 resp.headers().get(http::header::LAST_MODIFIED),
6836 Some(&last_modified),
6837 "304 (If-None-Match) must carry Last-Modified"
6838 );
6839
6840 let req = Request::builder()
6842 .method("GET")
6843 .uri("/index.html")
6844 .header(http::header::IF_MODIFIED_SINCE, last_modified.clone())
6845 .body(AxumBody::empty())
6846 .unwrap();
6847 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
6848 assert_eq!(
6849 resp.status(),
6850 StatusCode::NOT_MODIFIED,
6851 "If-Modified-Since with matching timestamp should return 304, got {}",
6852 resp.status()
6853 );
6854 assert!(
6855 resp.headers().contains_key(http::header::CACHE_CONTROL),
6856 "304 (If-Modified-Since) missing Cache-Control"
6857 );
6858 assert_eq!(
6859 resp.headers().get(http::header::ETAG),
6860 Some(&etag),
6861 "304 (If-Modified-Since) must carry the ETag validator"
6862 );
6863 assert_eq!(
6864 resp.headers().get(http::header::LAST_MODIFIED),
6865 Some(&last_modified),
6866 "304 (If-Modified-Since) must echo Last-Modified"
6867 );
6868
6869 let req = Request::builder()
6875 .method("GET")
6876 .uri("/index.html")
6877 .header(
6878 http::header::IF_MODIFIED_SINCE,
6879 "Wed, 21 Oct 2000 07:28:00 GMT",
6880 )
6881 .body(AxumBody::empty())
6882 .unwrap();
6883 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
6884 assert_eq!(
6885 resp.status(),
6886 StatusCode::OK,
6887 "past If-Modified-Since should return 200 (file modified after it), got {}",
6888 resp.status()
6889 );
6890
6891 std::fs::remove_dir_all(&temp_dir).ok();
6893 }
6894
6895 #[tokio::test]
6896 async fn test_conditional_get_returns_304_static_mode() {
6897 run_conditional_get_returns_304(MountMode::Static).await;
6898 }
6899
6900 #[tokio::test]
6901 async fn test_conditional_get_returns_304_spa_mode() {
6902 run_conditional_get_returns_304(MountMode::Spa).await;
6903 }
6904
6905 #[allow(clippy::await_holding_lock)]
6906 #[tokio::test]
6907 async fn test_error_page_mapping_serves_custom_404() {
6908 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6909 ServerRegistry::reset();
6910
6911 let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
6912 let errors_dir = temp_dir.join("errors");
6913 std::fs::create_dir_all(&errors_dir).unwrap();
6914 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
6915 std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
6916
6917 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
6918 let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
6919
6920 let registry = make_test_registry();
6921 let serve_dir = ServeDir::new(&canonical_dir)
6922 .precompressed_gzip()
6923 .precompressed_br()
6924 .append_index_html_on_directories(true);
6925
6926 let mut error_pages = std::collections::HashMap::new();
6927 error_pages.insert(404, canonical_404);
6928
6929 let mount = StaticMount {
6930 mount_path: "/".to_string(),
6931 mode: MountMode::Static,
6932 dir: canonical_dir.clone(),
6933 cache_control: "public, max-age=0".to_string(),
6934 error_pages,
6935 serve_dir,
6936 };
6937 registry.register_static_mount(mount).await.unwrap();
6938
6939 let state = make_test_state(registry);
6940
6941 let req = Request::builder()
6943 .method("GET")
6944 .uri("/missing.html")
6945 .body(AxumBody::empty())
6946 .unwrap();
6947 let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
6948 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
6949 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6950 .await
6951 .unwrap();
6952 assert_eq!(&body[..], b"<h1>Custom 404</h1>");
6953
6954 let req = Request::builder()
6956 .method("GET")
6957 .uri("/index.html")
6958 .body(AxumBody::empty())
6959 .unwrap();
6960 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
6961 assert_eq!(resp.status(), StatusCode::OK);
6962 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6963 .await
6964 .unwrap();
6965 assert_eq!(&body[..], b"<h1>Home</h1>");
6966
6967 std::fs::remove_dir_all(&temp_dir).ok();
6969 }
6970
6971 #[tokio::test]
6972 async fn http_consumer_returns_body_and_code_on_stop() {
6973 use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
6974 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
6975 use tower::ServiceExt;
6976
6977 let set_body_step = CompiledStep::Process {
6979 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
6980 ex.input.body = Body::Text("nope".into());
6981 Box::pin(async move { Ok(ex) })
6982 }),
6983 body_contract: None,
6984 lifecycle: None,
6985 };
6986 let set_status_step = CompiledStep::Process {
6987 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
6988 ex.input.set_header(
6989 "CamelHttpResponseCode",
6990 serde_json::Value::Number(409.into()),
6991 );
6992 Box::pin(async move { Ok(ex) })
6993 }),
6994 body_contract: None,
6995 lifecycle: None,
6996 };
6997 let pipeline = compose_pipeline_with_handler(
6998 vec![set_body_step, set_status_step, CompiledStep::Stop],
6999 None,
7000 PipelineRuntimeCtx::compile_time(),
7001 );
7002
7003 let ex = Exchange::new(Message::default());
7004 let result = pipeline.oneshot(ex).await;
7005 assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
7006 let returned = result.unwrap();
7007 assert_eq!(returned.input.body.as_text(), Some("nope"));
7008 assert_eq!(
7009 returned
7010 .input
7011 .header("CamelHttpResponseCode")
7012 .and_then(|v| v.as_u64()),
7013 Some(409)
7014 );
7015 }
7016
7017 #[tokio::test]
7018 async fn http_consumer_returns_200_when_body_empty_on_stop() {
7019 use camel_api::{Exchange, Message};
7027 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
7028 use tower::ServiceExt;
7029
7030 let pipeline = compose_pipeline_with_handler(
7031 vec![CompiledStep::Stop],
7032 None,
7033 PipelineRuntimeCtx::compile_time(),
7034 );
7035 let ex = Exchange::new(Message::default());
7036 let result = pipeline.oneshot(ex).await;
7037 assert!(result.is_ok(), "Stop with empty body arrives as Ok");
7038 }
7041
7042 async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
7050 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7051 let port = listener.local_addr().unwrap().port();
7052 let registry = HttpRouteRegistry::new();
7053 tokio::spawn(run_axum_server(
7054 listener,
7055 registry.clone(),
7056 2 * 1024 * 1024,
7057 10 * 1024 * 1024,
7058 Arc::new(tokio::sync::Semaphore::new(1024)),
7059 test_rt(),
7060 "test-route".into(),
7061 ));
7062 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7064 (port, registry)
7065 }
7066
7067 fn spawn_responder(
7072 mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
7073 status: u16,
7074 body: String,
7075 ) -> tokio::task::JoinHandle<()> {
7076 tokio::spawn(async move {
7077 if let Some(envelope) = rx.recv().await {
7078 let _ = envelope.reply_tx.send(HttpReply {
7079 status,
7080 headers: vec![],
7081 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
7082 });
7083 }
7084 })
7085 }
7086
7087 #[tokio::test]
7088 async fn method_aware_dispatch_same_path_different_verbs() {
7089 let (port, registry) = spawn_test_server().await;
7090
7091 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7096 registry
7097 .register_rest_endpoint(
7098 "GET".into(),
7099 vec![PathSegment::Literal("users".into())],
7100 get_tx,
7101 )
7102 .await;
7103
7104 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7105 registry
7106 .register_rest_endpoint(
7107 "POST".into(),
7108 vec![PathSegment::Literal("users".into())],
7109 post_tx,
7110 )
7111 .await;
7112
7113 let get_handle = spawn_responder(get_rx, 200, "list".into());
7114 let post_handle = spawn_responder(post_rx, 201, "create".into());
7115
7116 let client = reqwest::Client::new();
7117
7118 let resp = client
7120 .get(format!("http://127.0.0.1:{port}/users"))
7121 .send()
7122 .await
7123 .unwrap();
7124 assert_eq!(resp.status().as_u16(), 200);
7125 let body = resp.text().await.unwrap();
7126 assert_eq!(body, "list");
7127
7128 let resp = client
7130 .post(format!("http://127.0.0.1:{port}/users"))
7131 .send()
7132 .await
7133 .unwrap();
7134 assert_eq!(resp.status().as_u16(), 201);
7135 let body = resp.text().await.unwrap();
7136 assert_eq!(body, "create");
7137
7138 let _ = tokio::join!(get_handle, post_handle);
7139 }
7140
7141 #[tokio::test]
7142 async fn method_aware_dispatch_templated_path_extracts_params() {
7143 let (port, registry) = spawn_test_server().await;
7144
7145 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7149 registry
7150 .register_rest_endpoint(
7151 "GET".into(),
7152 vec![
7153 PathSegment::Literal("users".into()),
7154 PathSegment::Param("id".into()),
7155 ],
7156 tx,
7157 )
7158 .await;
7159
7160 let handle = tokio::spawn(async move {
7163 if let Some(envelope) = rx.recv().await {
7164 let id = envelope.path_params.get("id").cloned().unwrap_or_default();
7165 let _ = envelope.reply_tx.send(HttpReply {
7166 status: 200,
7167 headers: vec![],
7168 body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
7169 });
7170 }
7171 });
7172
7173 let client = reqwest::Client::new();
7174 let resp = client
7175 .get(format!("http://127.0.0.1:{port}/users/42"))
7176 .send()
7177 .await
7178 .unwrap();
7179 assert_eq!(resp.status().as_u16(), 200);
7180 let body = resp.text().await.unwrap();
7181 assert_eq!(body, "id=42");
7182
7183 let _ = handle.await;
7184 }
7185
7186 #[tokio::test]
7187 async fn method_aware_dispatch_unmatched_method_falls_through() {
7188 let (port, _registry) = spawn_test_server().await;
7193
7194 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7196 _registry
7197 .register_rest_endpoint(
7198 "GET".into(),
7199 vec![PathSegment::Literal("users".into())],
7200 get_tx,
7201 )
7202 .await;
7203
7204 let drain = tokio::spawn(async move {
7207 let mut get_rx = get_rx;
7208 while get_rx.recv().await.is_some() {}
7209 });
7210
7211 let client = reqwest::Client::new();
7212 let resp = client
7213 .delete(format!("http://127.0.0.1:{port}/users"))
7214 .send()
7215 .await
7216 .unwrap();
7217 assert_eq!(resp.status().as_u16(), 404);
7218
7219 drop(drain);
7220 }
7221
7222 #[tokio::test]
7223 async fn regression_legacy_exact_api_route_still_works() {
7224 let (port, registry) = spawn_test_server().await;
7229
7230 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7231 registry.register_api_route("/legacy/path".into(), tx).await;
7232
7233 let handle = tokio::spawn(async move {
7234 if let Some(envelope) = rx.recv().await {
7235 let _ = envelope.reply_tx.send(HttpReply {
7236 status: 200,
7237 headers: vec![],
7238 body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
7239 });
7240 }
7241 });
7242
7243 let client = reqwest::Client::new();
7244 let resp = client
7245 .get(format!("http://127.0.0.1:{port}/legacy/path"))
7246 .send()
7247 .await
7248 .unwrap();
7249 assert_eq!(resp.status().as_u16(), 200);
7250 let body = resp.text().await.unwrap();
7251 assert_eq!(body, "legacy ok");
7252
7253 let _ = handle.await;
7254 }
7255
7256 #[allow(clippy::await_holding_lock)]
7257 #[tokio::test]
7258 async fn regression_static_mount_still_works() {
7259 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7263 ServerRegistry::reset();
7264
7265 let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
7266 std::fs::create_dir_all(&temp_dir).unwrap();
7267 std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
7268 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
7269
7270 let registry = make_test_registry();
7271 let serve_dir = ServeDir::new(&canonical_dir)
7272 .precompressed_gzip()
7273 .precompressed_br()
7274 .append_index_html_on_directories(true);
7275 let mount = StaticMount {
7276 mount_path: "/".to_string(),
7277 mode: MountMode::Static,
7278 dir: canonical_dir.clone(),
7279 cache_control: "public, max-age=3600".to_string(),
7280 error_pages: std::collections::HashMap::new(),
7281 serve_dir,
7282 };
7283 registry.register_static_mount(mount).await.unwrap();
7284
7285 let state = make_test_state(registry);
7286 let req = Request::builder()
7287 .uri("/regress.txt")
7288 .body(AxumBody::empty())
7289 .unwrap();
7290 let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
7291 assert_eq!(resp.status(), StatusCode::OK);
7292 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
7293 .await
7294 .unwrap();
7295 assert_eq!(&body[..], b"static works");
7296
7297 std::fs::remove_dir_all(&temp_dir).ok();
7298 }
7299
7300 #[tokio::test]
7309 async fn deregister_one_method_keeps_sibling_verbs() {
7310 let (port, registry) = spawn_test_server().await;
7314
7315 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7316 registry
7317 .register_rest_endpoint(
7318 "GET".into(),
7319 vec![PathSegment::Literal("users".into())],
7320 get_tx,
7321 )
7322 .await;
7323
7324 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7325 registry
7326 .register_rest_endpoint(
7327 "POST".into(),
7328 vec![PathSegment::Literal("users".into())],
7329 post_tx,
7330 )
7331 .await;
7332
7333 let drain = tokio::spawn(async move {
7335 let mut get_rx = get_rx;
7336 while get_rx.recv().await.is_some() {}
7337 });
7338
7339 registry.unregister_rest_endpoint("GET", "/users").await;
7341 drop(drain);
7342
7343 let post_handle = spawn_responder(post_rx, 201, "create".into());
7344
7345 let client = reqwest::Client::new();
7346 let resp = client
7348 .post(format!("http://127.0.0.1:{port}/users"))
7349 .send()
7350 .await
7351 .unwrap();
7352 assert_eq!(resp.status().as_u16(), 201);
7353 assert_eq!(resp.text().await.unwrap(), "create");
7354
7355 let _ = post_handle.await;
7356 }
7357
7358 #[tokio::test]
7359 async fn dispatch_exact_legacy_beats_rest_template() {
7360 let (port, registry) = spawn_test_server().await;
7365
7366 let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7368 registry
7369 .register_api_route("/api/users".into(), exact_tx)
7370 .await;
7371 let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
7372
7373 let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7375 registry
7376 .register_rest_endpoint(
7377 "GET".into(),
7378 vec![
7379 PathSegment::Literal("api".into()),
7380 PathSegment::Param("resource".into()),
7381 ],
7382 tpl_tx,
7383 )
7384 .await;
7385 let _tpl_drain = tokio::spawn(async move {
7391 let mut tpl_rx = tpl_rx;
7392 if let Some(env) = tpl_rx.recv().await {
7393 let _ = env.reply_tx.send(HttpReply {
7394 status: 200,
7395 headers: vec![],
7396 body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
7397 });
7398 }
7399 });
7400
7401 let client = reqwest::Client::new();
7402 let resp = client
7403 .get(format!("http://127.0.0.1:{port}/api/users"))
7404 .send()
7405 .await
7406 .unwrap();
7407 assert_eq!(resp.status().as_u16(), 200);
7408 assert_eq!(resp.text().await.unwrap(), "exact");
7410
7411 let _ = exact_handle.await;
7412 }
7413
7414 #[tokio::test]
7415 async fn ambiguous_rest_templates_return_500_not_silent_404() {
7416 let (port, registry) = spawn_test_server().await;
7421
7422 let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7423 registry
7424 .register_rest_endpoint(
7425 "GET".into(),
7426 vec![
7427 PathSegment::Literal("users".into()),
7428 PathSegment::Param("id".into()),
7429 ],
7430 a_tx,
7431 )
7432 .await;
7433
7434 let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
7435 registry
7436 .register_rest_endpoint(
7437 "GET".into(),
7438 vec![
7439 PathSegment::Literal("users".into()),
7440 PathSegment::Param("name".into()),
7441 ],
7442 b_tx,
7443 )
7444 .await;
7445
7446 let client = reqwest::Client::new();
7447 let resp = client
7448 .get(format!("http://127.0.0.1:{port}/users/42"))
7449 .send()
7450 .await
7451 .unwrap();
7452 assert_eq!(resp.status().as_u16(), 500);
7454 }
7455
7456 #[test]
7457 fn from_uri_round_trips_templated_path_with_http_method() {
7458 use crate::UriConfig;
7464 let cfg =
7465 HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
7466 assert_eq!(cfg.host, "0.0.0.0");
7467 assert_eq!(cfg.port, 8080);
7468 assert_eq!(cfg.path, "/users/{id}");
7469 assert_eq!(cfg.method.as_deref(), Some("GET"));
7470
7471 let cfg_lc =
7473 HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
7474 assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
7475 assert_eq!(cfg_lc.path, "/orders");
7476 }
7477
7478 #[test]
7483 fn type_conversion_failed_maps_to_400() {
7484 let reply = pipeline_error_to_reply(
7485 CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
7486 "/api/users",
7487 );
7488 assert_eq!(reply.status, 400);
7489 let ct = reply
7491 .headers
7492 .iter()
7493 .find(|(k, _)| k == "Content-Type")
7494 .map(|(_, v)| v.as_str());
7495 assert_eq!(ct, Some("application/json"));
7496 let body = match &reply.body {
7498 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
7499 _ => panic!("expected bytes body"),
7500 };
7501 assert!(body.contains("\"error\""));
7502 assert!(body.contains("bad_request"));
7503 assert!(body.contains("invalid JSON at line 1"));
7504 }
7505
7506 #[test]
7507 fn other_error_still_maps_to_500() {
7508 let reply =
7509 pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
7510 assert_eq!(reply.status, 500);
7511 }
7512
7513 #[test]
7514 fn unauthenticated_maps_to_401() {
7515 let reply = pipeline_error_to_reply(
7516 CamelError::Unauthenticated("no token".to_string()),
7517 "/api/users",
7518 );
7519 assert_eq!(reply.status, 401);
7520 }
7521
7522 #[test]
7523 fn unauthorized_maps_to_403() {
7524 let reply = pipeline_error_to_reply(
7525 CamelError::Unauthorized("forbidden".to_string()),
7526 "/api/users",
7527 );
7528 assert_eq!(reply.status, 403);
7529 }
7530
7531 #[test]
7532 fn validation_error_maps_to_400() {
7533 let reply = pipeline_error_to_reply(
7534 CamelError::ValidationError("body does not match schema".to_string()),
7535 "/api/users",
7536 );
7537 assert_eq!(reply.status, 400);
7538 let ct = reply
7539 .headers
7540 .iter()
7541 .find(|(k, _)| k == "Content-Type")
7542 .map(|(_, v)| v.as_str());
7543 assert_eq!(ct, Some("application/json"));
7544 let body = match &reply.body {
7545 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
7546 _ => panic!("expected bytes body"),
7547 };
7548 assert!(body.contains("\"error\""));
7549 assert!(body.contains("validation_error"));
7550 assert!(body.contains("body does not match schema"));
7551 }
7552
7553 #[test]
7554 fn https_consumer_without_tls_cert_errors() {
7555 let endpoint = HttpEndpoint {
7556 uri: "https://0.0.0.0:8443/api".to_string(),
7557 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
7558 server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
7559 client: reqwest::Client::new(),
7560 http_config: HttpConfig::default(),
7561 };
7562 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7563 let result = endpoint.create_consumer(rt);
7564 assert!(result.is_err(), "expected error for https without tls cert");
7565 if let Err(e) = result {
7566 let msg = e.to_string();
7567 assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
7568 }
7569 }
7570
7571 #[test]
7572 fn http_consumer_with_tls_config_errors() {
7573 let endpoint = HttpEndpoint {
7574 uri: "http://0.0.0.0:8080/api".to_string(),
7575 config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
7576 server_config: HttpServerConfig::from_uri(
7577 "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
7578 )
7579 .unwrap(),
7580 client: reqwest::Client::new(),
7581 http_config: HttpConfig::default(),
7582 };
7583 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7584 let result = endpoint.create_consumer(rt);
7585 assert!(result.is_err(), "expected error for http with tls config");
7586 if let Err(e) = result {
7587 let msg = e.to_string();
7588 assert!(msg.contains("https"), "error must mention https: {msg}");
7589 }
7590 }
7591
7592 #[test]
7593 fn https_consumer_with_partial_tls_cert_only_errors() {
7594 let server_config =
7597 HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
7598 assert!(
7599 server_config.tls_config.is_none(),
7600 "partial tlsCert must not create ServerTlsConfig"
7601 );
7602 let endpoint = HttpEndpoint {
7603 uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
7604 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
7605 .unwrap(),
7606 server_config,
7607 client: reqwest::Client::new(),
7608 http_config: HttpConfig::default(),
7609 };
7610 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
7611 let result = endpoint.create_consumer(rt);
7612 assert!(
7613 result.is_err(),
7614 "must error: https:// requires both tlsCert and tlsKey"
7615 );
7616 }
7617
7618 #[test]
7619 fn load_tls_config_parses_valid_pem() {
7620 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7622 use camel_component_api::test_support::tls;
7623 let (_, cert_pem, key_pem) = tls::gen_server_cert();
7624 let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
7625 let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
7626
7627 let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
7628 assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
7629 }
7630
7631 #[tokio::test(flavor = "multi_thread")]
7632 #[allow(clippy::await_holding_lock)]
7633 async fn consumer_tls_handshake_roundtrip() {
7634 use camel_component_api::test_support::tls;
7635 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7636
7637 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7639
7640 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7642
7643 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
7645 let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
7646 let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
7647 let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
7648
7649 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7651 let port = probe.local_addr().unwrap().port();
7652 drop(probe);
7653
7654 ServerRegistry::reset();
7655
7656 let component = HttpComponent::new();
7658 let endpoint_ctx = NoOpComponentContext;
7659 let uri = format!(
7660 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
7661 cert_path.to_string_lossy(),
7662 key_path.to_string_lossy(),
7663 );
7664 let endpoint = component
7665 .create_endpoint(&uri, &endpoint_ctx)
7666 .expect("create TLS endpoint");
7667 let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
7668
7669 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7671 let token = tokio_util::sync::CancellationToken::new();
7672 let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
7673 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7674
7675 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
7677
7678 let ca_bytes = std::fs::read(&ca_path).unwrap();
7680 let client = reqwest::Client::builder()
7681 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
7682 .build()
7683 .unwrap();
7684
7685 let send_fut = client
7686 .post(format!("https://localhost:{port}/test"))
7687 .body("ping")
7688 .send();
7689
7690 let (http_result, _) = tokio::join!(send_fut, async {
7692 if let Some(mut envelope) = rx.recv().await {
7693 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
7694 if let Some(reply_tx) = envelope.reply_tx {
7695 let _ = reply_tx.send(Ok(envelope.exchange));
7696 }
7697 }
7698 });
7699
7700 let resp = http_result.expect("TLS handshake + request must succeed");
7701
7702 assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
7703 let body = resp.text().await.unwrap();
7704 assert_eq!(body, "pong");
7705
7706 token.cancel();
7707 }
7708
7709 #[tokio::test(flavor = "multi_thread")]
7710 #[allow(clippy::await_holding_lock)]
7711 async fn consumer_tls_rejects_client_without_ca() {
7712 use camel_component_api::test_support::tls;
7713 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7714
7715 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
7716
7717 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
7719
7720 let (_, cert_pem, key_pem) = tls::gen_server_cert();
7721 let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
7722 let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
7723
7724 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7725 let port = probe.local_addr().unwrap().port();
7726 drop(probe);
7727
7728 ServerRegistry::reset();
7729
7730 let component = HttpComponent::new();
7732 let endpoint_ctx = NoOpComponentContext;
7733 let uri = format!(
7734 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
7735 cert_path.to_string_lossy(),
7736 key_path.to_string_lossy(),
7737 );
7738 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
7739 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7740 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7741 let token = tokio_util::sync::CancellationToken::new();
7742 let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
7743 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7744
7745 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
7746
7747 let client = reqwest::Client::builder().build().unwrap();
7749
7750 let result = client
7751 .get(format!("https://localhost:{port}/test"))
7752 .send()
7753 .await;
7754
7755 assert!(
7756 result.is_err(),
7757 "must reject without CA — proves real verification"
7758 );
7759
7760 token.cancel();
7761 }
7762
7763 #[test]
7764 fn server_config_partial_tls_cert_without_key() {
7765 let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
7767 assert!(cfg.tls_config.is_none());
7769 }
7770
7771 #[test]
7772 fn endpoint_uri_options_count_parity() {
7773 assert_eq!(
7775 HttpEndpointConfig::uri_options().len(),
7776 20,
7777 "HttpEndpointUriConfig #[uri_param] count drifted from parser"
7778 );
7779 }
7780
7781 fn make_headers(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
7782 pairs
7783 .iter()
7784 .map(|(k, v)| {
7785 (
7786 (*k).to_string(),
7787 serde_json::Value::String((*v).to_string()),
7788 )
7789 })
7790 .collect()
7791 }
7792
7793 #[test]
7794 fn response_emits_cache_control_via_pragma_warning() {
7795 let headers = make_headers(&[
7796 ("Cache-Control", "public, max-age=3600"),
7797 ("Via", "1.1 myproxy"),
7798 ("Pragma", "no-cache"),
7799 ("Warning", "199 misc"),
7800 ]);
7801 let selected = select_response_headers(&headers, None, None);
7802 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
7803 for expected in ["Cache-Control", "Via", "Pragma", "Warning"] {
7804 assert!(
7805 names.contains(&expected),
7806 "{expected} should pass through to the response"
7807 );
7808 }
7809 }
7810
7811 #[test]
7812 fn response_excludes_request_only_and_server_owned() {
7813 let headers = make_headers(&[
7814 ("User-Agent", "x"),
7815 ("Accept", "*/*"),
7816 ("Date", "Thu, 01 Jan 2026 00:00:00 GMT"),
7817 ]);
7818 let selected = select_response_headers(&headers, None, None);
7819 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
7820 for excluded in ["User-Agent", "Accept", "Date"] {
7821 assert!(
7822 !names.contains(&excluded),
7823 "{excluded} should NOT appear in the response"
7824 );
7825 }
7826 }
7827
7828 #[test]
7829 fn response_re_derives_content_type() {
7830 let headers = make_headers(&[("Content-Type", "text/plain")]);
7831 let selected = select_response_headers(&headers, Some("application/json".into()), None);
7832 let ct_entries: Vec<&str> = selected
7833 .iter()
7834 .filter(|(k, _)| k == "Content-Type")
7835 .map(|(_, v)| v.as_str())
7836 .collect();
7837 assert_eq!(
7838 ct_entries,
7839 ["application/json"],
7840 "exactly one Content-Type entry, re-derived from user_content_type"
7841 );
7842 }
7843
7844 #[test]
7845 fn response_excludes_camel_headers() {
7846 let headers = make_headers(&[("CamelHttpPath", "/foo"), ("Cache-Control", "public")]);
7847 let selected = select_response_headers(&headers, None, None);
7848 let names: Vec<&str> = selected.iter().map(|(k, _)| k.as_str()).collect();
7849 assert!(
7850 !names.contains(&"CamelHttpPath"),
7851 "Camel-namespace headers must be excluded"
7852 );
7853 assert!(
7854 names.contains(&"Cache-Control"),
7855 "Cache-Control must pass through"
7856 );
7857 }
7858
7859 async fn start_host_capturing_destination() -> (
7871 String,
7872 Arc<std::sync::Mutex<Option<(String, String)>>>,
7873 tokio::task::JoinHandle<()>,
7874 ) {
7875 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7876 let port = listener.local_addr().unwrap().port();
7877 let url = format!("http://127.0.0.1:{port}");
7878 let captured: Arc<std::sync::Mutex<Option<(String, String)>>> =
7879 Arc::new(std::sync::Mutex::new(None));
7880 let captured_clone = Arc::clone(&captured);
7881 let handle = tokio::spawn(async move {
7882 use tokio::io::{AsyncReadExt, AsyncWriteExt};
7883 if let Ok((mut stream, _)) = listener.accept().await {
7884 let mut buf = vec![0u8; 16384];
7885 let n = stream.read(&mut buf).await.unwrap_or(0);
7886 let request = String::from_utf8_lossy(&buf[..n]).to_string();
7887 if request.contains("\r\n\r\n") {
7888 let request_line = request.lines().next().unwrap_or("").to_string();
7889 let host_value = request
7890 .lines()
7891 .find(|l| l.to_lowercase().starts_with("host:"))
7892 .and_then(|l| l.split_once(':'))
7893 .map(|(_, v)| v.trim().to_string())
7894 .unwrap_or_default();
7895 *captured_clone.lock().unwrap() = Some((host_value, request_line));
7896 }
7897 let body = r#"{"echo":"ok"}"#;
7898 let resp = format!(
7899 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
7900 body.len(),
7901 body
7902 );
7903 let _ = stream.write_all(resp.as_bytes()).await;
7904 }
7905 });
7906 (url, captured, handle)
7907 }
7908
7909 #[tokio::test]
7914 async fn bridge_proxy_outbound_host_matches_destination() {
7915 use tower::ServiceExt;
7916
7917 let (url, captured, _handle) = start_host_capturing_destination().await;
7918 let expected_host = url.strip_prefix("http://").unwrap();
7921
7922 let ctx = test_producer_ctx();
7923 let component = HttpComponent::new();
7924 let endpoint_ctx = NoOpComponentContext;
7925 let endpoint = component
7926 .create_endpoint(
7927 &format!("{url}?bridgeEndpoint=true&allowInternal=true"),
7928 &endpoint_ctx,
7929 )
7930 .unwrap();
7931 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
7932
7933 let mut exchange = Exchange::new(Message::default());
7936 exchange.input.set_header("Host", "localhost");
7937 exchange.input.set_header("CamelHttpPath", "/foo");
7938
7939 let result = producer.oneshot(exchange).await;
7940 assert!(result.is_ok(), "producer call failed: {:?}", result);
7941
7942 tokio::time::sleep(Duration::from_millis(100)).await;
7943 let (host_value, request_line) = captured
7944 .lock()
7945 .unwrap()
7946 .take()
7947 .expect("destination capture mutex empty — producer did not reach the destination");
7948
7949 assert_ne!(
7950 host_value, "localhost",
7951 "bridge producer must not forward the exchange Host: localhost"
7952 );
7953 assert_eq!(
7954 host_value, expected_host,
7955 "Host must be derived from the destination authority (no scheme)"
7956 );
7957 assert!(
7958 !request_line.contains("/foo"),
7959 "bridge_endpoint must drop CamelHttpPath; request line was: {request_line}"
7960 );
7961 }
7962
7963 #[tokio::test]
7968 async fn bridge_proxy_route_set_response_header_survives() {
7969 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
7970
7971 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7972 let port = listener.local_addr().unwrap().port();
7973 drop(listener);
7974
7975 let component = HttpComponent::new();
7976 let endpoint_ctx = NoOpComponentContext;
7977 let endpoint = component
7978 .create_endpoint(&format!("http://127.0.0.1:{port}/cache"), &endpoint_ctx)
7979 .unwrap();
7980 let mut consumer = endpoint.create_consumer(rt()).unwrap();
7981
7982 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
7983 let token = tokio_util::sync::CancellationToken::new();
7984 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
7985
7986 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
7987 tokio::time::sleep(Duration::from_millis(50)).await;
7988
7989 let client = reqwest::Client::new();
7990 let send_fut = client.get(format!("http://127.0.0.1:{port}/cache")).send();
7991
7992 let (http_result, _) = tokio::join!(send_fut, async {
7996 if let Some(mut envelope) = rx.recv().await {
7997 envelope
7998 .exchange
7999 .input
8000 .set_header("Cache-Control", "public, max-age=3600");
8001 if let Some(reply_tx) = envelope.reply_tx {
8002 let _ = reply_tx.send(Ok(envelope.exchange));
8003 }
8004 }
8005 });
8006
8007 let resp = http_result.unwrap();
8008 assert_eq!(resp.status().as_u16(), 200);
8009
8010 let cache_control = resp.headers().get("cache-control");
8011 assert!(
8012 cache_control.is_some(),
8013 "Cache-Control header must survive to the wire response"
8014 );
8015 assert_eq!(
8016 cache_control.unwrap().to_str().unwrap(),
8017 "public, max-age=3600"
8018 );
8019
8020 token.cancel();
8021 }
8022
8023 use camel_api::security_policy::{AuthorizationDecision, CredentialSource, SecurityPolicy};
8042 use camel_auth::native_auth::NativeCredentialStore;
8043 use camel_auth::{RolePolicy, StaticTokenAuthenticator, TokenAuthenticator};
8044
8045 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 {
8053 let mut msg = Message::default();
8054 msg.set_header(
8055 "CamelHttpMethod",
8056 serde_json::Value::String(envelope.method.clone()),
8057 );
8058 msg.set_header(
8059 "CamelHttpPath",
8060 serde_json::Value::String(envelope.path.clone()),
8061 );
8062 msg.set_header(
8063 "CamelHttpQuery",
8064 serde_json::Value::String(envelope.query.clone()),
8065 );
8066 for (k, v) in &envelope.headers {
8067 if let Ok(val_str) = v.to_str() {
8068 msg.set_header(
8069 title_case_header(k.as_str()),
8070 serde_json::Value::String(val_str.to_string()),
8071 );
8072 }
8073 }
8074 Exchange::new(msg)
8075 }
8076
8077 async fn spawn_failing_auth_route(
8082 registry: &HttpRouteRegistry,
8083 path: &str,
8084 sources: Vec<CredentialSource>,
8085 ) {
8086 let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(
8087 NativeCredentialStore::try_new(vec![]).unwrap(),
8088 ));
8089 let policy = Arc::new(RolePolicy::new(
8090 vec!["tiles-reader".to_string()],
8091 true,
8092 false,
8093 authenticator,
8094 sources,
8095 ));
8096 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
8097 registry.register_api_route(path.to_string(), tx).await;
8098 let path_owned = path.to_string();
8099 tokio::spawn(async move {
8100 while let Some(envelope) = rx.recv().await {
8101 let mut exchange = envelope_to_exchange(&envelope);
8102 let reply_tx = envelope.reply_tx;
8103 let result: Result<(), CamelError> = match policy.evaluate(&mut exchange).await {
8104 Ok(AuthorizationDecision::Granted { .. }) => Ok(()),
8105 Ok(AuthorizationDecision::Denied { reason, .. }) => {
8106 Err(CamelError::Unauthorized(format!("Access denied: {reason}")))
8107 }
8108 Err(e) => Err(e),
8109 _ => Err(CamelError::Unauthorized(
8110 "access denied by security policy".to_string(),
8111 )),
8112 };
8113 let reply = match result {
8114 Ok(()) => HttpReply {
8115 status: 200,
8116 headers: vec![],
8117 body: HttpReplyBody::Bytes(bytes::Bytes::from("ok")),
8118 },
8119 Err(e) => pipeline_error_to_reply(e, &path_owned),
8120 };
8121 let _ = reply_tx.send(reply);
8122 }
8123 });
8124 }
8125
8126 fn captured_logs_contain(needle: &str) -> bool {
8130 let buf = tracing_test::internal::global_buf().lock().unwrap();
8131 String::from_utf8_lossy(&buf).contains(needle)
8132 }
8133
8134 #[tracing_test::traced_test]
8135 #[tokio::test]
8136 async fn error_context_redacts_query_sentinel() {
8137 let (port, registry) = spawn_test_server().await;
8138 spawn_failing_auth_route(
8139 ®istry,
8140 "/secure-query",
8141 vec![CredentialSource::QueryParam {
8142 param: "token".to_string(),
8143 }],
8144 )
8145 .await;
8146
8147 let client = reqwest::Client::new();
8148 let resp = client
8149 .get(format!(
8151 "http://127.0.0.1:{port}/secure-query?token={SENTINEL_QRY_42}"
8152 ))
8153 .send()
8154 .await
8155 .unwrap();
8156
8157 assert_eq!(resp.status().as_u16(), 401);
8158 let body = resp.text().await.unwrap();
8159 assert_eq!(body, "Unauthorized");
8160 assert!(
8161 !body.contains(SENTINEL_QRY_42),
8162 "reply body must not contain the query credential"
8163 );
8164 assert!(
8165 !captured_logs_contain(SENTINEL_QRY_42),
8166 "no tracing record during request handling may render the query credential"
8167 );
8168 assert!(
8172 captured_logs_contain("Authentication failed"),
8173 "positive control: the failed-auth warn! must be captured by the test subscriber"
8174 );
8175 }
8176
8177 #[tracing_test::traced_test]
8178 #[tokio::test]
8179 async fn error_context_redacts_cookie_sentinel() {
8180 let (port, registry) = spawn_test_server().await;
8181 spawn_failing_auth_route(
8182 ®istry,
8183 "/secure-cookie",
8184 vec![CredentialSource::Cookie {
8185 name: "session".to_string(),
8186 }],
8187 )
8188 .await;
8189
8190 let client = reqwest::Client::new();
8191 let resp = client
8192 .get(format!("http://127.0.0.1:{port}/secure-cookie"))
8193 .header("Cookie", format!("session={SENTINEL_CKY_7}"))
8194 .send()
8195 .await
8196 .unwrap();
8197
8198 assert_eq!(resp.status().as_u16(), 401);
8199 let body = resp.text().await.unwrap();
8200 assert_eq!(body, "Unauthorized");
8201 assert!(
8202 !body.contains(SENTINEL_CKY_7),
8203 "reply body must not contain the cookie credential"
8204 );
8205 assert!(
8206 !captured_logs_contain(SENTINEL_CKY_7),
8207 "no tracing record during request handling may render the cookie credential"
8208 );
8209 }
8210
8211 #[tracing_test::traced_test]
8212 #[tokio::test]
8213 async fn error_reply_no_credential_value() {
8214 let (port, registry) = spawn_test_server().await;
8215 spawn_failing_auth_route(
8216 ®istry,
8217 "/secure-bad",
8218 vec![CredentialSource::Cookie {
8219 name: "session".to_string(),
8220 }],
8221 )
8222 .await;
8223
8224 let client = reqwest::Client::new();
8225 let resp = client
8226 .get(format!("http://127.0.0.1:{port}/secure-bad"))
8227 .header("Cookie", format!("session={SENTINEL_BAD_1}"))
8228 .send()
8229 .await
8230 .unwrap();
8231
8232 assert_eq!(resp.status().as_u16(), 401);
8233 let body = resp.text().await.unwrap();
8234 assert_eq!(body, "Unauthorized");
8235 assert!(
8236 !body.contains(SENTINEL_BAD_1),
8237 "reply body must not contain the credential value"
8238 );
8239 assert!(
8240 !captured_logs_contain(SENTINEL_BAD_1),
8241 "error logs must not render the credential value"
8242 );
8243 }
8244}