1pub mod auth;
2pub mod bundle;
3pub mod config;
4pub mod health;
5pub mod registry;
6pub(crate) mod rest_match;
7pub(crate) mod ssrf;
8pub mod static_config;
9pub mod static_dispatch;
10pub mod static_endpoint;
11pub(crate) mod tls_reload;
12use crate::config::parse_ok_status_code_range;
13pub use bundle::HttpBundle;
14pub use bundle::HttpStaticBundle;
15pub use config::HttpConfig;
16pub use health::HttpHealthCheck;
17pub use registry::HttpRouteRegistry;
18pub use static_config::HttpStaticConfig;
19pub use static_endpoint::{HttpStaticComponent, HttpStaticConsumer, HttpStaticEndpoint};
20
21use std::collections::HashMap;
22use std::future::Future;
23use std::pin::Pin;
24
25use std::sync::{Arc, Mutex, OnceLock};
26use std::task::{Context, Poll};
27use std::time::Duration;
28
29use tokio::sync::OnceCell;
30use tower::Layer;
31use tower::Service;
32use tracing::debug;
33
34use axum::body::BodyDataStream;
35use camel_api::component_metadata::ComponentMetadata;
36use camel_auth::bearer_token_layer::BearerTokenLayer;
37use camel_auth::oauth2::TokenProvider;
38use camel_component_api::tls_source::ServerTlsSource;
39use camel_component_api::{Body, BoxProcessor, CamelError, Exchange, StreamBody, StreamMetadata};
40use camel_component_api::{Component, Consumer, Endpoint, ProducerContext, RuntimeObservability};
41use camel_component_api::{UriComponents, UriConfig, parse_uri};
42use futures::TryStreamExt;
43use futures::stream::BoxStream;
44
45#[derive(Debug, Clone)]
106pub struct HttpEndpointConfig {
107 pub base_url: String,
108 pub http_method: Option<String>,
109 pub throw_exception_on_failure: bool,
110 pub ok_status_code_range: (u16, u16),
111 pub response_timeout: Option<Duration>,
112 pub query_params: HashMap<String, String>,
113 pub allow_internal: bool,
114 pub blocked_hosts: Vec<String>,
115 pub max_body_size: usize,
116 pub read_timeout_ms: u64,
117 pub max_response_bytes: usize,
118 pub auth: HttpAuth,
119 pub token_provider: Option<Arc<dyn TokenProvider>>,
120 pub user_agent: Option<String>,
121 pub bridge_endpoint: bool,
122 pub connection_close: bool,
123 pub skip_request_headers: Vec<String>,
124 pub skip_response_headers: Vec<String>,
125 pub follow_redirects: bool,
126 pub max_redirects: usize,
127}
128
129#[derive(Clone, PartialEq)]
130pub enum HttpAuth {
131 None,
132 Basic { username: String, password: String },
133 Bearer { token: String },
134}
135
136impl std::fmt::Debug for HttpAuth {
137 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138 match self {
139 HttpAuth::None => f.write_str("None"),
140 HttpAuth::Basic { username, .. } => f
141 .debug_struct("Basic")
142 .field("username", username)
143 .field("password", &"***")
144 .finish(),
145 HttpAuth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
146 }
147 }
148}
149
150const HTTP_CAMEL_OPTIONS: &[&str] = &[
152 "httpMethod",
153 "throwExceptionOnFailure",
154 "okStatusCodeRange",
155 "followRedirects",
156 "maxRedirects",
157 "connectTimeout",
158 "responseTimeout",
159 "allowInternal",
160 "blockedHosts",
161 "maxBodySize",
162 "readTimeout",
163 "maxResponseBytes",
164 "authMethod",
165 "authUsername",
166 "authPassword",
167 "authBearerToken",
168 "userAgent",
169 "cookieHandling",
170 "bridgeEndpoint",
171 "connectionClose",
172 "skipRequestHeaders",
173 "skipResponseHeaders",
174];
175
176impl UriConfig for HttpEndpointConfig {
177 fn scheme() -> &'static str {
179 "http"
180 }
181
182 fn from_uri(uri: &str) -> Result<Self, CamelError> {
183 let parts = parse_uri(uri)?;
184 Self::from_components(parts)
185 }
186
187 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
188 if parts.scheme != "http" && parts.scheme != "https" {
190 return Err(CamelError::InvalidUri(format!(
191 "expected scheme 'http' or 'https', got '{}'",
192 parts.scheme
193 )));
194 }
195
196 let base_url = format!("{}:{}", parts.scheme, parts.path);
199
200 let http_method = parts.params.get("httpMethod").cloned();
201
202 let throw_exception_on_failure = match parts.params.get("throwExceptionOnFailure") {
203 Some(v) => parse_bool_param_http(v).map_err(|e| {
204 CamelError::InvalidUri(format!("invalid value for throwExceptionOnFailure: {e}"))
205 })?,
206 None => true,
207 };
208
209 let ok_status_code_range = match parts.params.get("okStatusCodeRange") {
211 Some(v) => parse_ok_status_code_range(v)?,
212 None => (200, 299),
213 };
214
215 let response_timeout = match parts.params.get("responseTimeout") {
216 Some(v) => Some(v.parse::<u64>().map(Duration::from_millis).map_err(|e| {
217 CamelError::InvalidUri(format!("invalid value for responseTimeout: {e}"))
218 })?),
219 None => None,
220 };
221
222 let allow_internal = match parts.params.get("allowInternal") {
224 Some(v) => parse_bool_param_http(v).map_err(|e| {
225 CamelError::InvalidUri(format!("invalid value for allowInternal: {e}"))
226 })?,
227 None => false, };
229
230 let blocked_hosts = parts
232 .params
233 .get("blockedHosts")
234 .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
235 .unwrap_or_default();
236
237 let max_body_size = match parts.params.get("maxBodySize") {
238 Some(v) => v.parse::<usize>().map_err(|e| {
239 CamelError::InvalidUri(format!("invalid value for maxBodySize: {e}"))
240 })?,
241 None => 10 * 1024 * 1024, };
243
244 let read_timeout_ms = match parts.params.get("readTimeout") {
245 Some(v) => v.parse::<u64>().map_err(|e| {
246 CamelError::InvalidUri(format!("invalid value for readTimeout: {e}"))
247 })?,
248 None => 30_000, };
250
251 let max_response_bytes = match parts.params.get("maxResponseBytes") {
252 Some(v) => v.parse::<usize>().map_err(|e| {
253 CamelError::InvalidUri(format!("invalid value for maxResponseBytes: {e}"))
254 })?,
255 None => 10 * 1024 * 1024, };
257
258 let auth = parse_auth_from_params(&parts.params)?;
259
260 let user_agent = parts.params.get("userAgent").cloned();
261
262 if parts.params.contains_key("cookieHandling") {
263 return Err(CamelError::InvalidUri(
264 "cookieHandling is not supported".into(),
265 ));
266 }
267
268 let bridge_endpoint = match parts.params.get("bridgeEndpoint") {
269 Some(v) => parse_bool_param_http(v).map_err(|e| {
270 CamelError::InvalidUri(format!("invalid value for bridgeEndpoint: {e}"))
271 })?,
272 None => false,
273 };
274
275 let connection_close = match parts.params.get("connectionClose") {
276 Some(v) => parse_bool_param_http(v).map_err(|e| {
277 CamelError::InvalidUri(format!("invalid value for connectionClose: {e}"))
278 })?,
279 None => false,
280 };
281
282 let skip_request_headers = parts
283 .params
284 .get("skipRequestHeaders")
285 .map(|v| {
286 v.split(',')
287 .map(str::trim)
288 .filter(|s| !s.is_empty())
289 .map(|s| s.to_ascii_lowercase())
290 .collect::<Vec<_>>()
291 })
292 .unwrap_or_default();
293
294 let skip_response_headers = parts
295 .params
296 .get("skipResponseHeaders")
297 .map(|v| {
298 v.split(',')
299 .map(str::trim)
300 .filter(|s| !s.is_empty())
301 .map(|s| s.to_ascii_lowercase())
302 .collect::<Vec<_>>()
303 })
304 .unwrap_or_default();
305
306 let follow_redirects = match parts.params.get("followRedirects") {
307 Some(v) => parse_bool_param_http(v).map_err(|e| {
308 CamelError::InvalidUri(format!("invalid value for followRedirects: {e}"))
309 })?,
310 None => false,
311 };
312
313 let max_redirects = match parts.params.get("maxRedirects") {
314 Some(v) => v.parse::<usize>().map_err(|e| {
315 CamelError::InvalidUri(format!("invalid value for maxRedirects: {e}"))
316 })?,
317 None => 10,
318 };
319
320 let query_params: HashMap<String, String> = parts
322 .params
323 .into_iter()
324 .filter(|(k, _)| !HTTP_CAMEL_OPTIONS.contains(&k.as_str()))
325 .collect();
326
327 Ok(Self {
328 base_url,
329 http_method,
330 throw_exception_on_failure,
331 ok_status_code_range,
332 response_timeout,
333 query_params,
334 allow_internal,
335 blocked_hosts,
336 max_body_size,
337 read_timeout_ms,
338 max_response_bytes,
339 auth,
340 token_provider: None,
341 user_agent,
342 bridge_endpoint,
343 connection_close,
344 skip_request_headers,
345 skip_response_headers,
346 follow_redirects,
347 max_redirects,
348 })
349 }
350}
351
352#[derive(Debug, Clone, UriConfig)]
359#[allow(dead_code)]
360#[uri_scheme = "http"]
361#[uri_config(
362 skip_impl,
363 metadata(
364 scheme = "http",
365 description = "HTTP client and server component",
366 producer,
367 consumer,
368 streaming
369 ),
370 crate = "camel_component_api"
371)]
372struct HttpEndpointUriConfig {
373 #[allow(dead_code)]
374 _base_url: String,
375
376 #[uri_param(
377 name = "httpMethod",
378 desc = "HTTP method. Defaults to CamelHttpMethod header or POST/GET"
379 )]
380 http_method: Option<String>,
381
382 #[uri_param(
383 name = "throwExceptionOnFailure",
384 default = "true",
385 desc = "Throw on non-2xx status"
386 )]
387 throw_exception_on_failure: bool,
388
389 #[uri_param(
390 name = "okStatusCodeRange",
391 default = "200-299",
392 desc = "Success status code range"
393 )]
394 ok_status_code_range: String,
395
396 #[uri_param(name = "responseTimeout", desc = "Response timeout in milliseconds")]
397 response_timeout: Option<u64>,
398
399 #[uri_param(
400 name = "allowInternal",
401 default = "false",
402 desc = "Allow private/internal network destinations (SSRF)"
403 )]
404 allow_internal: bool,
405
406 #[uri_param(name = "blockedHosts", desc = "Comma-separated blocked host list")]
407 blocked_hosts: Option<String>,
408
409 #[uri_param(
410 name = "maxBodySize",
411 default = "10485760",
412 desc = "Max request/response body bytes"
413 )]
414 max_body_size: u64,
415
416 #[uri_param(name = "readTimeout", desc = "Socket read timeout in milliseconds")]
417 read_timeout: Option<u64>,
418
419 #[uri_param(name = "maxResponseBytes", desc = "Max response body bytes")]
420 max_response_bytes: Option<u64>,
421
422 #[uri_param(
423 name = "authMethod",
424 kind = "enum:Basic,Bearer",
425 desc = "Authentication method"
426 )]
427 auth_method: Option<String>,
428
429 #[uri_param(name = "authUsername", secret, desc = "Basic auth username")]
430 auth_username: Option<String>,
431
432 #[uri_param(name = "authPassword", secret, desc = "Basic auth password")]
433 auth_password: Option<String>,
434
435 #[uri_param(name = "authBearerToken", secret, desc = "Bearer auth token")]
436 auth_bearer_token: Option<String>,
437
438 #[uri_param(name = "userAgent", desc = "User-Agent header")]
439 user_agent: Option<String>,
440
441 #[uri_param(
442 name = "bridgeEndpoint",
443 default = "false",
444 desc = "Bridge endpoint mode"
445 )]
446 bridge_endpoint: bool,
447
448 #[uri_param(
449 name = "connectionClose",
450 default = "false",
451 desc = "Send Connection: close"
452 )]
453 connection_close: bool,
454
455 #[uri_param(
456 name = "skipRequestHeaders",
457 desc = "Comma-separated request headers to skip"
458 )]
459 skip_request_headers: Option<String>,
460
461 #[uri_param(
462 name = "skipResponseHeaders",
463 desc = "Comma-separated response headers to skip"
464 )]
465 skip_response_headers: Option<String>,
466
467 #[uri_param(
468 name = "followRedirects",
469 default = "false",
470 desc = "Follow HTTP redirects"
471 )]
472 follow_redirects: bool,
473
474 #[uri_param(name = "maxRedirects", default = "10", desc = "Max redirect hops")]
475 max_redirects: u64,
476}
477
478impl HttpEndpointConfig {
479 pub fn metadata() -> ComponentMetadata {
482 HttpEndpointUriConfig::metadata()
483 }
484
485 pub fn uri_options() -> Vec<camel_api::component_metadata::UriOption> {
487 HttpEndpointUriConfig::uri_options()
488 }
489}
490
491fn parse_auth_from_params(params: &HashMap<String, String>) -> Result<HttpAuth, CamelError> {
492 let Some(method) = params.get("authMethod") else {
493 return Ok(HttpAuth::None);
494 };
495
496 if method.eq_ignore_ascii_case("none") {
497 return Ok(HttpAuth::None);
498 }
499
500 if method.eq_ignore_ascii_case("basic") {
501 let username = params.get("authUsername").cloned().ok_or_else(|| {
502 CamelError::InvalidUri("authUsername is required for authMethod=Basic".to_string())
503 })?;
504 let password = params.get("authPassword").cloned().ok_or_else(|| {
505 CamelError::InvalidUri("authPassword is required for authMethod=Basic".to_string())
506 })?;
507 return Ok(HttpAuth::Basic { username, password });
508 }
509
510 if method.eq_ignore_ascii_case("bearer") {
511 let token = params.get("authBearerToken").cloned().ok_or_else(|| {
512 CamelError::InvalidUri("authBearerToken is required for authMethod=Bearer".to_string())
513 })?;
514 return Ok(HttpAuth::Bearer { token });
515 }
516
517 Err(CamelError::InvalidUri(format!(
518 "invalid value for authMethod: {method} (expected None, Basic, or Bearer)"
519 )))
520}
521
522fn parse_bool_param_http(value: &str) -> Result<bool, CamelError> {
523 match value.to_ascii_lowercase().as_str() {
524 "true" | "1" | "yes" => Ok(true),
525 "false" | "0" | "no" => Ok(false),
526 _ => Err(CamelError::InvalidUri(format!(
527 "invalid boolean value: '{value}'"
528 ))),
529 }
530}
531
532impl HttpEndpointConfig {
533 pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
534 let parts = parse_uri(uri)?;
535 let mut endpoint = Self::from_components(parts.clone())?;
536 if endpoint.response_timeout.is_none() {
537 endpoint.response_timeout = Some(Duration::from_millis(config.response_timeout_ms));
538 }
539 if !parts.params.contains_key("allowInternal") {
540 endpoint.allow_internal = config.allow_internal;
541 }
542 if !parts.params.contains_key("blockedHosts") {
543 endpoint.blocked_hosts = config.blocked_hosts.clone();
544 }
545 if !parts.params.contains_key("maxBodySize") {
546 endpoint.max_body_size = config.max_body_size;
547 }
548 if !parts.params.contains_key("readTimeout") {
549 endpoint.read_timeout_ms = config.read_timeout_ms;
550 }
551 if !parts.params.contains_key("maxResponseBytes") {
552 endpoint.max_response_bytes = config.max_response_bytes;
553 }
554 if !parts.params.contains_key("okStatusCodeRange")
555 && let Some(range) = &config.ok_status_code_range
556 {
557 endpoint.ok_status_code_range = parse_ok_status_code_range(range)?;
558 }
559 if !parts.params.contains_key("followRedirects") {
560 endpoint.follow_redirects = config.follow_redirects;
561 }
562 if !parts.params.contains_key("maxRedirects") {
563 endpoint.max_redirects = config.max_redirects.unwrap_or(10);
564 }
565
566 Ok(endpoint)
567 }
568}
569
570#[derive(Debug, Clone)]
576pub struct HttpServerConfig {
577 pub scheme: String,
579 pub host: String,
581 pub port: u16,
583 pub path: String,
585 pub max_request_body: usize,
587 pub max_response_body: usize,
589 pub max_inflight_requests: usize,
591 pub method: Option<String>,
598 pub tls_config: Option<crate::config::ServerTlsConfig>,
601}
602
603impl UriConfig for HttpServerConfig {
604 fn scheme() -> &'static str {
606 "http"
607 }
608
609 fn from_uri(uri: &str) -> Result<Self, CamelError> {
610 let parts = parse_uri(uri)?;
611 Self::from_components(parts)
612 }
613
614 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
615 if parts.scheme != "http" && parts.scheme != "https" {
617 return Err(CamelError::InvalidUri(format!(
618 "expected scheme 'http' or 'https', got '{}'",
619 parts.scheme
620 )));
621 }
622
623 let authority_and_path = parts.path.trim_start_matches('/');
626
627 let (authority, path_suffix) = if let Some(idx) = authority_and_path.find('/') {
629 (&authority_and_path[..idx], &authority_and_path[idx..])
630 } else {
631 (authority_and_path, "/")
632 };
633
634 let path = if path_suffix.is_empty() {
635 "/"
636 } else {
637 path_suffix
638 }
639 .to_string();
640
641 let (host, port) = if let Some(colon) = authority.rfind(':') {
643 let port_str = &authority[colon + 1..];
644 match port_str.parse::<u16>() {
645 Ok(p) => (authority[..colon].to_string(), p),
646 Err(_) => {
647 return Err(CamelError::InvalidUri(format!(
648 "invalid port '{}' in authority",
649 port_str
650 )));
651 }
652 }
653 } else {
654 let default_port = if parts.scheme == "https" { 443 } else { 80 };
656 (authority.to_string(), default_port)
657 };
658
659 let max_request_body = parts
660 .params
661 .get("maxRequestBody")
662 .and_then(|v| v.parse::<usize>().ok())
663 .unwrap_or(2 * 1024 * 1024); let max_response_body = parts
666 .params
667 .get("maxResponseBody")
668 .and_then(|v| v.parse::<usize>().ok())
669 .unwrap_or(10 * 1024 * 1024); let max_inflight_requests = parts
672 .params
673 .get("maxInflightRequests")
674 .and_then(|v| v.parse::<usize>().ok())
675 .unwrap_or(1024);
676
677 let method = parts.params.get("httpMethod").map(|m| m.to_uppercase());
683
684 Ok(Self {
685 scheme: parts.scheme,
686 host,
687 port,
688 path,
689 max_request_body,
690 max_response_body,
691 max_inflight_requests,
692 method,
693 tls_config: {
694 let cert = parts.params.get("tlsCert").cloned();
695 let key = parts.params.get("tlsKey").cloned();
696 match (cert, key) {
697 (Some(c), Some(k)) => Some(crate::config::ServerTlsConfig {
698 cert_path: c,
699 key_path: k,
700 }),
701 (None, None) => None,
702 _ => None, }
704 },
705 })
706 }
707}
708
709impl HttpServerConfig {
710 pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
711 let parts = parse_uri(uri)?;
712 let mut server = Self::from_components(parts.clone())?;
713 if !parts.params.contains_key("maxRequestBody") {
714 server.max_request_body = config.max_request_body;
715 }
716 if !parts.params.contains_key("maxResponseBody") {
717 server.max_response_body = config.max_body_size;
719 }
720 Ok(server)
721 }
722}
723
724pub enum HttpReplyBody {
732 Bytes(bytes::Bytes),
733 Stream(BoxStream<'static, Result<bytes::Bytes, CamelError>>),
734}
735
736pub struct RequestEnvelope {
741 pub method: String,
742 pub path: String,
743 pub query: String,
744 pub headers: http::HeaderMap,
745 pub body: StreamBody,
746 pub path_params: std::collections::HashMap<String, String>,
752 pub reply_tx: tokio::sync::oneshot::Sender<HttpReply>,
753}
754
755pub struct HttpReply {
759 pub status: u16,
760 pub headers: Vec<(String, String)>,
761 pub body: HttpReplyBody,
762}
763
764type ServerKey = (String, u16);
769
770struct ServerHandle {
772 registry: HttpRouteRegistry,
773 max_request_body: usize,
774 max_response_body: usize,
775 max_inflight_requests: usize,
776 is_tls: bool,
777 tls_cert_path: Option<String>,
778 tls_key_path: Option<String>,
779 monitor_task: tokio::task::JoinHandle<()>,
782 tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
785 tls_source: Option<ServerTlsSource>,
786}
787
788pub struct ServerRegistry {
790 inner: Mutex<HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>>,
791}
792
793impl ServerRegistry {
794 pub fn global() -> &'static Self {
796 static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
797 INSTANCE.get_or_init(|| ServerRegistry {
798 inner: Mutex::new(HashMap::new()),
799 })
800 }
801
802 #[allow(clippy::too_many_arguments)]
805 pub async fn get_or_spawn(
806 &'static self,
807 host: &str,
808 port: u16,
809 max_request_body: usize,
810 max_response_body: usize,
811 max_inflight_requests: usize,
812 runtime: Arc<dyn RuntimeObservability>,
813 route_id: String,
814 tls_config: Option<crate::config::ServerTlsConfig>,
815 ) -> Result<HttpRouteRegistry, CamelError> {
816 let host_owned = host.to_string();
817
818 let cell = {
819 let mut guard = self.inner.lock().map_err(|_| {
820 CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
821 })?;
822 let key = (host.to_string(), port);
823 if let Some(existing) = guard.get(&key)
827 && let Some(handle) = existing.get()
828 && handle.monitor_task.is_finished()
829 {
830 if handle.is_tls {
833 let scheme = if handle.is_tls { "https" } else { "http" };
834 camel_component_api::tls_source::TlsReloadRegistry::global()
835 .unregister(scheme, host, port);
836 }
837 guard.remove(&key);
838 }
839 guard
840 .entry(key)
841 .or_insert_with(|| Arc::new(OnceCell::new()))
842 .clone()
843 };
844
845 if let Some(existing) = cell.get()
846 && existing.max_request_body != max_request_body
847 {
848 return Err(CamelError::EndpointCreationFailed(format!(
849 "incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
850 existing.max_request_body, max_request_body
851 )));
852 }
853
854 if let Some(existing) = cell.get()
855 && existing.max_response_body != max_response_body
856 {
857 return Err(CamelError::EndpointCreationFailed(format!(
858 "incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
859 existing.max_response_body, max_response_body
860 )));
861 }
862
863 if let Some(existing) = cell.get()
864 && existing.max_inflight_requests != max_inflight_requests
865 {
866 return Err(CamelError::EndpointCreationFailed(format!(
867 "incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
868 existing.max_inflight_requests, max_inflight_requests
869 )));
870 }
871
872 if let Some(existing) = cell.get()
874 && existing.is_tls != tls_config.is_some()
875 {
876 return Err(CamelError::EndpointCreationFailed(format!(
877 "incompatible TLS mode for shared server (host={host}, port={port}): existing is_tls={}, new has_tls={}",
878 existing.is_tls,
879 tls_config.is_some()
880 )));
881 }
882
883 if let (Some(existing), Some(new_tls)) = (cell.get(), &tls_config)
885 && (existing.tls_cert_path.as_deref() != Some(&new_tls.cert_path)
886 || existing.tls_key_path.as_deref() != Some(&new_tls.key_path))
887 {
888 return Err(CamelError::EndpointCreationFailed(format!(
889 "incompatible TLS cert/key for shared server (host={host}, port={port}): routes on the same TLS port must use the same cert and key"
890 )));
891 }
892
893 let handle = cell
894 .get_or_try_init(|| {
895 let rt = Arc::clone(&runtime);
896 let rid = route_id.clone();
897 async move {
898 let addr = format!("{host_owned}:{port}");
899 let listener = tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
900 CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
901 })?;
902 let registry = HttpRouteRegistry::new();
903 let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
904 let tls_rustls_cfg: Option<axum_server::tls_rustls::RustlsConfig>;
907 let tls_source: Option<ServerTlsSource>;
908 let server_task = if let Some(ref tls) = tls_config {
909 let rustls_config = load_tls_config(&tls.cert_path, &tls.key_path)?;
910 let source = ServerTlsSource {
911 cert_path: std::path::PathBuf::from(&tls.cert_path),
912 key_path: std::path::PathBuf::from(&tls.key_path),
913 client_ca_path: None,
914 };
915 let rustls_cfg = axum_server::tls_rustls::RustlsConfig::from_config(
919 std::sync::Arc::new(rustls_config),
920 );
921 tls_rustls_cfg = Some(rustls_cfg.clone());
922 tls_source = Some(source);
923 let std_listener = listener.into_std().map_err(|e| {
925 CamelError::EndpointCreationFailed(format!(
926 "TLS listener conversion: {e}"
927 ))
928 })?;
929 tokio::spawn(run_axum_server_tls(
930 std_listener,
931 rustls_cfg,
932 registry.clone(),
933 max_request_body,
934 max_response_body,
935 Arc::clone(&inflight),
936 Arc::clone(&rt),
937 rid.clone(),
938 ))
939 } else {
940 tls_rustls_cfg = None;
941 tls_source = None;
942 tokio::spawn(run_axum_server(
943 listener,
944 registry.clone(),
945 max_request_body,
946 max_response_body,
947 Arc::clone(&inflight),
948 Arc::clone(&rt),
949 rid.clone(),
950 ))
951 };
952 let addr_for_monitor = format!("{host_owned}:{port}");
953 let monitor_task = tokio::spawn(monitor_axum_task(
954 server_task,
955 addr_for_monitor,
956 Arc::clone(&rt),
957 rid,
958 ));
959 let handle = ServerHandle {
960 registry,
961 max_request_body,
962 max_response_body,
963 max_inflight_requests,
964 is_tls: tls_config.is_some(),
965 tls_cert_path: tls_config.as_ref().map(|t| t.cert_path.clone()),
966 tls_key_path: tls_config.as_ref().map(|t| t.key_path.clone()),
967 monitor_task,
968 tls_config: tls_rustls_cfg,
969 tls_source,
970 };
971 if let (Some(tls_cfg), Some(source)) =
976 (handle.tls_config.as_ref(), handle.tls_source.as_ref())
977 {
978 let handler = Arc::new(crate::tls_reload::HttpReloadHandler::new(
979 tls_cfg.clone(),
980 source.clone(),
981 host_owned.clone(),
982 port,
983 ));
984 camel_component_api::tls_source::TlsReloadRegistry::global()
985 .register(handler);
986 }
987 Ok::<ServerHandle, CamelError>(handle)
988 }
989 })
990 .await?;
991
992 Ok(handle.registry.clone())
993 }
994
995 pub async fn unregister(&self, host: &str, port: u16) {
999 debug!(
1000 host = host,
1001 port = port,
1002 "consumer unregistered from HTTP server"
1003 );
1004 }
1005
1006 #[cfg(test)]
1013 pub fn reset() {
1014 let instance = Self::global();
1015 let mut guard = instance
1016 .inner
1017 .lock()
1018 .expect("ServerRegistry lock poisoned during test reset");
1019 guard.clear();
1020 }
1021}
1022
1023use axum::{
1028 Router,
1029 body::Body as AxumBody,
1030 extract::{Request, State},
1031 http::{Response, StatusCode},
1032 response::IntoResponse,
1033};
1034
1035#[derive(Clone)]
1036pub(crate) struct AppState {
1037 registry: HttpRouteRegistry,
1038 max_request_body: usize,
1039 max_response_body: usize,
1040 inflight: Arc<tokio::sync::Semaphore>,
1041}
1042
1043async fn run_axum_server(
1044 listener: tokio::net::TcpListener,
1045 registry: HttpRouteRegistry,
1046 max_request_body: usize,
1047 max_response_body: usize,
1048 inflight: Arc<tokio::sync::Semaphore>,
1049 runtime: Arc<dyn RuntimeObservability>,
1050 route_id: String,
1051) {
1052 let state = AppState {
1053 registry,
1054 max_request_body,
1055 max_response_body,
1056 inflight,
1057 };
1058 let app = Router::new().fallback(dispatch_handler).with_state(state);
1059
1060 axum::serve(listener, app).await.unwrap_or_else(|e| {
1061 runtime
1062 .metrics()
1063 .increment_errors(&route_id, "e:http:accept");
1064 tracing::error!(error = %e, "Axum server error");
1066 });
1067}
1068
1069#[allow(clippy::too_many_arguments)]
1070async fn run_axum_server_tls(
1071 listener: std::net::TcpListener,
1072 tls_cfg: axum_server::tls_rustls::RustlsConfig,
1073 registry: HttpRouteRegistry,
1074 max_request_body: usize,
1075 max_response_body: usize,
1076 inflight: Arc<tokio::sync::Semaphore>,
1077 runtime: Arc<dyn RuntimeObservability>,
1078 route_id: String,
1079) {
1080 let state = AppState {
1081 registry,
1082 max_request_body,
1083 max_response_body,
1084 inflight,
1085 };
1086 let app = Router::new().fallback(dispatch_handler).with_state(state);
1087
1088 axum_server::from_tcp_rustls(listener, tls_cfg)
1092 .serve(app.into_make_service())
1093 .await
1094 .unwrap_or_else(|e| {
1095 runtime
1096 .metrics()
1097 .increment_errors(&route_id, "e:http:accept-tls");
1098 tracing::error!(error = %e, "Axum TLS server error");
1100 });
1101}
1102
1103async fn monitor_axum_task(
1111 handle: tokio::task::JoinHandle<()>,
1112 addr: String,
1113 runtime: Arc<dyn RuntimeObservability>,
1114 route_id: String,
1115) {
1116 match handle.await {
1117 Ok(()) => {
1118 }
1120 Err(join_err) => {
1121 runtime
1122 .metrics()
1123 .increment_errors(&route_id, "e:http:server-task-exited");
1124 tracing::error!(
1126 addr = %addr,
1127 error = %join_err,
1128 "Axum server task exited unexpectedly — all routes on this port are now dead"
1129 );
1130 }
1131 }
1132}
1133
1134fn load_tls_config(
1137 cert_path: &str,
1138 key_path: &str,
1139) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
1140 use std::fs::File;
1141 use std::io::BufReader;
1142
1143 let cert_file = File::open(cert_path)
1144 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
1145 let key_file = File::open(key_path)
1146 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
1147
1148 let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
1149 .collect::<Result<Vec<_>, _>>()
1150 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
1151
1152 let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
1153 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
1154 .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
1155
1156 tokio_rustls::rustls::ServerConfig::builder()
1157 .with_no_client_auth()
1158 .with_single_cert(certs, key)
1159 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
1160}
1161
1162async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
1163 let path = req.uri().path().to_owned();
1164 let method = req.method().to_string();
1165
1166 let api_sender = {
1183 let inner = state.registry.inner.read().await;
1184 inner.api_routes.get(&path).cloned()
1185 }; let (rest_sender, path_params) = if api_sender.is_some() {
1188 (None, Default::default())
1190 } else {
1191 let inner = state.registry.inner.read().await;
1192 match rest_match::match_endpoint(&method, &path, &inner.rest_endpoints) {
1193 rest_match::MatchOutcome::Found(m) => (Some(m.payload), m.path_params),
1194 rest_match::MatchOutcome::Ambiguous => {
1195 tracing::warn!(
1201 method = %method,
1202 path = %path,
1203 "ambiguous REST template match — returning 500"
1204 );
1205 return Response::builder()
1206 .status(StatusCode::INTERNAL_SERVER_ERROR)
1207 .body(AxumBody::from("Internal Server Error"))
1208 .expect("infallible"); }
1210 rest_match::MatchOutcome::NotFound => (None, Default::default()),
1211 }
1212 }; let sender = api_sender.or(rest_sender);
1215
1216 if let Some(sender) = sender {
1217 let query = req.uri().query().unwrap_or("").to_string();
1218 let headers = req.headers().clone();
1219
1220 let content_length: Option<u64> = headers
1222 .get(http::header::CONTENT_LENGTH)
1223 .and_then(|v| v.to_str().ok())
1224 .and_then(|s| s.parse().ok());
1225
1226 if let Some(len) = content_length
1227 && len > state.max_request_body as u64
1228 {
1229 return Response::builder()
1230 .status(StatusCode::PAYLOAD_TOO_LARGE)
1231 .body(AxumBody::from("Request body exceeds configured limit"))
1232 .expect("infallible"); }
1234
1235 let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
1236 Ok(permit) => permit,
1237 Err(_) => {
1238 return Response::builder()
1239 .status(StatusCode::SERVICE_UNAVAILABLE)
1240 .body(AxumBody::from("Service Unavailable"))
1241 .expect("infallible"); }
1243 };
1244
1245 let content_type = headers
1247 .get(http::header::CONTENT_TYPE)
1248 .and_then(|v| v.to_str().ok())
1249 .map(|s| s.to_string());
1250
1251 let data_stream: BodyDataStream = req.into_body().into_data_stream();
1252 let mapped_stream = data_stream.map_err(|e| CamelError::Io(e.to_string()));
1253 let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(mapped_stream);
1254
1255 let stream_body = StreamBody {
1256 stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
1257 metadata: StreamMetadata {
1258 size_hint: content_length,
1259 content_type,
1260 origin: None,
1261 },
1262 };
1263
1264 let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
1265 let envelope = RequestEnvelope {
1266 method,
1267 path,
1268 query,
1269 headers,
1270 body: stream_body,
1271 path_params,
1272 reply_tx,
1273 };
1274
1275 if sender.send(envelope).await.is_err() {
1276 return Response::builder()
1277 .status(StatusCode::SERVICE_UNAVAILABLE)
1278 .body(AxumBody::from("Consumer unavailable"))
1279 .expect("infallible"); }
1281
1282 match reply_rx.await {
1283 Ok(reply) => {
1284 let reply = match reply.body {
1285 HttpReplyBody::Bytes(b)
1286 if exceeds_max_response_body(b.len(), state.max_response_body) =>
1287 {
1288 HttpReply {
1289 status: 500,
1290 headers: vec![],
1291 body: HttpReplyBody::Bytes(bytes::Bytes::from(
1292 "Response body exceeds configured limit",
1293 )),
1294 }
1295 }
1296 _ => reply,
1297 };
1298
1299 let status =
1300 StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1301 let mut builder = Response::builder().status(status);
1302 for (k, v) in &reply.headers {
1303 builder = builder.header(k.as_str(), v.as_str());
1304 }
1305 match reply.body {
1306 HttpReplyBody::Bytes(b) => {
1307 builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
1308 Response::builder()
1309 .status(StatusCode::INTERNAL_SERVER_ERROR)
1310 .body(AxumBody::from("Invalid response headers from consumer"))
1311 .expect("infallible") })
1313 }
1314 HttpReplyBody::Stream(stream) => builder
1315 .body(AxumBody::from_stream(stream))
1316 .unwrap_or_else(|_| {
1317 Response::builder()
1318 .status(StatusCode::INTERNAL_SERVER_ERROR)
1319 .body(AxumBody::from("Invalid response headers from consumer"))
1320 .expect("infallible") }),
1322 }
1323 }
1324 Err(_) => Response::builder()
1325 .status(StatusCode::INTERNAL_SERVER_ERROR)
1326 .body(AxumBody::from("Pipeline error"))
1327 .expect("infallible"), }
1329 } else {
1330 static_dispatch::dispatch_static(&state, req, &path).await
1332 }
1333}
1334
1335fn exceeds_max_response_body(len: usize, max: usize) -> bool {
1336 len > max
1337}
1338
1339fn title_case_header(name: &str) -> String {
1340 name.split('-')
1341 .map(|part| {
1342 let mut chars = part.chars();
1343 match chars.next() {
1344 None => String::new(),
1345 Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
1346 }
1347 })
1348 .collect::<Vec<_>>()
1349 .join("-")
1350}
1351
1352pub struct HttpConsumer {
1357 config: HttpServerConfig,
1358 runtime: Arc<dyn RuntimeObservability>,
1360}
1361
1362impl HttpConsumer {
1363 pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
1364 Self { config, runtime }
1365 }
1366}
1367
1368#[async_trait::async_trait]
1369impl Consumer for HttpConsumer {
1370 async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
1371 use camel_component_api::{Body, Exchange, Message};
1372
1373 let registry = ServerRegistry::global()
1374 .get_or_spawn(
1375 &self.config.host,
1376 self.config.port,
1377 self.config.max_request_body,
1378 self.config.max_response_body,
1379 self.config.max_inflight_requests,
1380 self.runtime.clone(),
1381 ctx.route_id().to_string(),
1382 self.config.tls_config.clone(),
1383 )
1384 .await?;
1385
1386 let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(64);
1388 if let Some(method) = self.config.method.clone() {
1395 let segments = rest_match::parse_path_template(&self.config.path);
1396 registry
1397 .register_rest_endpoint(method, segments, env_tx)
1398 .await;
1399 } else {
1400 registry
1401 .register_api_route(self.config.path.clone(), env_tx)
1402 .await;
1403 }
1404
1405 ctx.mark_ready();
1414
1415 let path = self.config.path.clone();
1416 let registry_for_cleanup = registry.clone();
1417 let cancel_token = ctx.cancel_token();
1418 loop {
1419 tokio::select! {
1420 _ = ctx.cancelled() => {
1421 break;
1422 }
1423 envelope = env_rx.recv() => {
1424 let Some(envelope) = envelope else { break; };
1425
1426 let mut msg = Message::default();
1428
1429 msg.set_header("CamelHttpMethod",
1431 serde_json::Value::String(envelope.method.clone()));
1432 msg.set_header("CamelHttpPath",
1433 serde_json::Value::String(envelope.path.clone()));
1434 msg.set_header("CamelHttpQuery",
1435 serde_json::Value::String(envelope.query.clone()));
1436
1437 for (param_name, param_value) in &envelope.path_params {
1444 msg.set_header(
1445 format!("CamelHttpPath_{param_name}"),
1446 serde_json::Value::String(param_value.clone()),
1447 );
1448 }
1449
1450 for (k, v) in &envelope.headers {
1452 if let Ok(val_str) = v.to_str() {
1453 msg.set_header(
1454 title_case_header(k.as_str()),
1455 serde_json::Value::String(val_str.to_string()),
1456 );
1457 }
1458 }
1459
1460 msg.body = Body::Stream(envelope.body);
1463
1464 #[allow(unused_mut)]
1465 let mut exchange = Exchange::new(msg);
1466
1467 #[cfg(feature = "otel")]
1469 {
1470 let headers: HashMap<String, String> = envelope
1471 .headers
1472 .iter()
1473 .filter_map(|(k, v)| {
1474 Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1475 })
1476 .collect();
1477 camel_otel::extract_into_exchange(&mut exchange, &headers);
1478 }
1479
1480 let reply_tx = envelope.reply_tx;
1481 let sender = ctx.sender().clone();
1482 let path_clone = path.clone();
1483 let cancel = cancel_token.clone();
1484
1485 tokio::spawn(async move {
1505 if cancel.is_cancelled() {
1513 let _ = reply_tx.send(HttpReply {
1514 status: 503,
1515 headers: vec![],
1516 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
1517 });
1518 return;
1519 }
1520
1521 let (tx, rx) = tokio::sync::oneshot::channel();
1523 let envelope = camel_component_api::consumer::ExchangeEnvelope {
1524 exchange,
1525 reply_tx: Some(tx),
1526 };
1527
1528 let result = match sender.send(envelope).await {
1529 Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
1530 Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
1531 }
1532 .and_then(|r| r);
1533
1534 let reply = match result {
1535 Ok(out) => {
1536 let status = out
1537 .input
1538 .header("CamelHttpResponseCode")
1539 .and_then(|v| {
1540 let raw = v.as_u64()
1541 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
1542 let code = raw as u16;
1543 (100..1000).contains(&code).then_some(code)
1544 })
1545 .unwrap_or(200);
1546
1547 let user_content_type = out
1548 .input
1549 .header("Content-Type")
1550 .and_then(|v| v.as_str().map(|s| s.to_string()));
1551
1552 let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
1553 Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
1554 Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
1555 Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
1556 Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
1557 v.to_string().into_bytes(),
1558 )), Some("application/json".to_string())),
1559 Body::Stream(s) => {
1560 let ct = s.metadata.content_type.clone();
1561 match s.stream.lock().await.take() {
1562 Some(stream) => (
1563 HttpReplyBody::Stream(stream),
1564 ct,
1565 ),
1566 None => {
1567 tracing::error!(
1569 "Body::Stream already consumed before HTTP reply — returning 500"
1570 );
1571 let error_reply = HttpReply {
1572 status: 500,
1573 headers: vec![],
1574 body: HttpReplyBody::Bytes(bytes::Bytes::new()),
1575 };
1576 if reply_tx.send(error_reply).is_err() {
1577 debug!("reply_tx dropped before error reply could be sent");
1578 }
1579 return;
1580 }
1581 }
1582 }
1583 _ => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
1585 };
1586
1587 let mut resp_headers: Vec<(String, String)> = out
1588 .input
1589 .headers
1590 .iter()
1591 .filter(|(k, _)| !k.starts_with("Camel"))
1592 .filter(|(k, _)| {
1593 !matches!(
1594 k.to_lowercase().as_str(),
1595 "content-length"
1596 | "content-type"
1597 | "transfer-encoding"
1598 | "connection"
1599 | "cache-control"
1600 | "date"
1601 | "pragma"
1602 | "trailer"
1603 | "upgrade"
1604 | "via"
1605 | "warning"
1606 | "host"
1607 | "user-agent"
1608 | "accept"
1609 | "accept-encoding"
1610 | "accept-language"
1611 | "accept-charset"
1612 | "authorization"
1613 | "proxy-authorization"
1614 | "cookie"
1615 | "expect"
1616 | "from"
1617 | "if-match"
1618 | "if-modified-since"
1619 | "if-none-match"
1620 | "if-range"
1621 | "if-unmodified-since"
1622 | "max-forwards"
1623 | "proxy-connection"
1624 | "range"
1625 | "referer"
1626 | "te"
1627 )
1628 })
1629 .filter_map(|(k, v)| {
1630 v.as_str().map(|s| (k.clone(), s.to_string()))
1631 })
1632 .collect();
1633
1634 let content_type = user_content_type
1635 .or(inferred_content_type);
1636 if let Some(ct) = content_type {
1637 resp_headers.push(("Content-Type".to_string(), ct));
1638 }
1639
1640 HttpReply {
1641 status,
1642 headers: resp_headers,
1643 body: reply_body,
1644 }
1645 }
1646 Err(e) => {
1647 pipeline_error_to_reply(e, &path_clone)
1648 }
1649 };
1650
1651 let _ = reply_tx.send(reply);
1653 });
1654 }
1655 }
1656 }
1657
1658 if let Some(method) = &self.config.method {
1663 registry_for_cleanup
1664 .unregister_rest_endpoint(method, &path)
1665 .await;
1666 } else {
1667 registry_for_cleanup.unregister_api_route(&path).await;
1668 }
1669
1670 ServerRegistry::global()
1674 .unregister(&self.config.host, self.config.port)
1675 .await;
1676
1677 Ok(())
1678 }
1679
1680 async fn stop(&mut self) -> Result<(), CamelError> {
1681 Ok(())
1682 }
1683
1684 fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
1685 camel_component_api::ConcurrencyModel::Concurrent { max: None }
1686 }
1687
1688 fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
1695 camel_component_api::ConsumerStartupMode::Explicit
1696 }
1697}
1698
1699pub struct HttpComponent {
1704 config: HttpConfig,
1705}
1706
1707pub(crate) fn build_client(
1708 config: &HttpConfig,
1709 resolve_override: Option<(&str, &[std::net::SocketAddr])>,
1710) -> reqwest::Client {
1711 let mut builder = reqwest::Client::builder()
1712 .no_proxy() .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
1714 .pool_max_idle_per_host(config.pool_max_idle_per_host)
1715 .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
1716
1717 builder = builder.redirect(reqwest::redirect::Policy::none());
1721
1722 if let Some((host, addrs)) = resolve_override {
1723 builder = builder.resolve_to_addrs(host, addrs);
1724 }
1725
1726 if let Some(tls) = &config.tls
1727 && tls.enabled
1728 {
1729 if tls.insecure || !tls.verify_peer {
1730 tracing::warn!("HTTP TLS verification disabled — connections are vulnerable to MitM");
1732 builder = builder.danger_accept_invalid_certs(true);
1733 }
1734
1735 if let Some(ca_path) = &tls.ca_cert_path
1736 && let Ok(ca_bytes) = std::fs::read(ca_path)
1737 {
1738 let cert = reqwest::Certificate::from_pem(&ca_bytes)
1739 .or_else(|_| reqwest::Certificate::from_der(&ca_bytes));
1740 if let Ok(ca_cert) = cert {
1741 builder = builder.add_root_certificate(ca_cert);
1742 }
1743 }
1744
1745 if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path)
1746 && let (Ok(cert_bytes), Ok(key_bytes)) =
1747 (std::fs::read(cert_path), std::fs::read(key_path))
1748 {
1749 let mut identity_pem = cert_bytes;
1750 identity_pem.extend_from_slice(&key_bytes);
1751 if let Ok(identity) = reqwest::Identity::from_pem(&identity_pem) {
1752 builder = builder.identity(identity);
1753 }
1754 }
1755 }
1756
1757 builder
1758 .build()
1759 .expect("reqwest::Client::build() with valid config should not fail") }
1761
1762impl HttpComponent {
1763 pub fn new() -> Self {
1764 let config = HttpConfig::default();
1765 Self { config }
1766 }
1767
1768 pub fn with_config(config: HttpConfig) -> Self {
1769 Self { config }
1770 }
1771
1772 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
1773 match config {
1774 Some(cfg) => Self::with_config(cfg),
1775 None => Self::new(),
1776 }
1777 }
1778}
1779
1780impl Default for HttpComponent {
1781 fn default() -> Self {
1782 Self::new()
1783 }
1784}
1785
1786impl Component for HttpComponent {
1787 fn scheme(&self) -> &str {
1788 "http"
1789 }
1790
1791 fn metadata(&self) -> ComponentMetadata {
1792 HttpEndpointConfig::metadata()
1793 }
1794
1795 fn create_endpoint(
1796 &self,
1797 uri: &str,
1798 ctx: &dyn camel_component_api::ComponentContext,
1799 ) -> Result<Box<dyn Endpoint>, CamelError> {
1800 self.config.validate()?;
1801 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
1802 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
1803 let client = build_client(&self.config, None);
1804 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
1805 server_config.host.clone(),
1806 server_config.port,
1807 )));
1808 Ok(Box::new(HttpEndpoint {
1809 uri: uri.to_string(),
1810 config,
1811 server_config,
1812 client,
1813 http_config: self.config.clone(),
1814 }))
1815 }
1816}
1817
1818pub struct HttpsComponent {
1819 config: HttpConfig,
1820}
1821
1822impl HttpsComponent {
1823 pub fn new() -> Self {
1824 let config = HttpConfig::default();
1825 Self { config }
1826 }
1827
1828 pub fn with_config(config: HttpConfig) -> Self {
1829 Self { config }
1830 }
1831
1832 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
1833 match config {
1834 Some(cfg) => Self::with_config(cfg),
1835 None => Self::new(),
1836 }
1837 }
1838}
1839
1840impl Default for HttpsComponent {
1841 fn default() -> Self {
1842 Self::new()
1843 }
1844}
1845
1846impl Component for HttpsComponent {
1847 fn scheme(&self) -> &str {
1848 "https"
1849 }
1850
1851 fn metadata(&self) -> ComponentMetadata {
1852 let mut meta = HttpEndpointConfig::metadata();
1855 meta.scheme = "https".to_string();
1856 meta.description = "HTTPS client and server component (TLS over HTTP)".to_string();
1857 meta
1858 }
1859
1860 fn create_endpoint(
1861 &self,
1862 uri: &str,
1863 ctx: &dyn camel_component_api::ComponentContext,
1864 ) -> Result<Box<dyn Endpoint>, CamelError> {
1865 self.config.validate()?;
1866 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
1867 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
1868 let client = build_client(&self.config, None);
1869 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
1870 server_config.host.clone(),
1871 server_config.port,
1872 )));
1873 Ok(Box::new(HttpEndpoint {
1874 uri: uri.to_string(),
1875 config,
1876 server_config,
1877 client,
1878 http_config: self.config.clone(),
1879 }))
1880 }
1881}
1882
1883struct HttpEndpoint {
1888 uri: String,
1889 config: HttpEndpointConfig,
1890 server_config: HttpServerConfig,
1891 client: reqwest::Client,
1892 http_config: HttpConfig,
1893}
1894
1895impl Endpoint for HttpEndpoint {
1896 fn uri(&self) -> &str {
1897 &self.uri
1898 }
1899
1900 fn create_consumer(
1901 &self,
1902 rt: Arc<dyn camel_component_api::RuntimeObservability>,
1903 ) -> Result<Box<dyn Consumer>, CamelError> {
1904 let scheme_is_https = self.server_config.scheme == "https";
1907 let has_tls = self.server_config.tls_config.is_some();
1908
1909 if scheme_is_https && !has_tls {
1910 return Err(CamelError::EndpointCreationFailed(
1911 "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
1912 ));
1913 }
1914 if !scheme_is_https && has_tls {
1915 return Err(CamelError::EndpointCreationFailed(
1916 "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
1917 ));
1918 }
1919 Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
1920 }
1921
1922 fn create_producer(
1923 &self,
1924 _rt: Arc<dyn camel_component_api::RuntimeObservability>,
1925 _ctx: &ProducerContext,
1926 ) -> Result<BoxProcessor, CamelError> {
1927 let producer = HttpProducer {
1928 config: Arc::new(self.config.clone()),
1929 client: self.client.clone(),
1930 http_config: Arc::new(self.http_config.clone()),
1931 };
1932 if let Some(ref provider) = self.config.token_provider {
1933 let layer = BearerTokenLayer::new(Arc::clone(provider));
1934 Ok(BoxProcessor::new(layer.layer(producer)))
1935 } else {
1936 Ok(BoxProcessor::new(producer))
1937 }
1938 }
1939}
1940
1941#[derive(Clone)]
1946struct HttpProducer {
1947 config: Arc<HttpEndpointConfig>,
1948 client: reqwest::Client,
1949 http_config: Arc<HttpConfig>,
1950}
1951
1952impl HttpProducer {
1953 fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
1954 if let Some(ref method) = config.http_method {
1955 return method.to_uppercase();
1956 }
1957 if let Some(method) = exchange
1958 .input
1959 .header("CamelHttpMethod")
1960 .and_then(|v| v.as_str())
1961 {
1962 return method.to_uppercase();
1963 }
1964 if !exchange.input.body.is_empty() {
1965 return "POST".to_string();
1966 }
1967 "GET".to_string()
1968 }
1969
1970 fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
1971 if let Some(uri) = exchange
1972 .input
1973 .header("CamelHttpUri")
1974 .and_then(|v| v.as_str())
1975 {
1976 let mut url = uri.to_string();
1977 if let Some(path) = exchange
1978 .input
1979 .header("CamelHttpPath")
1980 .and_then(|v| v.as_str())
1981 {
1982 if !url.ends_with('/') && !path.starts_with('/') {
1983 url.push('/');
1984 }
1985 url.push_str(path);
1986 }
1987 if let Some(query) = exchange
1988 .input
1989 .header("CamelHttpQuery")
1990 .and_then(|v| v.as_str())
1991 {
1992 url.push('?');
1993 url.push_str(query);
1994 }
1995 return url;
1996 }
1997
1998 let mut url = config.base_url.clone();
1999
2000 if let Some(path) = exchange
2001 .input
2002 .header("CamelHttpPath")
2003 .and_then(|v| v.as_str())
2004 {
2005 if !url.ends_with('/') && !path.starts_with('/') {
2006 url.push('/');
2007 }
2008 url.push_str(path);
2009 }
2010
2011 if let Some(query) = exchange
2012 .input
2013 .header("CamelHttpQuery")
2014 .and_then(|v| v.as_str())
2015 {
2016 url.push('?');
2017 url.push_str(query);
2018 } else if !config.query_params.is_empty() {
2019 let mut parsed = url::Url::parse(&url).expect("base URL must be valid"); for (k, v) in &config.query_params {
2021 parsed.query_pairs_mut().append_pair(k, v);
2022 }
2023 url = parsed.to_string();
2024 }
2025
2026 url
2027 }
2028
2029 fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
2030 status >= range.0 && status <= range.1
2031 }
2032}
2033
2034impl Service<Exchange> for HttpProducer {
2035 type Response = Exchange;
2036 type Error = CamelError;
2037 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
2038
2039 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
2040 Poll::Ready(Ok(()))
2041 }
2042
2043 fn call(&mut self, mut exchange: Exchange) -> Self::Future {
2044 let config = self.config.clone();
2045 let client = self.client.clone();
2046 let http_config = self.http_config.clone();
2047
2048 Box::pin(async move {
2049 let method_str = HttpProducer::resolve_method(&exchange, &config);
2050 let url = HttpProducer::resolve_url(&exchange, &config);
2051
2052 ssrf::validate_url_for_ssrf(&url, &config)?;
2054
2055 let resolved = ssrf::resolve_initial_url_for_ssrf(&url, config.allow_internal).await?;
2060 let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
2061 build_client(&http_config, Some((host.as_str(), addrs)))
2062 } else {
2063 client
2064 };
2065
2066 debug!(
2067 correlation_id = %exchange.correlation_id(),
2068 method = %method_str,
2069 url = %url,
2070 "HTTP request"
2071 );
2072
2073 let method = method_str.parse::<reqwest::Method>().map_err(|e| {
2074 CamelError::ProcessorError(format!("Invalid HTTP method '{}': {}", method_str, e))
2075 })?;
2076
2077 let mut collected_headers: Vec<(
2079 reqwest::header::HeaderName,
2080 reqwest::header::HeaderValue,
2081 )> = Vec::new();
2082
2083 if let Some(user_agent) = &config.user_agent
2084 && !config.bridge_endpoint
2085 && let Ok(val) = reqwest::header::HeaderValue::from_str(user_agent)
2086 {
2087 collected_headers.push((reqwest::header::USER_AGENT, val));
2088 }
2089
2090 #[cfg(feature = "otel")]
2092 let should_inject_otel = !config.bridge_endpoint;
2093 #[cfg(feature = "otel")]
2094 if should_inject_otel {
2095 let mut otel_headers = HashMap::new();
2096 camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
2097 for (k, v) in otel_headers {
2098 if let (Ok(name), Ok(val)) = (
2099 reqwest::header::HeaderName::from_bytes(k.as_bytes()),
2100 reqwest::header::HeaderValue::from_str(&v),
2101 ) {
2102 collected_headers.push((name, val));
2103 }
2104 }
2105 }
2106
2107 for (key, value) in &exchange.input.headers {
2108 if !key.starts_with("Camel")
2109 && !config
2110 .skip_request_headers
2111 .iter()
2112 .any(|h| h.eq_ignore_ascii_case(key))
2113 && let Some(val_str) = value.as_str()
2114 && let (Ok(name), Ok(val)) = (
2115 reqwest::header::HeaderName::from_bytes(key.as_bytes()),
2116 reqwest::header::HeaderValue::from_str(val_str),
2117 )
2118 {
2119 collected_headers.push((name, val));
2120 }
2121 }
2122
2123 if !config.bridge_endpoint {
2125 match &config.auth {
2126 HttpAuth::None => {}
2127 HttpAuth::Basic { username, password } => {
2128 use base64::Engine;
2129 let credentials = format!("{username}:{password}");
2131 let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
2132 if let Ok(val) =
2133 reqwest::header::HeaderValue::from_str(&format!("Basic {encoded}"))
2134 {
2135 collected_headers.push((reqwest::header::AUTHORIZATION, val));
2136 }
2137 }
2138 HttpAuth::Bearer { token } => {
2139 let bearer = format!("Bearer {token}");
2141 if let Ok(val) = reqwest::header::HeaderValue::from_str(&bearer) {
2142 collected_headers.push((reqwest::header::AUTHORIZATION, val));
2143 }
2144 }
2145 }
2146
2147 if config.connection_close
2148 && let Ok(val) = reqwest::header::HeaderValue::from_str("close")
2149 {
2150 collected_headers.push((reqwest::header::CONNECTION, val));
2151 }
2152 }
2153
2154 let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
2156 let materialized_body: Option<Vec<u8>> = if is_stream_body {
2157 None } else {
2159 let body = std::mem::take(&mut exchange.input.body);
2160 let bytes = body.into_bytes(config.max_body_size).await?;
2161 if bytes.is_empty() {
2162 None
2163 } else {
2164 Some(bytes.to_vec())
2165 }
2166 };
2167
2168 let response = if config.follow_redirects && !is_stream_body {
2169 ssrf::send_with_ssrf_safe_redirects(
2171 &client,
2172 &http_config,
2173 &config,
2174 method,
2175 &url,
2176 collected_headers,
2177 materialized_body,
2178 config.max_redirects,
2179 config.response_timeout,
2180 )
2181 .await?
2182 } else {
2183 let mut request = client.request(method, &url);
2185
2186 if let Some(timeout) = config.response_timeout {
2187 request = request.timeout(timeout);
2188 }
2189
2190 for (name, value) in &collected_headers {
2191 request = request.header(name, value);
2192 }
2193
2194 if is_stream_body {
2195 if let Body::Stream(ref s) = exchange.input.body {
2196 let mut stream_lock = s.stream.lock().await;
2197 if let Some(stream) = stream_lock.take() {
2198 request = request.body(reqwest::Body::wrap_stream(stream));
2199 } else {
2200 return Err(CamelError::AlreadyConsumed);
2201 }
2202 }
2203 } else if let Some(ref body_bytes) = materialized_body {
2204 request = request.body(body_bytes.clone());
2205 }
2206
2207 request
2208 .send()
2209 .await
2210 .map_err(|e| CamelError::ProcessorError(format!("HTTP request failed: {e}")))?
2211 };
2212
2213 let status_code = response.status().as_u16();
2214 let status_text = response
2215 .status()
2216 .canonical_reason()
2217 .unwrap_or("Unknown")
2218 .to_string();
2219
2220 for (key, value) in response.headers() {
2221 if config
2222 .skip_response_headers
2223 .iter()
2224 .any(|h| h.eq_ignore_ascii_case(key.as_str()))
2225 {
2226 continue;
2227 }
2228 if let Ok(val_str) = value.to_str() {
2229 exchange.input.set_header(
2230 title_case_header(key.as_str()),
2231 serde_json::Value::String(val_str.to_string()),
2232 );
2233 }
2234 }
2235
2236 exchange.input.set_header(
2237 "CamelHttpResponseCode",
2238 serde_json::Value::Number(status_code.into()),
2239 );
2240 exchange.input.set_header(
2241 "CamelHttpResponseText",
2242 serde_json::Value::String(status_text.clone()),
2243 );
2244
2245 let read_timeout = Duration::from_millis(config.read_timeout_ms);
2247 let response_body = tokio::time::timeout(read_timeout, async {
2248 if let Some(content_len) = response.content_length()
2250 && content_len > config.max_response_bytes as u64
2251 {
2252 return Err(CamelError::ProcessorError(format!(
2253 "Response body too large: {} bytes exceeds limit of {} bytes",
2254 content_len, config.max_response_bytes
2255 )));
2256 }
2257 use futures::TryStreamExt;
2259 let mut stream = response.bytes_stream();
2260 let mut total: usize = 0;
2261 let mut collected = Vec::new();
2262 while let Some(chunk) = stream.try_next().await.map_err(|e| {
2263 CamelError::ProcessorError(format!("Failed to read response body: {e}"))
2264 })? {
2265 total += chunk.len();
2266 if total > config.max_response_bytes {
2267 return Err(CamelError::ProcessorError(format!(
2268 "Response body too large: {} bytes exceeds limit of {} bytes",
2269 total, config.max_response_bytes
2270 )));
2271 }
2272 collected.push(chunk);
2273 }
2274 let mut result = bytes::BytesMut::with_capacity(total);
2275 for chunk in collected {
2276 result.extend_from_slice(&chunk);
2277 }
2278 Ok::<bytes::Bytes, CamelError>(result.freeze())
2279 })
2280 .await
2281 .map_err(|_| {
2282 CamelError::ProcessorError(format!(
2283 "Read timeout after {}ms",
2284 config.read_timeout_ms
2285 ))
2286 })??;
2287
2288 if config.throw_exception_on_failure
2289 && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
2290 {
2291 return Err(CamelError::HttpOperationFailed {
2292 method: method_str,
2293 url,
2294 status_code,
2295 status_text,
2296 response_body: Some(String::from_utf8_lossy(&response_body).to_string()),
2297 });
2298 }
2299
2300 if !response_body.is_empty() {
2301 exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
2302 }
2303
2304 debug!(
2305 correlation_id = %exchange.correlation_id(),
2306 status = status_code,
2307 url = %url,
2308 "HTTP response"
2309 );
2310 Ok(exchange)
2311 })
2312 }
2313}
2314
2315#[cfg(test)]
2325pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
2326
2327fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
2335 match e {
2336 CamelError::Unauthenticated(msg) => {
2337 tracing::warn!(error = %msg, path = %path, "Authentication failed");
2338 HttpReply {
2339 status: 401,
2340 headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
2341 body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
2342 }
2343 }
2344 CamelError::Unauthorized(msg) => {
2345 tracing::warn!(error = %msg, path = %path, "Authorization failed");
2346 HttpReply {
2347 status: 403,
2348 headers: vec![],
2349 body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
2350 }
2351 }
2352 CamelError::TypeConversionFailed(msg) => {
2353 tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
2354 let body = serde_json::to_string(&serde_json::json!({
2355 "error": "bad_request",
2356 "message": msg,
2357 }))
2358 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
2360 status: 400,
2361 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
2362 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
2363 }
2364 }
2365 CamelError::ValidationError(msg) => {
2366 tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
2367 let body = serde_json::to_string(&serde_json::json!({
2368 "error": "validation_error",
2369 "message": msg,
2370 }))
2371 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
2373 status: 400,
2374 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
2375 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
2376 }
2377 }
2378 CamelError::ConsumerStopping => {
2379 tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
2380 HttpReply {
2381 status: 503,
2382 headers: vec![],
2383 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
2384 }
2385 }
2386 e => {
2387 tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
2389 HttpReply {
2390 status: 500,
2391 headers: vec![],
2392 body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
2393 }
2394 }
2395 }
2396}
2397
2398#[cfg(test)]
2399mod tests {
2400 use camel_component_api::test_support::{NoopRuntimeObservability, PanicRuntimeObservability};
2401 fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2402 std::sync::Arc::new(PanicRuntimeObservability)
2403 }
2404 fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2405 std::sync::Arc::new(PanicRuntimeObservability)
2406 }
2407 fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2408 std::sync::Arc::new(NoopRuntimeObservability)
2409 }
2410
2411 use super::*;
2412 use crate::rest_match::PathSegment;
2413 use camel_component_api::{Message, NoOpComponentContext};
2414 use std::sync::Arc;
2415 use std::time::Duration;
2416
2417 fn test_producer_ctx() -> ProducerContext {
2418 ProducerContext::new()
2419 }
2420
2421 #[test]
2422 fn test_http_config_defaults() {
2423 let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
2424 assert_eq!(config.base_url, "http://localhost:8080/api");
2425 assert!(config.http_method.is_none());
2426 assert!(config.throw_exception_on_failure);
2427 assert_eq!(config.ok_status_code_range, (200, 299));
2428 assert!(config.response_timeout.is_none());
2429 assert!(matches!(config.auth, HttpAuth::None));
2430 assert!(!config.bridge_endpoint);
2431 assert!(!config.connection_close);
2432 }
2433
2434 #[test]
2435 fn test_http_config_scheme() {
2436 assert_eq!(HttpEndpointConfig::scheme(), "http");
2438 }
2439
2440 #[test]
2441 fn test_http_config_from_components() {
2442 let components = camel_component_api::UriComponents {
2444 scheme: "https".to_string(),
2445 path: "//api.example.com/v1".to_string(),
2446 params: std::collections::HashMap::from([(
2447 "httpMethod".to_string(),
2448 "POST".to_string(),
2449 )]),
2450 };
2451 let config = HttpEndpointConfig::from_components(components).unwrap();
2452 assert_eq!(config.base_url, "https://api.example.com/v1");
2453 assert_eq!(config.http_method, Some("POST".to_string()));
2454 }
2455
2456 #[test]
2457 fn test_http_config_with_options() {
2458 let config = HttpEndpointConfig::from_uri(
2459 "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
2460 ).unwrap();
2461 assert_eq!(config.base_url, "https://api.example.com/v1");
2462 assert_eq!(config.http_method, Some("PUT".to_string()));
2463 assert!(!config.throw_exception_on_failure);
2464 assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
2465 }
2466
2467 #[test]
2468 fn test_http_endpoint_config_auth_and_headers_options() {
2469 let config = HttpEndpointConfig::from_uri(
2470 "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie",
2471 )
2472 .unwrap();
2473
2474 assert!(matches!(
2475 config.auth,
2476 HttpAuth::Basic { username, password } if username == "u" && password == "p"
2477 ));
2478 assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
2479 assert!(config.bridge_endpoint);
2480 assert!(config.connection_close);
2481 assert_eq!(
2482 config.skip_request_headers,
2483 vec!["authorization".to_string(), "x-secret".to_string()]
2484 );
2485 assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
2486 }
2487
2488 #[test]
2489 fn test_http_endpoint_config_bearer_auth() {
2490 let config = HttpEndpointConfig::from_uri(
2491 "http://localhost/api?authMethod=Bearer&authBearerToken=t",
2492 )
2493 .unwrap();
2494 assert!(matches!(
2495 config.auth,
2496 HttpAuth::Bearer { token } if token == "t"
2497 ));
2498 }
2499
2500 #[test]
2501 fn rejects_cookie_handling_inmemory() {
2502 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=InMemory");
2503 match result {
2504 Err(CamelError::InvalidUri(msg)) => {
2505 assert!(
2506 msg.contains("cookieHandling is not supported"),
2507 "expected rejection message, got: {msg}"
2508 );
2509 }
2510 other => panic!("expected InvalidUri error, got: {other:?}"),
2511 }
2512 }
2513
2514 #[test]
2515 fn rejects_cookie_handling_disabled() {
2516 let result = HttpEndpointConfig::from_uri("http://localhost/api?cookieHandling=Disabled");
2517 match result {
2518 Err(CamelError::InvalidUri(msg)) => {
2519 assert!(
2520 msg.contains("cookieHandling is not supported"),
2521 "expected rejection message, got: {msg}"
2522 );
2523 }
2524 other => panic!("expected InvalidUri error, got: {other:?}"),
2525 }
2526 }
2527
2528 #[test]
2529 fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
2530 let config = HttpConfig::default()
2531 .with_response_timeout_ms(999)
2532 .with_allow_internal(true)
2533 .with_blocked_hosts(vec!["evil.com".to_string()])
2534 .with_max_body_size(12345);
2535 let endpoint =
2536 HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
2537 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
2538 assert!(endpoint.allow_internal);
2539 assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
2540 assert_eq!(endpoint.max_body_size, 12345);
2541 }
2542
2543 #[test]
2544 fn test_from_uri_with_defaults_uri_overrides_config() {
2545 let config = HttpConfig::default()
2546 .with_response_timeout_ms(999)
2547 .with_allow_internal(true)
2548 .with_blocked_hosts(vec!["evil.com".to_string()])
2549 .with_max_body_size(12345);
2550 let endpoint = HttpEndpointConfig::from_uri_with_defaults(
2551 "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
2552 &config,
2553 )
2554 .unwrap();
2555 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
2556 assert!(!endpoint.allow_internal);
2557 assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
2558 assert_eq!(endpoint.max_body_size, 99);
2559 }
2560
2561 #[test]
2562 fn test_http_config_ok_status_range() {
2563 let config =
2564 HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
2565 assert_eq!(config.ok_status_code_range, (200, 204));
2566 }
2567
2568 #[test]
2569 fn test_http_config_wrong_scheme() {
2570 let result = HttpEndpointConfig::from_uri("file:/tmp");
2571 assert!(result.is_err());
2572 }
2573
2574 #[test]
2575 fn test_http_component_scheme() {
2576 let component = HttpComponent::new();
2577 assert_eq!(component.scheme(), "http");
2578 }
2579
2580 #[test]
2581 fn test_https_component_scheme() {
2582 let component = HttpsComponent::new();
2583 assert_eq!(component.scheme(), "https");
2584 }
2585
2586 #[test]
2587 fn test_http_endpoint_creates_consumer() {
2588 let component = HttpComponent::new();
2589 let ctx = NoOpComponentContext;
2590 let endpoint = component
2591 .create_endpoint("http://0.0.0.0:19100/test", &ctx)
2592 .unwrap();
2593 assert!(endpoint.create_consumer(rt()).is_ok());
2594 }
2595
2596 #[test]
2597 fn test_https_endpoint_creates_consumer_errors_without_tls() {
2598 let component = HttpsComponent::new();
2599 let ctx = NoOpComponentContext;
2600 let endpoint = component
2601 .create_endpoint("https://0.0.0.0:8443/test", &ctx)
2602 .unwrap();
2603 assert!(endpoint.create_consumer(rt()).is_err());
2605 }
2606
2607 #[test]
2608 fn test_http_endpoint_creates_producer() {
2609 let ctx = test_producer_ctx();
2610 let component = HttpComponent::new();
2611 let endpoint_ctx = NoOpComponentContext;
2612 let endpoint = component
2613 .create_endpoint("http://localhost/api", &endpoint_ctx)
2614 .unwrap();
2615 assert!(endpoint.create_producer(rt(), &ctx).is_ok());
2616 }
2617
2618 #[tokio::test]
2623 async fn test_producer_with_token_provider() {
2624 use camel_auth::oauth2::TokenProvider;
2625 use tower::ServiceExt;
2626
2627 let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
2628 Arc::new(std::sync::Mutex::new(None));
2629 let captured_clone = Arc::clone(&captured_auth);
2630
2631 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2632 let port = listener.local_addr().unwrap().port();
2633
2634 let _handle = tokio::spawn(async move {
2635 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2636 if let Ok((mut stream, _)) = listener.accept().await {
2637 let mut buf = vec![0u8; 8192];
2638 let n = stream.read(&mut buf).await.unwrap_or(0);
2639 let request = String::from_utf8_lossy(&buf[..n]).to_string();
2640 let auth = request
2641 .lines()
2642 .find(|l| l.to_lowercase().starts_with("authorization:"))
2643 .map(|l| {
2644 l.split(':')
2645 .nth(1)
2646 .map(|s| s.trim().to_string())
2647 .unwrap_or_default()
2648 });
2649 *captured_clone.lock().unwrap() = auth;
2650 let body = r#"{"echo":"ok"}"#;
2651 let resp = format!(
2652 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
2653 body.len(),
2654 body
2655 );
2656 let _ = stream.write_all(resp.as_bytes()).await;
2657 }
2658 });
2659
2660 #[derive(Debug)]
2661 struct StaticProvider;
2662 #[async_trait::async_trait]
2663 impl TokenProvider for StaticProvider {
2664 async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
2665 Ok("injected-token".into())
2666 }
2667 }
2668
2669 let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
2670 let ctx = test_producer_ctx();
2671 let component = HttpComponent::new();
2672 let endpoint_ctx = NoOpComponentContext;
2673 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
2674 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2675
2676 let exchange = Exchange::new(Message::new("hello"));
2677
2678 let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
2679 let mut layered = layer.layer(producer);
2680 let result = layered.ready().await.unwrap().call(exchange).await;
2681 assert!(result.is_ok(), "producer call failed: {:?}", result);
2682
2683 tokio::time::sleep(Duration::from_millis(100)).await;
2684 let auth = captured_auth.lock().unwrap().take();
2685 assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
2686 }
2687
2688 async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
2689 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2690 let addr = listener.local_addr().unwrap();
2691 let url = format!("http://127.0.0.1:{}", addr.port());
2692
2693 let handle = tokio::spawn(async move {
2694 loop {
2695 if let Ok((mut stream, _)) = listener.accept().await {
2696 tokio::spawn(async move {
2697 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2698 let mut buf = vec![0u8; 4096];
2699 let n = stream.read(&mut buf).await.unwrap_or(0);
2700 let request = String::from_utf8_lossy(&buf[..n]).to_string();
2701
2702 let method = request.split_whitespace().next().unwrap_or("GET");
2703
2704 let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
2705 let response = format!(
2706 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
2707 body.len(),
2708 body
2709 );
2710 let _ = stream.write_all(response.as_bytes()).await;
2711 });
2712 }
2713 }
2714 });
2715
2716 (url, handle)
2717 }
2718
2719 async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
2720 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2721 let addr = listener.local_addr().unwrap();
2722 let url = format!("http://127.0.0.1:{}", addr.port());
2723
2724 let handle = tokio::spawn(async move {
2725 loop {
2726 if let Ok((mut stream, _)) = listener.accept().await {
2727 let status = status;
2728 tokio::spawn(async move {
2729 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2730 let mut buf = vec![0u8; 4096];
2731 let _ = stream.read(&mut buf).await;
2732
2733 let status_text = match status {
2734 404 => "Not Found",
2735 500 => "Internal Server Error",
2736 _ => "Error",
2737 };
2738 let body = "error body";
2739 let response = format!(
2740 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
2741 status,
2742 status_text,
2743 body.len(),
2744 body
2745 );
2746 let _ = stream.write_all(response.as_bytes()).await;
2747 });
2748 }
2749 }
2750 });
2751
2752 (url, handle)
2753 }
2754
2755 #[tokio::test]
2756 async fn test_http_producer_get_request() {
2757 use tower::ServiceExt;
2758
2759 let (url, _handle) = start_test_server().await;
2760 let ctx = test_producer_ctx();
2761
2762 let component = HttpComponent::new();
2763 let endpoint_ctx = NoOpComponentContext;
2764 let endpoint = component
2765 .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
2766 .unwrap();
2767 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2768
2769 let exchange = Exchange::new(Message::default());
2770 let result = producer.oneshot(exchange).await.unwrap();
2771
2772 let status = result
2773 .input
2774 .header("CamelHttpResponseCode")
2775 .and_then(|v| v.as_u64())
2776 .unwrap();
2777 assert_eq!(status, 200);
2778
2779 assert!(!result.input.body.is_empty());
2780 }
2781
2782 #[tokio::test]
2783 async fn test_http_producer_post_with_body() {
2784 use tower::ServiceExt;
2785
2786 let (url, _handle) = start_test_server().await;
2787 let ctx = test_producer_ctx();
2788
2789 let component = HttpComponent::new();
2790 let endpoint_ctx = NoOpComponentContext;
2791 let endpoint = component
2792 .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
2793 .unwrap();
2794 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2795
2796 let exchange = Exchange::new(Message::new("request body"));
2797 let result = producer.oneshot(exchange).await.unwrap();
2798
2799 let status = result
2800 .input
2801 .header("CamelHttpResponseCode")
2802 .and_then(|v| v.as_u64())
2803 .unwrap();
2804 assert_eq!(status, 200);
2805 }
2806
2807 #[tokio::test]
2808 async fn test_http_producer_method_from_header() {
2809 use tower::ServiceExt;
2810
2811 let (url, _handle) = start_test_server().await;
2812 let ctx = test_producer_ctx();
2813
2814 let component = HttpComponent::new();
2815 let endpoint_ctx = NoOpComponentContext;
2816 let endpoint = component
2817 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
2818 .unwrap();
2819 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2820
2821 let mut exchange = Exchange::new(Message::default());
2822 exchange.input.set_header(
2823 "CamelHttpMethod",
2824 serde_json::Value::String("DELETE".to_string()),
2825 );
2826
2827 let result = producer.oneshot(exchange).await.unwrap();
2828 let status = result
2829 .input
2830 .header("CamelHttpResponseCode")
2831 .and_then(|v| v.as_u64())
2832 .unwrap();
2833 assert_eq!(status, 200);
2834 }
2835
2836 #[tokio::test]
2837 async fn test_http_producer_forced_method() {
2838 use tower::ServiceExt;
2839
2840 let (url, _handle) = start_test_server().await;
2841 let ctx = test_producer_ctx();
2842
2843 let component = HttpComponent::new();
2844 let endpoint_ctx = NoOpComponentContext;
2845 let endpoint = component
2846 .create_endpoint(
2847 &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
2848 &endpoint_ctx,
2849 )
2850 .unwrap();
2851 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2852
2853 let exchange = Exchange::new(Message::default());
2854 let result = producer.oneshot(exchange).await.unwrap();
2855
2856 let status = result
2857 .input
2858 .header("CamelHttpResponseCode")
2859 .and_then(|v| v.as_u64())
2860 .unwrap();
2861 assert_eq!(status, 200);
2862 }
2863
2864 #[tokio::test]
2865 async fn test_http_producer_throw_exception_on_failure() {
2866 use tower::ServiceExt;
2867
2868 let (url, _handle) = start_status_server(404).await;
2869 let ctx = test_producer_ctx();
2870
2871 let component = HttpComponent::new();
2872 let endpoint_ctx = NoOpComponentContext;
2873 let endpoint = component
2874 .create_endpoint(
2875 &format!("{url}/not-found?allowInternal=true"),
2876 &endpoint_ctx,
2877 )
2878 .unwrap();
2879 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2880
2881 let exchange = Exchange::new(Message::default());
2882 let result = producer.oneshot(exchange).await;
2883 assert!(result.is_err());
2884
2885 match result.unwrap_err() {
2886 CamelError::HttpOperationFailed { status_code, .. } => {
2887 assert_eq!(status_code, 404);
2888 }
2889 e => panic!("Expected HttpOperationFailed, got: {e}"),
2890 }
2891 }
2892
2893 #[tokio::test]
2894 async fn test_http_producer_no_throw_on_failure() {
2895 use tower::ServiceExt;
2896
2897 let (url, _handle) = start_status_server(500).await;
2898 let ctx = test_producer_ctx();
2899
2900 let component = HttpComponent::new();
2901 let endpoint_ctx = NoOpComponentContext;
2902 let endpoint = component
2903 .create_endpoint(
2904 &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
2905 &endpoint_ctx,
2906 )
2907 .unwrap();
2908 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2909
2910 let exchange = Exchange::new(Message::default());
2911 let result = producer.oneshot(exchange).await.unwrap();
2912
2913 let status = result
2914 .input
2915 .header("CamelHttpResponseCode")
2916 .and_then(|v| v.as_u64())
2917 .unwrap();
2918 assert_eq!(status, 500);
2919 }
2920
2921 #[tokio::test]
2922 async fn test_http_producer_uri_override() {
2923 use tower::ServiceExt;
2924
2925 let (url, _handle) = start_test_server().await;
2926 let ctx = test_producer_ctx();
2927
2928 let component = HttpComponent::new();
2929 let endpoint_ctx = NoOpComponentContext;
2930 let endpoint = component
2931 .create_endpoint(
2932 "http://localhost:1/does-not-exist?allowInternal=true",
2933 &endpoint_ctx,
2934 )
2935 .unwrap();
2936 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2937
2938 let mut exchange = Exchange::new(Message::default());
2939 exchange.input.set_header(
2940 "CamelHttpUri",
2941 serde_json::Value::String(format!("{url}/api")),
2942 );
2943
2944 let result = producer.oneshot(exchange).await.unwrap();
2945 let status = result
2946 .input
2947 .header("CamelHttpResponseCode")
2948 .and_then(|v| v.as_u64())
2949 .unwrap();
2950 assert_eq!(status, 200);
2951 }
2952
2953 #[tokio::test]
2954 async fn test_http_producer_response_headers_mapped() {
2955 use tower::ServiceExt;
2956
2957 let (url, _handle) = start_test_server().await;
2958 let ctx = test_producer_ctx();
2959
2960 let component = HttpComponent::new();
2961 let endpoint_ctx = NoOpComponentContext;
2962 let endpoint = component
2963 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
2964 .unwrap();
2965 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2966
2967 let exchange = Exchange::new(Message::default());
2968 let result = producer.oneshot(exchange).await.unwrap();
2969
2970 assert!(
2971 result.input.header("Content-Type").is_some(),
2972 "Response should have Content-Type header"
2973 );
2974 assert!(result.input.header("CamelHttpResponseText").is_some());
2975 }
2976
2977 async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
2982 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2983 let addr = listener.local_addr().unwrap();
2984 let url = format!("http://127.0.0.1:{}", addr.port());
2985
2986 let handle = tokio::spawn(async move {
2987 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2988 loop {
2989 if let Ok((mut stream, _)) = listener.accept().await {
2990 tokio::spawn(async move {
2991 let mut buf = vec![0u8; 4096];
2992 let n = stream.read(&mut buf).await.unwrap_or(0);
2993 let request = String::from_utf8_lossy(&buf[..n]).to_string();
2994
2995 if request.contains("GET /final") {
2997 let body = r#"{"status":"final"}"#;
2998 let response = format!(
2999 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
3000 body.len(),
3001 body
3002 );
3003 let _ = stream.write_all(response.as_bytes()).await;
3004 } else {
3005 let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\n\r\n";
3007 let _ = stream.write_all(response.as_bytes()).await;
3008 }
3009 });
3010 }
3011 }
3012 });
3013
3014 (url, handle)
3015 }
3016
3017 #[tokio::test]
3018 async fn test_follow_redirects_false_does_not_follow() {
3019 use tower::ServiceExt;
3020
3021 let (url, _handle) = start_redirect_server().await;
3022 let ctx = test_producer_ctx();
3023
3024 let component =
3025 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
3026 let endpoint_ctx = NoOpComponentContext;
3027 let endpoint = component
3028 .create_endpoint(
3029 &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
3030 &endpoint_ctx,
3031 )
3032 .unwrap();
3033 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3034
3035 let exchange = Exchange::new(Message::default());
3036 let result = producer.oneshot(exchange).await.unwrap();
3037
3038 let status = result
3040 .input
3041 .header("CamelHttpResponseCode")
3042 .and_then(|v| v.as_u64())
3043 .unwrap();
3044 assert_eq!(
3045 status, 302,
3046 "Should NOT follow redirect when followRedirects=false"
3047 );
3048 }
3049
3050 #[tokio::test]
3051 async fn test_follow_redirects_true_follows_redirect() {
3052 use tower::ServiceExt;
3053
3054 let (url, _handle) = start_redirect_server().await;
3055 let ctx = test_producer_ctx();
3056
3057 let component =
3058 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3059 let endpoint_ctx = NoOpComponentContext;
3060 let endpoint = component
3061 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
3062 .unwrap();
3063 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3064
3065 let exchange = Exchange::new(Message::default());
3066 let result = producer.oneshot(exchange).await.unwrap();
3067
3068 let status = result
3070 .input
3071 .header("CamelHttpResponseCode")
3072 .and_then(|v| v.as_u64())
3073 .unwrap();
3074 assert_eq!(
3075 status, 200,
3076 "Should follow redirect when followRedirects=true"
3077 );
3078 }
3079
3080 #[tokio::test]
3083 async fn test_redirect_to_private_ip_is_ssrf_blocked() {
3084 use tower::ServiceExt;
3085
3086 let (url, _handle) = start_redirect_server().await;
3088 let ctx = test_producer_ctx();
3089
3090 let component =
3091 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3092 let endpoint_ctx = NoOpComponentContext;
3093 let endpoint = component
3094 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
3095 .unwrap();
3096 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3097
3098 let exchange = Exchange::new(Message::default());
3099 let result = producer.oneshot(exchange).await;
3100
3101 assert!(
3103 result.is_ok(),
3104 "Redirect should succeed with allowInternal=true, got: {:?}",
3105 result
3106 );
3107 let exchange = result.unwrap();
3108 let status = exchange
3109 .input
3110 .header("CamelHttpResponseCode")
3111 .and_then(|v| v.as_u64())
3112 .unwrap();
3113 assert_eq!(status, 200, "Should follow redirect to /final");
3114 }
3115
3116 #[tokio::test]
3118 async fn test_redirect_to_private_ip_allowed_when_configured() {
3119 use tower::ServiceExt;
3120
3121 let (url, _handle) = start_redirect_server().await;
3123 let ctx = test_producer_ctx();
3124
3125 let component =
3126 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3127 let endpoint_ctx = NoOpComponentContext;
3128 let endpoint = component
3129 .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
3130 .unwrap();
3131 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3132
3133 let exchange = Exchange::new(Message::default());
3134 let result = producer.oneshot(exchange).await.unwrap();
3135
3136 let status = result
3137 .input
3138 .header("CamelHttpResponseCode")
3139 .and_then(|v| v.as_u64())
3140 .unwrap();
3141 assert_eq!(
3142 status, 200,
3143 "Should follow redirect to private IP when allowInternal=true"
3144 );
3145 }
3146
3147 #[tokio::test]
3150 async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
3151 use tower::ServiceExt;
3152
3153 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3155 let addr = listener.local_addr().unwrap();
3156 let url = format!("http://127.0.0.1:{}", addr.port());
3157
3158 let handle = tokio::spawn(async move {
3159 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3160 loop {
3161 if let Ok((mut stream, _)) = listener.accept().await {
3162 tokio::spawn(async move {
3163 let mut buf = vec![0u8; 4096];
3164 let _ = stream.read(&mut buf).await;
3165 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";
3167 let _ = stream.write_all(response.as_bytes()).await;
3168 });
3169 }
3170 }
3171 });
3172
3173 let ctx = test_producer_ctx();
3174 let component =
3175 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3176 let endpoint_ctx = NoOpComponentContext;
3177 let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
3179 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3180
3181 let exchange = Exchange::new(Message::default());
3182 let result = producer.oneshot(exchange).await;
3183
3184 assert!(
3186 result.is_err(),
3187 "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
3188 );
3189 let err = result.unwrap_err().to_string();
3190 assert!(
3191 err.contains("blocked IP")
3192 || err.contains("private IP")
3193 || err.contains("SSRF")
3194 || err.contains("not allowed"),
3195 "Error should mention SSRF/IP blocking, got: {err}"
3196 );
3197
3198 handle.abort();
3199 }
3200
3201 #[tokio::test]
3203 async fn test_too_many_redirects_returns_error() {
3204 use tower::ServiceExt;
3205
3206 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3208 let addr = listener.local_addr().unwrap();
3209 let url = format!("http://127.0.0.1:{}", addr.port());
3210
3211 let handle = tokio::spawn(async move {
3212 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3213 loop {
3214 if let Ok((mut stream, _)) = listener.accept().await {
3215 tokio::spawn(async move {
3216 let mut buf = vec![0u8; 4096];
3217 let _ = stream.read(&mut buf).await;
3218 let response =
3220 "HTTP/1.1 302 Found\r\nLocation: /loop\r\nContent-Length: 0\r\n\r\n";
3221 let _ = stream.write_all(response.as_bytes()).await;
3222 });
3223 }
3224 }
3225 });
3226
3227 let ctx = test_producer_ctx();
3228 let component =
3229 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3230 let endpoint_ctx = NoOpComponentContext;
3231 let endpoint = component
3232 .create_endpoint(
3233 &format!("{url}?allowInternal=true&maxRedirects=2"),
3234 &endpoint_ctx,
3235 )
3236 .unwrap();
3237 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3238
3239 let exchange = Exchange::new(Message::default());
3240 let result = producer.oneshot(exchange).await;
3241
3242 match result {
3250 Err(e) => {
3251 let msg = e.to_string();
3253 assert!(
3254 msg.contains("HTTP operation failed") || msg.contains("302"),
3255 "expected redirect-after-exhaustion error, got: {msg}"
3256 );
3257 }
3258 Ok(ex) => {
3259 let response_code = ex
3260 .input
3261 .header("CamelHttpResponseCode")
3262 .and_then(|v| v.as_u64());
3263 assert_eq!(
3264 response_code,
3265 Some(302),
3266 "expected 302 after exhausting redirects"
3267 );
3268 }
3269 }
3270
3271 handle.abort();
3272 }
3273
3274 #[tokio::test]
3275 async fn test_query_params_forwarded_to_http_request() {
3276 use tower::ServiceExt;
3277
3278 let (url, _handle) = start_test_server().await;
3279 let ctx = test_producer_ctx();
3280
3281 let component = HttpComponent::new();
3282 let endpoint_ctx = NoOpComponentContext;
3283 let endpoint = component
3285 .create_endpoint(
3286 &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
3287 &endpoint_ctx,
3288 )
3289 .unwrap();
3290 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3291
3292 let exchange = Exchange::new(Message::default());
3293 let result = producer.oneshot(exchange).await.unwrap();
3294
3295 let status = result
3298 .input
3299 .header("CamelHttpResponseCode")
3300 .and_then(|v| v.as_u64())
3301 .unwrap();
3302 assert_eq!(status, 200);
3303 }
3304
3305 #[tokio::test]
3306 async fn test_non_camel_query_params_are_forwarded() {
3307 let config = HttpEndpointConfig::from_uri(
3310 "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
3311 )
3312 .unwrap();
3313
3314 assert!(
3316 config.query_params.contains_key("apiKey"),
3317 "apiKey should be preserved"
3318 );
3319 assert!(
3320 config.query_params.contains_key("token"),
3321 "token should be preserved"
3322 );
3323 assert_eq!(config.query_params.get("apiKey").unwrap(), "secret123");
3324 assert_eq!(config.query_params.get("token").unwrap(), "abc456");
3325
3326 assert!(
3328 !config.query_params.contains_key("httpMethod"),
3329 "httpMethod should not be forwarded"
3330 );
3331 }
3332
3333 #[test]
3334 fn test_query_params_are_url_encoded_when_resolving_url() {
3335 let config =
3336 HttpEndpointConfig::from_uri("http://example.com/api?q=hello world&tag=a+b").unwrap();
3337 let exchange = Exchange::new(Message::default());
3338
3339 let url = HttpProducer::resolve_url(&exchange, &config);
3340
3341 assert!(url.contains("q=hello+world"), "url was: {url}");
3342 assert!(url.contains("tag=a%2Bb"), "url was: {url}");
3343 }
3344
3345 async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
3350 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3351 let addr = listener.local_addr().unwrap();
3352 let url = format!("http://127.0.0.1:{}", addr.port());
3353
3354 let handle = tokio::spawn(async move {
3355 loop {
3356 if let Ok((mut stream, _)) = listener.accept().await {
3357 let delay = delay_ms;
3358 tokio::spawn(async move {
3359 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3360 let mut buf = vec![0u8; 4096];
3361 let _ = stream.read(&mut buf).await;
3362 let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
3364 let _ = stream.write_all(headers.as_bytes()).await;
3365 tokio::time::sleep(Duration::from_millis(delay)).await;
3367 let body = r#"{"status":"slow"}"#;
3368 let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
3369 let _ = stream.write_all(chunk.as_bytes()).await;
3370 });
3371 }
3372 }
3373 });
3374
3375 (url, handle)
3376 }
3377
3378 #[tokio::test]
3379 async fn test_http_producer_timeout() {
3380 use tower::ServiceExt;
3381
3382 let (url, _handle) = start_slow_server(500).await;
3384 let ctx = test_producer_ctx();
3385
3386 let component = HttpComponent::with_config(
3387 HttpConfig::default()
3388 .with_read_timeout_ms(100)
3389 .with_response_timeout_ms(30_000), );
3391 let endpoint_ctx = NoOpComponentContext;
3392 let endpoint = component
3393 .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
3394 .unwrap();
3395 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3396
3397 let exchange = Exchange::new(Message::default());
3398 let result = producer.oneshot(exchange).await;
3399
3400 assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
3401 let err = result.unwrap_err().to_string();
3402 assert!(
3403 err.contains("Read timeout") || err.contains("timeout"),
3404 "Error should mention timeout, got: {}",
3405 err
3406 );
3407 }
3408
3409 #[tokio::test]
3410 async fn test_http_producer_no_timeout_when_fast() {
3411 use tower::ServiceExt;
3412
3413 let (url, _handle) = start_test_server().await;
3414 let ctx = test_producer_ctx();
3415
3416 let component =
3417 HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
3418 let endpoint_ctx = NoOpComponentContext;
3419 let endpoint = component
3420 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
3421 .unwrap();
3422 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3423
3424 let exchange = Exchange::new(Message::default());
3425 let result = producer.oneshot(exchange).await.unwrap();
3426
3427 let status = result
3428 .input
3429 .header("CamelHttpResponseCode")
3430 .and_then(|v| v.as_u64())
3431 .unwrap();
3432 assert_eq!(status, 200);
3433 }
3434
3435 #[tokio::test]
3440 async fn test_http_producer_blocks_metadata_endpoint() {
3441 use tower::ServiceExt;
3442
3443 let ctx = test_producer_ctx();
3444 let component = HttpComponent::new();
3445 let endpoint_ctx = NoOpComponentContext;
3446 let endpoint = component
3447 .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
3448 .unwrap();
3449 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3450
3451 let mut exchange = Exchange::new(Message::default());
3452 exchange.input.set_header(
3453 "CamelHttpUri",
3454 serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
3455 );
3456
3457 let result = producer.oneshot(exchange).await;
3458 assert!(result.is_err(), "Should block AWS metadata endpoint");
3459
3460 let err = result.unwrap_err();
3461 assert!(
3462 err.to_string().contains("Private IP"),
3463 "Error should mention private IP blocking, got: {}",
3464 err
3465 );
3466 }
3467
3468 #[test]
3469 fn test_ssrf_config_defaults() {
3470 let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
3471 assert!(
3472 !config.allow_internal,
3473 "Private IPs should be blocked by default"
3474 );
3475 assert!(
3476 config.blocked_hosts.is_empty(),
3477 "Blocked hosts should be empty by default"
3478 );
3479 }
3480
3481 #[test]
3482 fn test_ssrf_config_allow_internal() {
3483 let config =
3484 HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
3485 assert!(
3486 config.allow_internal,
3487 "Private IPs should be allowed when explicitly set"
3488 );
3489 }
3490
3491 #[test]
3492 fn test_ssrf_config_blocked_hosts() {
3493 let config = HttpEndpointConfig::from_uri(
3494 "http://example.com/api?blockedHosts=evil.com,malware.net",
3495 )
3496 .unwrap();
3497 assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
3498 }
3499
3500 #[tokio::test]
3501 async fn test_http_producer_blocks_localhost() {
3502 use tower::ServiceExt;
3503
3504 let ctx = test_producer_ctx();
3505 let component = HttpComponent::new();
3506 let endpoint_ctx = NoOpComponentContext;
3507 let endpoint = component
3508 .create_endpoint("http://example.com/api", &endpoint_ctx)
3509 .unwrap();
3510 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3511
3512 let mut exchange = Exchange::new(Message::default());
3513 exchange.input.set_header(
3514 "CamelHttpUri",
3515 serde_json::Value::String("http://localhost:8080/internal".to_string()),
3516 );
3517
3518 let result = producer.oneshot(exchange).await;
3519 assert!(result.is_err(), "Should block localhost");
3520 }
3521
3522 #[tokio::test]
3523 async fn test_http_producer_blocks_loopback_ip() {
3524 use tower::ServiceExt;
3525
3526 let ctx = test_producer_ctx();
3527 let component = HttpComponent::new();
3528 let endpoint_ctx = NoOpComponentContext;
3529 let endpoint = component
3530 .create_endpoint("http://example.com/api", &endpoint_ctx)
3531 .unwrap();
3532 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3533
3534 let mut exchange = Exchange::new(Message::default());
3535 exchange.input.set_header(
3536 "CamelHttpUri",
3537 serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
3538 );
3539
3540 let result = producer.oneshot(exchange).await;
3541 assert!(result.is_err(), "Should block loopback IP");
3542 }
3543
3544 #[tokio::test]
3545 async fn test_http_producer_allows_private_ip_when_enabled() {
3546 use tower::ServiceExt;
3547
3548 let ctx = test_producer_ctx();
3549 let component = HttpComponent::new();
3550 let endpoint_ctx = NoOpComponentContext;
3551 let endpoint = component
3554 .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
3555 .unwrap();
3556 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3557
3558 let exchange = Exchange::new(Message::default());
3559
3560 let result = producer.oneshot(exchange).await;
3563 if let Err(ref e) = result {
3565 let err_str = e.to_string();
3566 assert!(
3567 !err_str.contains("Private IP") && !err_str.contains("not allowed"),
3568 "Should not be SSRF error, got: {}",
3569 err_str
3570 );
3571 }
3572 }
3573
3574 #[test]
3579 fn test_http_server_config_parse() {
3580 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
3581 assert_eq!(cfg.host, "0.0.0.0");
3582 assert_eq!(cfg.port, 8080);
3583 assert_eq!(cfg.path, "/orders");
3584 assert_eq!(cfg.max_inflight_requests, 1024);
3585 }
3586
3587 #[test]
3588 fn test_http_server_config_scheme() {
3589 assert_eq!(HttpServerConfig::scheme(), "http");
3591 }
3592
3593 #[test]
3594 fn test_http_server_config_from_components() {
3595 let components = camel_component_api::UriComponents {
3597 scheme: "https".to_string(),
3598 path: "//0.0.0.0:8443/api".to_string(),
3599 params: std::collections::HashMap::from([
3600 ("maxRequestBody".to_string(), "5242880".to_string()),
3601 ("maxInflightRequests".to_string(), "7".to_string()),
3602 ]),
3603 };
3604 let cfg = HttpServerConfig::from_components(components).unwrap();
3605 assert_eq!(cfg.host, "0.0.0.0");
3606 assert_eq!(cfg.port, 8443);
3607 assert_eq!(cfg.path, "/api");
3608 assert_eq!(cfg.max_request_body, 5242880);
3609 assert_eq!(cfg.max_inflight_requests, 7);
3610 }
3611
3612 #[test]
3613 fn test_http_server_config_default_path() {
3614 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
3615 assert_eq!(cfg.path, "/");
3616 }
3617
3618 #[test]
3619 fn test_http_server_config_wrong_scheme() {
3620 assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
3621 }
3622
3623 #[test]
3624 fn test_http_server_config_invalid_port() {
3625 assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
3626 }
3627
3628 #[test]
3629 fn test_http_server_config_default_port_by_scheme() {
3630 let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
3632 assert_eq!(cfg_http.port, 80);
3633
3634 let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
3636 assert_eq!(cfg_https.port, 443);
3637 }
3638
3639 #[test]
3640 fn test_request_envelope_and_reply_are_send() {
3641 fn assert_send<T: Send>() {}
3642 assert_send::<RequestEnvelope>();
3643 assert_send::<HttpReply>();
3644 }
3645
3646 #[test]
3651 fn test_server_registry_global_is_singleton() {
3652 let r1 = ServerRegistry::global();
3653 let r2 = ServerRegistry::global();
3654 assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
3655 }
3656
3657 #[allow(clippy::await_holding_lock)]
3658 #[tokio::test]
3659 async fn test_concurrent_get_or_spawn_returns_same_registry() {
3660 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3661 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3662 let port = listener.local_addr().unwrap().port();
3663 drop(listener);
3664
3665 let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
3666 Arc::new(std::sync::Mutex::new(Vec::new()));
3667
3668 let mut handles = Vec::new();
3669 for _ in 0..4 {
3670 let results = results.clone();
3671 handles.push(tokio::spawn(async move {
3672 let registry = ServerRegistry::global()
3673 .get_or_spawn(
3674 "127.0.0.1",
3675 port,
3676 2 * 1024 * 1024,
3677 10 * 1024 * 1024,
3678 1024,
3679 test_rt(),
3680 "test-route".into(),
3681 None,
3682 )
3683 .await
3684 .unwrap();
3685 results.lock().unwrap().push(registry);
3686 }));
3687 }
3688
3689 for h in handles {
3690 h.await.unwrap();
3691 }
3692
3693 let registries = results.lock().unwrap();
3694 assert_eq!(registries.len(), 4);
3695 for i in 1..registries.len() {
3696 assert!(
3697 Arc::ptr_eq(®istries[0].inner, ®istries[i].inner),
3698 "all concurrent callers should get same route registry"
3699 );
3700 }
3701 }
3702
3703 #[test]
3704 fn test_server_registry_distinguishes_host_and_port() {
3705 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3706 let rt = tokio::runtime::Runtime::new().expect("runtime");
3707 rt.block_on(async {
3708 let registry = ServerRegistry::global();
3709 let d1 = registry
3713 .get_or_spawn(
3714 "127.0.0.1",
3715 0,
3716 1024 * 1024,
3717 10 * 1024 * 1024,
3718 1024,
3719 test_rt(),
3720 "test-route-1".into(),
3721 None,
3722 )
3723 .await;
3724 let d2 = registry
3725 .get_or_spawn(
3726 "0.0.0.0",
3727 0,
3728 1024 * 1024,
3729 10 * 1024 * 1024,
3730 1024,
3731 test_rt(),
3732 "test-route-2".into(),
3733 None,
3734 )
3735 .await;
3736 assert!(d1.is_ok());
3737 assert!(d2.is_ok());
3738 assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
3739 });
3740 }
3741
3742 #[allow(clippy::await_holding_lock)]
3743 #[tokio::test]
3744 async fn test_shared_server_max_request_body_policy_is_deterministic() {
3745 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3746 let registry = ServerRegistry::global();
3747 let d1 = registry
3749 .get_or_spawn(
3750 "127.0.0.1",
3751 9991,
3752 1024 * 1024,
3753 10 * 1024 * 1024,
3754 1024,
3755 test_rt(),
3756 "test-route".into(),
3757 None,
3758 )
3759 .await;
3760 assert!(d1.is_ok());
3761
3762 let d2 = registry
3765 .get_or_spawn(
3766 "127.0.0.1",
3767 9991,
3768 2 * 1024 * 1024,
3769 10 * 1024 * 1024,
3770 1024,
3771 test_rt(),
3772 "test-route-2".into(),
3773 None,
3774 )
3775 .await;
3776 assert!(d2.is_err());
3777 let err = d2.unwrap_err();
3778 assert!(
3779 err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
3780 "Expected incompatible maxRequestBody error, got: {}",
3781 err
3782 );
3783 }
3784
3785 #[test]
3786 fn test_server_registry_reset_clears_entries() {
3787 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3788 let rt = tokio::runtime::Runtime::new().expect("runtime");
3789 rt.block_on(async {
3790 let d1 = ServerRegistry::global()
3792 .get_or_spawn(
3793 "127.0.0.1",
3794 9992,
3795 1024 * 1024,
3796 10 * 1024 * 1024,
3797 1024,
3798 test_rt(),
3799 "test-route".into(),
3800 None,
3801 )
3802 .await;
3803 assert!(d1.is_ok());
3804
3805 let guard = ServerRegistry::global().inner.lock().expect("lock");
3807 assert!(guard.contains_key(&("127.0.0.1".to_string(), 9992)));
3808 drop(guard);
3809
3810 ServerRegistry::reset();
3812
3813 let guard = ServerRegistry::global().inner.lock().expect("lock");
3815 assert!(
3816 guard.is_empty(),
3817 "registry should be empty after reset, has {} entries",
3818 guard.len()
3819 );
3820 });
3821 }
3822
3823 #[tokio::test]
3824 async fn registry_rejects_tls_on_plain_port() {
3825 ServerRegistry::reset();
3826 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
3827
3828 let _r1 = ServerRegistry::global()
3830 .get_or_spawn(
3831 "127.0.0.1",
3832 0,
3833 1024,
3834 1024,
3835 16,
3836 Arc::clone(&rt),
3837 "route-1".into(),
3838 None, )
3840 .await;
3841
3842 let result = ServerRegistry::global()
3844 .get_or_spawn(
3845 "127.0.0.1",
3846 0,
3847 1024,
3848 1024,
3849 16,
3850 Arc::clone(&rt),
3851 "route-2".into(),
3852 Some(crate::config::ServerTlsConfig {
3853 cert_path: "/x.pem".into(),
3854 key_path: "/y.pem".into(),
3855 }),
3856 )
3857 .await;
3858 assert!(result.is_err(), "must reject TLS on plain port");
3859 }
3860
3861 #[allow(clippy::await_holding_lock)]
3866 #[tokio::test]
3867 async fn test_unregister_last_http_route_keeps_server_alive() {
3868 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3869 ServerRegistry::reset();
3870 let registry = ServerRegistry::global();
3871
3872 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3873 let port = listener.local_addr().unwrap().port();
3874 drop(listener); let rt = test_rt();
3876
3877 let _r1 = registry
3880 .get_or_spawn(
3881 "127.0.0.1",
3882 port,
3883 1024 * 1024,
3884 10 * 1024 * 1024,
3885 16,
3886 rt.clone(),
3887 "test-route-1".into(),
3888 None,
3889 )
3890 .await
3891 .unwrap();
3892 let _r2 = registry
3893 .get_or_spawn(
3894 "127.0.0.1",
3895 port,
3896 1024 * 1024,
3897 10 * 1024 * 1024,
3898 16,
3899 rt,
3900 "test-route-2".into(),
3901 None,
3902 )
3903 .await
3904 .unwrap();
3905
3906 let key = ("127.0.0.1".to_string(), port);
3907 let cell = {
3908 let guard = registry.inner.lock().expect("lock");
3909 guard.get(&key).expect("entry should exist").clone()
3910 };
3911
3912 registry.unregister("127.0.0.1", port).await;
3914 {
3915 let handle = cell
3916 .get()
3917 .expect("handle should still exist after first unregister");
3918 assert!(
3919 !handle.monitor_task.is_finished(),
3920 "monitor task should still be alive after first unregister"
3921 );
3922 }
3923
3924 registry.unregister("127.0.0.1", port).await;
3926 tokio::time::sleep(Duration::from_millis(20)).await;
3927 {
3928 let handle = cell
3929 .get()
3930 .expect("handle should still exist after last unregister");
3931 assert!(
3932 !handle.monitor_task.is_finished(),
3933 "monitor task should still be alive — server is process-lifetime"
3934 );
3935 }
3936
3937 {
3939 let guard = registry.inner.lock().expect("lock");
3940 assert!(
3941 guard.get(&key).is_some(),
3942 "entry should remain in registry — server kept alive for restart"
3943 );
3944 }
3945 }
3946
3947 #[tokio::test]
3952 async fn test_dispatch_handler_returns_404_for_unknown_path() {
3953 let registry = HttpRouteRegistry::new();
3954 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3956 let port = listener.local_addr().unwrap().port();
3957 tokio::spawn(run_axum_server(
3958 listener,
3959 registry,
3960 2 * 1024 * 1024,
3961 10 * 1024 * 1024,
3962 Arc::new(tokio::sync::Semaphore::new(1024)),
3963 test_rt(),
3964 "test-route".into(),
3965 ));
3966
3967 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3969
3970 let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
3971 .await
3972 .unwrap();
3973 assert_eq!(resp.status().as_u16(), 404);
3974 }
3975
3976 #[tokio::test]
3981 async fn test_http_consumer_start_registers_path() {
3982 use camel_component_api::ConsumerContext;
3983
3984 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3986 let port = listener.local_addr().unwrap().port();
3987 drop(listener); let consumer_cfg = HttpServerConfig {
3990 scheme: "http".to_string(),
3991 host: "127.0.0.1".to_string(),
3992 port,
3993 path: "/ping".to_string(),
3994 max_request_body: 2 * 1024 * 1024,
3995 max_response_body: 10 * 1024 * 1024,
3996 max_inflight_requests: 1024,
3997 method: None,
3998 tls_config: None,
3999 };
4000 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4001
4002 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
4003 let token = tokio_util::sync::CancellationToken::new();
4004 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4005
4006 tokio::spawn(async move {
4007 consumer.start(ctx).await.unwrap();
4008 });
4009
4010 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4011
4012 let client = reqwest::Client::new();
4013 let resp_future = client
4014 .post(format!("http://127.0.0.1:{port}/ping"))
4015 .body("hello world")
4016 .send();
4017
4018 let (http_result, _) = tokio::join!(resp_future, async {
4019 if let Some(mut envelope) = rx.recv().await {
4020 envelope.exchange.input.set_header(
4022 "CamelHttpResponseCode",
4023 serde_json::Value::Number(201.into()),
4024 );
4025 if let Some(reply_tx) = envelope.reply_tx {
4026 let _ = reply_tx.send(Ok(envelope.exchange));
4027 }
4028 }
4029 });
4030
4031 let resp = http_result.unwrap();
4032 assert_eq!(resp.status().as_u16(), 201);
4033
4034 token.cancel();
4035 }
4036
4037 #[test]
4040 fn test_http_consumer_startup_mode_is_explicit() {
4041 use camel_component_api::ConsumerStartupMode;
4042 let consumer_cfg = HttpServerConfig {
4043 scheme: "http".to_string(),
4044 host: "127.0.0.1".to_string(),
4045 port: 0,
4046 path: "/x".to_string(),
4047 max_request_body: 2 * 1024 * 1024,
4048 max_response_body: 10 * 1024 * 1024,
4049 max_inflight_requests: 1024,
4050 method: None,
4051 tls_config: None,
4052 };
4053 let consumer = HttpConsumer::new(consumer_cfg, test_rt());
4054 assert_eq!(
4055 consumer.startup_mode(),
4056 ConsumerStartupMode::Explicit,
4057 "HttpConsumer must opt into Explicit startup"
4058 );
4059 }
4060
4061 #[allow(clippy::await_holding_lock)]
4067 #[tokio::test]
4068 async fn test_http_consumer_emits_mark_ready_after_bind() {
4069 use camel_component_api::{ConsumerContext, StartupSignal};
4070
4071 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4072
4073 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4074 let port = listener.local_addr().unwrap().port();
4075 drop(listener);
4076
4077 let consumer_cfg = HttpServerConfig {
4078 scheme: "http".to_string(),
4079 host: "127.0.0.1".to_string(),
4080 port,
4081 path: "/ready-probe".to_string(),
4082 max_request_body: 2 * 1024 * 1024,
4083 max_response_body: 10 * 1024 * 1024,
4084 max_inflight_requests: 1024,
4085 method: None,
4086 tls_config: None,
4087 };
4088 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4089
4090 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
4091 let token = tokio_util::sync::CancellationToken::new();
4092 let ctx = ConsumerContext::new(tx, token.clone(), "ready-probe-route".to_string());
4093
4094 let (signal, startup_rx) = StartupSignal::pair();
4096 let ctx = ctx.with_startup(signal);
4097
4098 tokio::spawn(async move {
4101 let _ = consumer.start(ctx).await;
4102 });
4103
4104 let result =
4109 tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
4110 .await
4111 .expect("HttpConsumer must call ctx.mark_ready() after bind (rc-w1u9)");
4112 assert!(result.is_ok(), "mark_ready must resolve Ok after bind");
4113
4114 token.cancel();
4116 }
4117
4118 #[tokio::test]
4119 async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
4120 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4121
4122 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4123 let port = listener.local_addr().unwrap().port();
4124 drop(listener);
4125
4126 let consumer_cfg = HttpServerConfig {
4127 scheme: "http".to_string(),
4128 host: "127.0.0.1".to_string(),
4129 port,
4130 path: "/saturation".to_string(),
4131 max_request_body: 2 * 1024 * 1024,
4132 max_response_body: 10 * 1024 * 1024,
4133 max_inflight_requests: 1,
4134 method: None,
4135 tls_config: None,
4136 };
4137 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4138
4139 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4140 let token = tokio_util::sync::CancellationToken::new();
4141 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4142 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4143 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4144
4145 let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
4146 let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
4147
4148 tokio::spawn(async move {
4149 let mut first_seen_tx = Some(first_seen_tx);
4150 let mut unblock_first_rx = Some(unblock_first_rx);
4151
4152 while let Some(envelope) = rx.recv().await {
4153 if let Some(tx) = first_seen_tx.take() {
4154 let _ = tx.send(());
4155 if let Some(rx_unblock) = unblock_first_rx.take() {
4156 let _ = rx_unblock.await;
4157 }
4158 }
4159
4160 if let Some(reply_tx) = envelope.reply_tx {
4161 let _ = reply_tx.send(Ok(envelope.exchange));
4162 }
4163 }
4164 });
4165
4166 let client = reqwest::Client::new();
4167 let first_req = {
4168 let client = client.clone();
4169 async move {
4170 client
4171 .get(format!("http://127.0.0.1:{port}/saturation"))
4172 .send()
4173 .await
4174 .unwrap()
4175 }
4176 };
4177
4178 let first_handle = tokio::spawn(first_req);
4179 first_seen_rx.await.unwrap();
4180
4181 let second_resp = client
4182 .get(format!("http://127.0.0.1:{port}/saturation"))
4183 .send()
4184 .await
4185 .unwrap();
4186
4187 assert_eq!(second_resp.status().as_u16(), 503);
4188
4189 let _ = unblock_first_tx.send(());
4190 let first_resp = first_handle.await.unwrap();
4191 assert_eq!(first_resp.status().as_u16(), 200);
4192
4193 token.cancel();
4194 }
4195
4196 #[tokio::test]
4197 #[allow(clippy::await_holding_lock)]
4198 async fn test_http_consumer_enforces_max_response_body_for_bytes() {
4199 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4200
4201 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4202
4203 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4204 let port = listener.local_addr().unwrap().port();
4205 drop(listener);
4206
4207 let consumer_cfg = HttpServerConfig {
4208 scheme: "http".to_string(),
4209 host: "127.0.0.1".to_string(),
4210 port,
4211 path: "/limit-bytes".to_string(),
4212 max_request_body: 2 * 1024 * 1024,
4213 max_response_body: 16,
4214 max_inflight_requests: 1024,
4215 method: None,
4216 tls_config: None,
4217 };
4218 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4219
4220 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4221 let token = tokio_util::sync::CancellationToken::new();
4222 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4223 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4224 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4225
4226 let client = reqwest::Client::new();
4227 let send_fut = client
4228 .get(format!("http://127.0.0.1:{port}/limit-bytes"))
4229 .send();
4230
4231 let (http_result, _) = tokio::join!(send_fut, async {
4232 if let Some(mut envelope) = rx.recv().await {
4233 envelope.exchange.input.body =
4234 camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
4235 if let Some(reply_tx) = envelope.reply_tx {
4236 let _ = reply_tx.send(Ok(envelope.exchange));
4237 }
4238 }
4239 });
4240
4241 let resp = http_result.unwrap();
4242 assert_eq!(resp.status().as_u16(), 500);
4243 let body = resp.text().await.unwrap();
4244 assert_eq!(body, "Response body exceeds configured limit");
4245 token.cancel();
4246 }
4247
4248 #[tokio::test]
4249 #[allow(clippy::await_holding_lock)]
4250 async fn test_http_consumer_enforces_max_response_body_for_json() {
4251 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4252
4253 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4254
4255 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4256 let port = listener.local_addr().unwrap().port();
4257 drop(listener);
4258
4259 let consumer_cfg = HttpServerConfig {
4260 scheme: "http".to_string(),
4261 host: "127.0.0.1".to_string(),
4262 port,
4263 path: "/limit-json".to_string(),
4264 max_request_body: 2 * 1024 * 1024,
4265 max_response_body: 16,
4266 max_inflight_requests: 1024,
4267 method: None,
4268 tls_config: None,
4269 };
4270 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4271
4272 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4273 let token = tokio_util::sync::CancellationToken::new();
4274 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4275 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4276 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4277
4278 let client = reqwest::Client::new();
4279 let send_fut = client
4280 .get(format!("http://127.0.0.1:{port}/limit-json"))
4281 .send();
4282
4283 let (http_result, _) = tokio::join!(send_fut, async {
4284 if let Some(mut envelope) = rx.recv().await {
4285 envelope.exchange.input.body = camel_component_api::Body::Json(
4286 serde_json::json!({"message":"this response is bigger than sixteen"}),
4287 );
4288 if let Some(reply_tx) = envelope.reply_tx {
4289 let _ = reply_tx.send(Ok(envelope.exchange));
4290 }
4291 }
4292 });
4293
4294 let resp = http_result.unwrap();
4295 assert_eq!(resp.status().as_u16(), 500);
4296 let body = resp.text().await.unwrap();
4297 assert_eq!(body, "Response body exceeds configured limit");
4298 token.cancel();
4299 }
4300
4301 #[tokio::test]
4302 #[allow(clippy::await_holding_lock)]
4303 async fn test_http_consumer_enforces_max_response_body_for_xml() {
4304 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4305
4306 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4307
4308 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4309 let port = listener.local_addr().unwrap().port();
4310 drop(listener);
4311
4312 let consumer_cfg = HttpServerConfig {
4313 scheme: "http".to_string(),
4314 host: "127.0.0.1".to_string(),
4315 port,
4316 path: "/limit-xml".to_string(),
4317 max_request_body: 2 * 1024 * 1024,
4318 max_response_body: 16,
4319 max_inflight_requests: 1024,
4320 method: None,
4321 tls_config: None,
4322 };
4323 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4324
4325 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4326 let token = tokio_util::sync::CancellationToken::new();
4327 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4328 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4329 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4330
4331 let client = reqwest::Client::new();
4332 let send_fut = client
4333 .get(format!("http://127.0.0.1:{port}/limit-xml"))
4334 .send();
4335
4336 let (http_result, _) = tokio::join!(send_fut, async {
4337 if let Some(mut envelope) = rx.recv().await {
4338 envelope.exchange.input.body = camel_component_api::Body::Xml(
4339 "<root><value>way-too-large</value></root>".into(),
4340 );
4341 if let Some(reply_tx) = envelope.reply_tx {
4342 let _ = reply_tx.send(Ok(envelope.exchange));
4343 }
4344 }
4345 });
4346
4347 let resp = http_result.unwrap();
4348 assert_eq!(resp.status().as_u16(), 500);
4349 let body = resp.text().await.unwrap();
4350 assert_eq!(body, "Response body exceeds configured limit");
4351 token.cancel();
4352 }
4353
4354 #[tokio::test]
4355 #[allow(clippy::await_holding_lock)]
4356 async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
4357 use camel_component_api::{
4358 CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
4359 };
4360 use futures::stream;
4361
4362 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4363
4364 let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
4365 let port = listener.local_addr().unwrap().port();
4366 drop(listener);
4367
4368 let consumer_cfg = HttpServerConfig {
4369 scheme: "http".to_string(),
4370 host: "0.0.0.0".to_string(),
4371 port,
4372 path: "/limit-stream".to_string(),
4373 max_request_body: 2 * 1024 * 1024,
4374 max_response_body: 16,
4375 max_inflight_requests: 1024,
4376 method: None,
4377 tls_config: None,
4378 };
4379 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4380
4381 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4382 let token = tokio_util::sync::CancellationToken::new();
4383 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4384 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4385 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4386
4387 let client = reqwest::Client::new();
4388 let send_fut = client
4389 .get(format!("http://127.0.0.1:{port}/limit-stream"))
4390 .send();
4391
4392 let (http_result, _) = tokio::join!(send_fut, async {
4393 if let Some(mut envelope) = rx.recv().await {
4394 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
4395 vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
4396 let stream = Box::pin(stream::iter(chunks));
4397 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
4398 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
4399 metadata: StreamMetadata {
4400 size_hint: Some(32),
4401 content_type: Some("application/octet-stream".into()),
4402 origin: None,
4403 },
4404 });
4405 if let Some(reply_tx) = envelope.reply_tx {
4406 let _ = reply_tx.send(Ok(envelope.exchange));
4407 }
4408 }
4409 });
4410
4411 let resp = http_result.unwrap();
4412 assert_eq!(resp.status().as_u16(), 200);
4413 let body = resp.bytes().await.unwrap();
4414 assert_eq!(body.len(), 32);
4415 token.cancel();
4416 }
4417
4418 #[tokio::test]
4423 #[allow(clippy::await_holding_lock)]
4424 async fn test_integration_single_consumer_round_trip() {
4425 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4426
4427 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4431
4432 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4434 let port = listener.local_addr().unwrap().port();
4435 drop(listener); let component = HttpComponent::new();
4438 let endpoint_ctx = NoOpComponentContext;
4439 let endpoint = component
4440 .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
4441 .unwrap();
4442 let mut consumer = endpoint.create_consumer(rt()).unwrap();
4443
4444 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4445 let token = tokio_util::sync::CancellationToken::new();
4446 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4447
4448 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4449 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4450
4451 let client = reqwest::Client::new();
4452 let send_fut = client
4453 .post(format!("http://127.0.0.1:{port}/echo"))
4454 .header("Content-Type", "text/plain")
4455 .body("ping")
4456 .send();
4457
4458 let (http_result, _) = tokio::join!(send_fut, async {
4459 if let Some(mut envelope) = rx.recv().await {
4460 assert_eq!(
4461 envelope.exchange.input.header("CamelHttpMethod"),
4462 Some(&serde_json::Value::String("POST".into()))
4463 );
4464 assert_eq!(
4465 envelope.exchange.input.header("CamelHttpPath"),
4466 Some(&serde_json::Value::String("/echo".into()))
4467 );
4468 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
4469 if let Some(reply_tx) = envelope.reply_tx {
4470 let _ = reply_tx.send(Ok(envelope.exchange));
4471 }
4472 }
4473 });
4474
4475 let resp = http_result.unwrap();
4476 assert_eq!(resp.status().as_u16(), 200);
4477 let body = resp.text().await.unwrap();
4478 assert_eq!(body, "pong");
4479
4480 token.cancel();
4481 }
4482
4483 #[tokio::test]
4484 #[allow(clippy::await_holding_lock)]
4485 async fn test_integration_two_consumers_shared_port() {
4486 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4487
4488 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4489
4490 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4492 let port = listener.local_addr().unwrap().port();
4493 drop(listener);
4494
4495 let component = HttpComponent::new();
4496 let endpoint_ctx = NoOpComponentContext;
4497
4498 let endpoint_a = component
4500 .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
4501 .unwrap();
4502 let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
4503
4504 let endpoint_b = component
4506 .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
4507 .unwrap();
4508 let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
4509
4510 let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4511 let token_a = tokio_util::sync::CancellationToken::new();
4512 let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
4513
4514 let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4515 let token_b = tokio_util::sync::CancellationToken::new();
4516 let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
4517
4518 tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
4519 tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
4520 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4521
4522 let client = reqwest::Client::new();
4523
4524 let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
4526 let (resp_hello, _) = tokio::join!(fut_hello, async {
4527 if let Some(mut envelope) = rx_a.recv().await {
4528 envelope.exchange.input.body =
4529 camel_component_api::Body::Text("hello-response".to_string());
4530 if let Some(reply_tx) = envelope.reply_tx {
4531 let _ = reply_tx.send(Ok(envelope.exchange));
4532 }
4533 }
4534 });
4535
4536 let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
4538 let (resp_world, _) = tokio::join!(fut_world, async {
4539 if let Some(mut envelope) = rx_b.recv().await {
4540 envelope.exchange.input.body =
4541 camel_component_api::Body::Text("world-response".to_string());
4542 if let Some(reply_tx) = envelope.reply_tx {
4543 let _ = reply_tx.send(Ok(envelope.exchange));
4544 }
4545 }
4546 });
4547
4548 let body_a = resp_hello.unwrap().text().await.unwrap();
4549 let body_b = resp_world.unwrap().text().await.unwrap();
4550
4551 assert_eq!(body_a, "hello-response");
4552 assert_eq!(body_b, "world-response");
4553
4554 token_a.cancel();
4555 token_b.cancel();
4556 }
4557
4558 #[tokio::test]
4559 #[allow(clippy::await_holding_lock)]
4560 async fn test_integration_unregistered_path_returns_404() {
4561 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4562
4563 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4564
4565 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4567 let port = listener.local_addr().unwrap().port();
4568 drop(listener);
4569
4570 let component = HttpComponent::new();
4571 let endpoint_ctx = NoOpComponentContext;
4572 let endpoint = component
4573 .create_endpoint(
4574 &format!("http://127.0.0.1:{port}/registered"),
4575 &endpoint_ctx,
4576 )
4577 .unwrap();
4578 let mut consumer = endpoint.create_consumer(rt()).unwrap();
4579
4580 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4581 let token = tokio_util::sync::CancellationToken::new();
4582 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4583
4584 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4585
4586 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
4588 loop {
4589 if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
4590 .await
4591 .is_ok()
4592 {
4593 break;
4594 }
4595 if std::time::Instant::now() >= deadline {
4596 panic!("HTTP server did not start within 5s on port {port}");
4597 }
4598 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4599 }
4600
4601 let client = reqwest::Client::new();
4602 let resp = client
4603 .get(format!("http://127.0.0.1:{port}/not-there"))
4604 .send()
4605 .await
4606 .unwrap();
4607 assert_eq!(resp.status().as_u16(), 404);
4608
4609 token.cancel();
4610 }
4611
4612 #[test]
4613 fn test_http_consumer_declares_concurrent() {
4614 use camel_component_api::ConcurrencyModel;
4615
4616 let config = HttpServerConfig {
4617 scheme: "http".to_string(),
4618 host: "127.0.0.1".to_string(),
4619 port: 19999,
4620 path: "/test".to_string(),
4621 max_request_body: 2 * 1024 * 1024,
4622 max_response_body: 10 * 1024 * 1024,
4623 max_inflight_requests: 1024,
4624 method: None,
4625 tls_config: None,
4626 };
4627 let consumer = HttpConsumer::new(config, test_rt());
4628 assert_eq!(
4629 consumer.concurrency_model(),
4630 ConcurrencyModel::Concurrent { max: None }
4631 );
4632 }
4633
4634 #[test]
4635 fn server_config_parses_tls_cert_and_key() {
4636 let cfg = HttpServerConfig::from_uri(
4637 "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
4638 )
4639 .unwrap();
4640 assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
4641 assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
4642 }
4643
4644 #[test]
4645 fn server_config_no_tls_when_params_absent() {
4646 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
4647 assert!(cfg.tls_config.is_none());
4648 }
4649
4650 #[tokio::test]
4655 async fn test_http_reply_body_stream_variant_exists() {
4656 use bytes::Bytes;
4657 use camel_component_api::CamelError;
4658 use futures::stream;
4659
4660 let chunks: Vec<Result<Bytes, CamelError>> =
4661 vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
4662 let stream = Box::pin(stream::iter(chunks));
4663 let reply_body = HttpReplyBody::Stream(stream);
4664 match reply_body {
4666 HttpReplyBody::Stream(_) => {}
4667 HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
4668 }
4669 }
4670
4671 #[cfg(feature = "otel")]
4676 mod otel_tests {
4677 use super::*;
4678 use camel_component_api::Message;
4679 use tower::ServiceExt;
4680
4681 #[tokio::test]
4682 async fn test_producer_injects_traceparent_header() {
4683 let (url, _handle) = start_test_server_with_header_capture().await;
4684 let ctx = test_producer_ctx();
4685
4686 let component = HttpComponent::new();
4687 let endpoint_ctx = NoOpComponentContext;
4688 let endpoint = component
4689 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4690 .unwrap();
4691 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4692
4693 let mut exchange = Exchange::new(Message::default());
4695 let mut headers = std::collections::HashMap::new();
4696 headers.insert(
4697 "traceparent".to_string(),
4698 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
4699 );
4700 camel_otel::extract_into_exchange(&mut exchange, &headers);
4701
4702 let result = producer.oneshot(exchange).await.unwrap();
4703
4704 let status = result
4706 .input
4707 .header("CamelHttpResponseCode")
4708 .and_then(|v| v.as_u64())
4709 .unwrap();
4710 assert_eq!(status, 200);
4711
4712 let traceparent = result.input.header("X-Received-Traceparent");
4714 assert!(
4715 traceparent.is_some(),
4716 "traceparent header should have been sent"
4717 );
4718
4719 let traceparent_str = traceparent.unwrap().as_str().unwrap();
4720 let parts: Vec<&str> = traceparent_str.split('-').collect();
4722 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
4723 assert_eq!(parts[0], "00", "version should be 00");
4724 assert_eq!(
4725 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
4726 "trace-id should match"
4727 );
4728 assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
4729 assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
4730 }
4731
4732 #[tokio::test]
4733 async fn test_consumer_extracts_traceparent_header() {
4734 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4735
4736 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4738 let port = listener.local_addr().unwrap().port();
4739 drop(listener);
4740
4741 let component = HttpComponent::new();
4742 let endpoint_ctx = NoOpComponentContext;
4743 let endpoint = component
4744 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
4745 .unwrap();
4746 let mut consumer = endpoint.create_consumer(rt()).unwrap();
4747
4748 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4749 let token = tokio_util::sync::CancellationToken::new();
4750 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4751
4752 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4753 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4754
4755 let client = reqwest::Client::new();
4757 let send_fut = client
4758 .post(format!("http://127.0.0.1:{port}/trace"))
4759 .header(
4760 "traceparent",
4761 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
4762 )
4763 .body("test")
4764 .send();
4765
4766 let (http_result, _) = tokio::join!(send_fut, async {
4767 if let Some(envelope) = rx.recv().await {
4768 let mut injected_headers = std::collections::HashMap::new();
4771 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
4772
4773 assert!(
4774 injected_headers.contains_key("traceparent"),
4775 "Exchange should have traceparent after extraction"
4776 );
4777
4778 let traceparent = injected_headers.get("traceparent").unwrap();
4779 let parts: Vec<&str> = traceparent.split('-').collect();
4780 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
4781 assert_eq!(
4782 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
4783 "Trace ID should match the original traceparent header"
4784 );
4785
4786 if let Some(reply_tx) = envelope.reply_tx {
4787 let _ = reply_tx.send(Ok(envelope.exchange));
4788 }
4789 }
4790 });
4791
4792 let resp = http_result.unwrap();
4793 assert_eq!(resp.status().as_u16(), 200);
4794
4795 token.cancel();
4796 }
4797
4798 #[tokio::test]
4799 async fn test_consumer_extracts_mixed_case_traceparent_header() {
4800 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4801
4802 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4804 let port = listener.local_addr().unwrap().port();
4805 drop(listener);
4806
4807 let component = HttpComponent::new();
4808 let endpoint_ctx = NoOpComponentContext;
4809 let endpoint = component
4810 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
4811 .unwrap();
4812 let mut consumer = endpoint.create_consumer(rt()).unwrap();
4813
4814 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4815 let token = tokio_util::sync::CancellationToken::new();
4816 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4817
4818 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4819 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4820
4821 let client = reqwest::Client::new();
4823 let send_fut = client
4824 .post(format!("http://127.0.0.1:{port}/trace"))
4825 .header(
4826 "TraceParent",
4827 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
4828 )
4829 .body("test")
4830 .send();
4831
4832 let (http_result, _) = tokio::join!(send_fut, async {
4833 if let Some(envelope) = rx.recv().await {
4834 let mut injected_headers = HashMap::new();
4837 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
4838
4839 assert!(
4840 injected_headers.contains_key("traceparent"),
4841 "Exchange should have traceparent after extraction from mixed-case header"
4842 );
4843
4844 let traceparent = injected_headers.get("traceparent").unwrap();
4845 let parts: Vec<&str> = traceparent.split('-').collect();
4846 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
4847 assert_eq!(
4848 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
4849 "Trace ID should match the original mixed-case TraceParent header"
4850 );
4851
4852 if let Some(reply_tx) = envelope.reply_tx {
4853 let _ = reply_tx.send(Ok(envelope.exchange));
4854 }
4855 }
4856 });
4857
4858 let resp = http_result.unwrap();
4859 assert_eq!(resp.status().as_u16(), 200);
4860
4861 token.cancel();
4862 }
4863
4864 #[tokio::test]
4865 async fn test_producer_no_trace_context_no_crash() {
4866 let (url, _handle) = start_test_server().await;
4867 let ctx = test_producer_ctx();
4868
4869 let component = HttpComponent::new();
4870 let endpoint_ctx = NoOpComponentContext;
4871 let endpoint = component
4872 .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4873 .unwrap();
4874 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4875
4876 let exchange = Exchange::new(Message::default());
4878
4879 let result = producer.oneshot(exchange).await.unwrap();
4881
4882 let status = result
4884 .input
4885 .header("CamelHttpResponseCode")
4886 .and_then(|v| v.as_u64())
4887 .unwrap();
4888 assert_eq!(status, 200);
4889 }
4890
4891 async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
4893 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4894 let addr = listener.local_addr().unwrap();
4895 let url = format!("http://127.0.0.1:{}", addr.port());
4896
4897 let handle = tokio::spawn(async move {
4898 loop {
4899 if let Ok((mut stream, _)) = listener.accept().await {
4900 tokio::spawn(async move {
4901 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4902 let mut buf = vec![0u8; 8192];
4903 let n = stream.read(&mut buf).await.unwrap_or(0);
4904 let request = String::from_utf8_lossy(&buf[..n]).to_string();
4905
4906 let traceparent = request
4908 .lines()
4909 .find(|line| line.to_lowercase().starts_with("traceparent:"))
4910 .map(|line| {
4911 line.split(':')
4912 .nth(1)
4913 .map(|s| s.trim().to_string())
4914 .unwrap_or_default()
4915 })
4916 .unwrap_or_default();
4917
4918 let body =
4919 format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
4920 let response = format!(
4921 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
4922 body.len(),
4923 traceparent,
4924 body
4925 );
4926 let _ = stream.write_all(response.as_bytes()).await;
4927 });
4928 }
4929 }
4930 });
4931
4932 (url, handle)
4933 }
4934 }
4935
4936 #[tokio::test]
4945 async fn test_request_body_arrives_as_stream() {
4946 use camel_component_api::Body;
4947 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4948
4949 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4950 let port = listener.local_addr().unwrap().port();
4951 drop(listener);
4952
4953 let component = HttpComponent::new();
4954 let endpoint_ctx = NoOpComponentContext;
4955 let endpoint = component
4956 .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
4957 .unwrap();
4958 let mut consumer = endpoint.create_consumer(rt()).unwrap();
4959
4960 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4961 let token = tokio_util::sync::CancellationToken::new();
4962 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4963
4964 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4965 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4966
4967 let client = reqwest::Client::new();
4968 let send_fut = client
4969 .post(format!("http://127.0.0.1:{port}/upload"))
4970 .body("hello streaming world")
4971 .send();
4972
4973 let (http_result, _) = tokio::join!(send_fut, async {
4974 if let Some(mut envelope) = rx.recv().await {
4975 assert!(
4977 matches!(envelope.exchange.input.body, Body::Stream(_)),
4978 "expected Body::Stream, got discriminant {:?}",
4979 std::mem::discriminant(&envelope.exchange.input.body)
4980 );
4981 let bytes = envelope
4983 .exchange
4984 .input
4985 .body
4986 .into_bytes(1024 * 1024)
4987 .await
4988 .unwrap();
4989 assert_eq!(&bytes[..], b"hello streaming world");
4990
4991 envelope.exchange.input.body = camel_component_api::Body::Empty;
4992 if let Some(reply_tx) = envelope.reply_tx {
4993 let _ = reply_tx.send(Ok(envelope.exchange));
4994 }
4995 }
4996 });
4997
4998 let resp = http_result.unwrap();
4999 assert_eq!(resp.status().as_u16(), 200);
5000
5001 token.cancel();
5002 }
5003
5004 #[tokio::test]
5009 async fn test_streaming_response_chunked() {
5010 use bytes::Bytes;
5011 use camel_component_api::Body;
5012 use camel_component_api::CamelError;
5013 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
5014 use camel_component_api::{StreamBody, StreamMetadata};
5015 use futures::stream;
5016 use std::sync::Arc;
5017 use tokio::sync::Mutex;
5018
5019 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5020 let port = listener.local_addr().unwrap().port();
5021 drop(listener);
5022
5023 let component = HttpComponent::new();
5024 let endpoint_ctx = NoOpComponentContext;
5025 let endpoint = component
5026 .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
5027 .unwrap();
5028 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5029
5030 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
5031 let token = tokio_util::sync::CancellationToken::new();
5032 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5033
5034 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5035 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5036
5037 let client = reqwest::Client::new();
5038 let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
5039
5040 let (http_result, _) = tokio::join!(send_fut, async {
5041 if let Some(mut envelope) = rx.recv().await {
5042 let chunks: Vec<Result<Bytes, CamelError>> =
5044 vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
5045 let stream = Box::pin(stream::iter(chunks));
5046 envelope.exchange.input.body = Body::Stream(StreamBody {
5047 stream: Arc::new(Mutex::new(Some(stream))),
5048 metadata: StreamMetadata::default(),
5049 });
5050 if let Some(reply_tx) = envelope.reply_tx {
5051 let _ = reply_tx.send(Ok(envelope.exchange));
5052 }
5053 }
5054 });
5055
5056 let resp = http_result.unwrap();
5057 assert_eq!(resp.status().as_u16(), 200);
5058 let body = resp.text().await.unwrap();
5059 assert_eq!(body, "chunk1chunk2");
5060
5061 token.cancel();
5062 }
5063
5064 #[tokio::test]
5069 async fn test_413_when_content_length_exceeds_limit() {
5070 use camel_component_api::ConsumerContext;
5071
5072 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5073 let port = listener.local_addr().unwrap().port();
5074 drop(listener);
5075
5076 let component = HttpComponent::new();
5078 let endpoint_ctx = NoOpComponentContext;
5079 let endpoint = component
5080 .create_endpoint(
5081 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
5082 &endpoint_ctx,
5083 )
5084 .unwrap();
5085 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5086
5087 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
5088 let token = tokio_util::sync::CancellationToken::new();
5089 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5090
5091 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5092 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5093
5094 let client = reqwest::Client::new();
5095 let resp = client
5096 .post(format!("http://127.0.0.1:{port}/upload"))
5097 .header("Content-Length", "1000") .body("x".repeat(1000))
5099 .send()
5100 .await
5101 .unwrap();
5102
5103 assert_eq!(resp.status().as_u16(), 413);
5104
5105 token.cancel();
5106 }
5107
5108 #[tokio::test]
5112 async fn test_chunked_upload_without_content_length_bypasses_limit() {
5113 use bytes::Bytes;
5114 use camel_component_api::Body;
5115 use camel_component_api::ConsumerContext;
5116 use futures::stream;
5117
5118 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5119 let port = listener.local_addr().unwrap().port();
5120 drop(listener);
5121
5122 let component = HttpComponent::new();
5124 let endpoint_ctx = NoOpComponentContext;
5125 let endpoint = component
5126 .create_endpoint(
5127 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
5128 &endpoint_ctx,
5129 )
5130 .unwrap();
5131 let mut consumer = endpoint.create_consumer(rt()).unwrap();
5132
5133 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
5134 let token = tokio_util::sync::CancellationToken::new();
5135 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5136
5137 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5138 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5139
5140 let client = reqwest::Client::new();
5141
5142 let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
5146 Ok(Bytes::from("y".repeat(50))),
5147 Ok(Bytes::from("y".repeat(50))),
5148 ];
5149 let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
5150 let send_fut = client
5151 .post(format!("http://127.0.0.1:{port}/upload"))
5152 .body(stream_body)
5153 .send();
5154
5155 let consumer_fut = async {
5156 match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
5158 Ok(Some(mut envelope)) => {
5159 assert!(
5160 matches!(envelope.exchange.input.body, Body::Stream(_)),
5161 "expected Body::Stream"
5162 );
5163 envelope.exchange.input.body = camel_component_api::Body::Empty;
5164 if let Some(reply_tx) = envelope.reply_tx {
5165 let _ = reply_tx.send(Ok(envelope.exchange));
5166 }
5167 }
5168 Ok(None) => panic!("consumer channel closed unexpectedly"),
5169 Err(_) => {
5170 }
5173 }
5174 };
5175
5176 let (http_result, _) = tokio::join!(send_fut, consumer_fut);
5177
5178 let resp = http_result.unwrap();
5179 assert_ne!(
5181 resp.status().as_u16(),
5182 413,
5183 "chunked upload must not be rejected by maxRequestBody"
5184 );
5185 assert_eq!(resp.status().as_u16(), 200);
5186
5187 token.cancel();
5188 }
5189
5190 #[test]
5191 fn test_is_private_ip_ranges() {
5192 use camel_api::is_ssrf_blocked_ip;
5193 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(
5212 &"2001:4860:4860::8888".parse().unwrap()
5213 )); }
5215
5216 #[test]
5217 fn test_title_case_header() {
5218 assert_eq!(title_case_header("content-type"), "Content-Type");
5219 assert_eq!(title_case_header("authorization"), "Authorization");
5220 assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
5221 assert_eq!(title_case_header("host"), "Host");
5222 assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
5223 assert_eq!(title_case_header("single"), "Single");
5224 assert_eq!(title_case_header(""), "");
5225 }
5226
5227 #[test]
5228 fn test_resolve_url_combines_path_and_query_sources() {
5229 let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
5230 let mut exchange = Exchange::new(Message::default());
5231 exchange.input.set_header(
5232 "CamelHttpPath",
5233 serde_json::Value::String("next".to_string()),
5234 );
5235 let url = HttpProducer::resolve_url(&exchange, &cfg);
5236 assert!(url.starts_with("http://example.com/base/next?"));
5237 assert!(url.contains("foo=bar"));
5238
5239 exchange.input.set_header(
5240 "CamelHttpUri",
5241 serde_json::Value::String("http://other.test/root".to_string()),
5242 );
5243 exchange.input.set_header(
5244 "CamelHttpQuery",
5245 serde_json::Value::String("a=1&b=2".to_string()),
5246 );
5247
5248 let override_url = HttpProducer::resolve_url(&exchange, &cfg);
5249 assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
5250 }
5251
5252 #[test]
5253 fn test_http_producer_helpers_status_and_size_boundaries() {
5254 assert!(HttpProducer::is_ok_status(200, (200, 299)));
5255 assert!(HttpProducer::is_ok_status(299, (200, 299)));
5256 assert!(!HttpProducer::is_ok_status(199, (200, 299)));
5257 assert!(!HttpProducer::is_ok_status(300, (200, 299)));
5258
5259 assert!(!exceeds_max_response_body(10, 10));
5260 assert!(exceeds_max_response_body(11, 10));
5261 }
5262
5263 async fn setup_consumer_on_free_port(
5268 path: &str,
5269 ) -> (
5270 u16,
5271 tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
5272 tokio_util::sync::CancellationToken,
5273 ) {
5274 use camel_component_api::ConsumerContext;
5275
5276 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5277 let port = listener.local_addr().unwrap().port();
5278 drop(listener);
5279
5280 let consumer_cfg = HttpServerConfig {
5281 scheme: "http".to_string(),
5282 host: "127.0.0.1".to_string(),
5283 port,
5284 path: path.to_string(),
5285 max_request_body: 2 * 1024 * 1024,
5286 max_response_body: 10 * 1024 * 1024,
5287 max_inflight_requests: 1024,
5288 method: None,
5289 tls_config: None,
5290 };
5291 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5292
5293 let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
5294 let token = tokio_util::sync::CancellationToken::new();
5295 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5296
5297 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5298 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5299
5300 (port, rx, token)
5301 }
5302
5303 #[tokio::test]
5304 async fn test_content_type_inferred_for_json_body() {
5305 let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
5306
5307 let client = reqwest::Client::new();
5308 let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
5309
5310 let (http_result, _) = tokio::join!(send_fut, async {
5311 if let Some(mut envelope) = rx.recv().await {
5312 envelope.exchange.input.body =
5313 camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
5314 if let Some(reply_tx) = envelope.reply_tx {
5315 let _ = reply_tx.send(Ok(envelope.exchange));
5316 }
5317 }
5318 });
5319
5320 let resp = http_result.unwrap();
5321 assert_eq!(resp.status().as_u16(), 200);
5322 let ct = resp
5323 .headers()
5324 .get("content-type")
5325 .expect("Content-Type header should be present");
5326 assert_eq!(ct, "application/json");
5327 let body = resp.text().await.unwrap();
5328 assert_eq!(body, r#"{"message":"hello"}"#);
5329
5330 token.cancel();
5331 }
5332
5333 #[tokio::test]
5334 async fn test_content_type_inferred_for_text_body() {
5335 let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
5336
5337 let client = reqwest::Client::new();
5338 let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
5339
5340 let (http_result, _) = tokio::join!(send_fut, async {
5341 if let Some(mut envelope) = rx.recv().await {
5342 envelope.exchange.input.body =
5343 camel_component_api::Body::Text("plain text response".to_string());
5344 if let Some(reply_tx) = envelope.reply_tx {
5345 let _ = reply_tx.send(Ok(envelope.exchange));
5346 }
5347 }
5348 });
5349
5350 let resp = http_result.unwrap();
5351 assert_eq!(resp.status().as_u16(), 200);
5352 let ct = resp
5353 .headers()
5354 .get("content-type")
5355 .expect("Content-Type header should be present");
5356 assert_eq!(ct, "text/plain; charset=utf-8");
5357 let body = resp.text().await.unwrap();
5358 assert_eq!(body, "plain text response");
5359
5360 token.cancel();
5361 }
5362
5363 #[tokio::test]
5364 async fn test_content_type_inferred_for_xml_body() {
5365 let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
5366
5367 let client = reqwest::Client::new();
5368 let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
5369
5370 let (http_result, _) = tokio::join!(send_fut, async {
5371 if let Some(mut envelope) = rx.recv().await {
5372 envelope.exchange.input.body =
5373 camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
5374 if let Some(reply_tx) = envelope.reply_tx {
5375 let _ = reply_tx.send(Ok(envelope.exchange));
5376 }
5377 }
5378 });
5379
5380 let resp = http_result.unwrap();
5381 assert_eq!(resp.status().as_u16(), 200);
5382 let ct = resp
5383 .headers()
5384 .get("content-type")
5385 .expect("Content-Type header should be present");
5386 assert_eq!(ct, "application/xml");
5387 let body = resp.text().await.unwrap();
5388 assert_eq!(body, "<root><item>value</item></root>");
5389
5390 token.cancel();
5391 }
5392
5393 #[tokio::test]
5394 async fn test_no_content_type_for_empty_body() {
5395 let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
5396
5397 let client = reqwest::Client::new();
5398 let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
5399
5400 let (http_result, _) = tokio::join!(send_fut, async {
5401 if let Some(mut envelope) = rx.recv().await {
5402 envelope.exchange.input.body = camel_component_api::Body::Empty;
5403 if let Some(reply_tx) = envelope.reply_tx {
5404 let _ = reply_tx.send(Ok(envelope.exchange));
5405 }
5406 }
5407 });
5408
5409 let resp = http_result.unwrap();
5410 assert_eq!(resp.status().as_u16(), 200);
5411 assert!(
5412 resp.headers().get("content-type").is_none(),
5413 "Empty body should not set Content-Type"
5414 );
5415
5416 token.cancel();
5417 }
5418
5419 #[tokio::test]
5420 async fn test_no_content_type_for_raw_bytes_body() {
5421 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
5422
5423 let client = reqwest::Client::new();
5424 let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
5425
5426 let (http_result, _) = tokio::join!(send_fut, async {
5427 if let Some(mut envelope) = rx.recv().await {
5428 envelope.exchange.input.body =
5429 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
5430 if let Some(reply_tx) = envelope.reply_tx {
5431 let _ = reply_tx.send(Ok(envelope.exchange));
5432 }
5433 }
5434 });
5435
5436 let resp = http_result.unwrap();
5437 assert_eq!(resp.status().as_u16(), 200);
5438 assert!(
5439 resp.headers().get("content-type").is_none(),
5440 "Raw Bytes body should not set Content-Type"
5441 );
5442
5443 token.cancel();
5444 }
5445
5446 #[tokio::test]
5447 async fn test_content_type_from_stream_metadata() {
5448 use camel_component_api::{StreamBody, StreamMetadata};
5449 use futures::stream;
5450
5451 let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
5452
5453 let client = reqwest::Client::new();
5454 let send_fut = client
5455 .get(format!("http://127.0.0.1:{port}/stream-ct"))
5456 .send();
5457
5458 let (http_result, _) = tokio::join!(send_fut, async {
5459 if let Some(mut envelope) = rx.recv().await {
5460 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
5461 vec![Ok(bytes::Bytes::from("audio data"))];
5462 let stream = Box::pin(stream::iter(chunks));
5463 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
5464 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
5465 metadata: StreamMetadata {
5466 size_hint: None,
5467 content_type: Some("audio/mpeg".to_string()),
5468 origin: None,
5469 },
5470 });
5471 if let Some(reply_tx) = envelope.reply_tx {
5472 let _ = reply_tx.send(Ok(envelope.exchange));
5473 }
5474 }
5475 });
5476
5477 let resp = http_result.unwrap();
5478 assert_eq!(resp.status().as_u16(), 200);
5479 let ct = resp
5480 .headers()
5481 .get("content-type")
5482 .expect("Content-Type header should be present");
5483 assert_eq!(ct, "audio/mpeg");
5484 let body = resp.text().await.unwrap();
5485 assert_eq!(body, "audio data");
5486
5487 token.cancel();
5488 }
5489
5490 #[tokio::test]
5491 async fn test_user_content_type_overrides_inferred() {
5492 let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
5493
5494 let client = reqwest::Client::new();
5495 let send_fut = client
5496 .get(format!("http://127.0.0.1:{port}/override-ct"))
5497 .send();
5498
5499 let (http_result, _) = tokio::join!(send_fut, async {
5500 if let Some(mut envelope) = rx.recv().await {
5501 envelope.exchange.input.body =
5502 camel_component_api::Body::Json(serde_json::json!({"ok": true}));
5503 envelope.exchange.input.set_header(
5504 "Content-Type",
5505 serde_json::Value::String("text/html".to_string()),
5506 );
5507 if let Some(reply_tx) = envelope.reply_tx {
5508 let _ = reply_tx.send(Ok(envelope.exchange));
5509 }
5510 }
5511 });
5512
5513 let resp = http_result.unwrap();
5514 assert_eq!(resp.status().as_u16(), 200);
5515 let ct = resp
5516 .headers()
5517 .get("content-type")
5518 .expect("Content-Type header should be present");
5519 assert_eq!(
5520 ct, "text/html",
5521 "User-set Content-Type should take precedence over inferred type"
5522 );
5523
5524 token.cancel();
5525 }
5526
5527 #[tokio::test]
5528 async fn test_user_content_type_with_bytes_body() {
5529 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
5530
5531 let client = reqwest::Client::new();
5532 let send_fut = client
5533 .get(format!("http://127.0.0.1:{port}/bytes-ct"))
5534 .send();
5535
5536 let (http_result, _) = tokio::join!(send_fut, async {
5537 if let Some(mut envelope) = rx.recv().await {
5538 envelope.exchange.input.body =
5539 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
5540 envelope.exchange.input.set_header(
5541 "Content-Type",
5542 serde_json::Value::String("application/json".to_string()),
5543 );
5544 if let Some(reply_tx) = envelope.reply_tx {
5545 let _ = reply_tx.send(Ok(envelope.exchange));
5546 }
5547 }
5548 });
5549
5550 let resp = http_result.unwrap();
5551 assert_eq!(resp.status().as_u16(), 200);
5552 let ct = resp
5553 .headers()
5554 .get("content-type")
5555 .expect("Content-Type header should be present for Bytes body with user header");
5556 assert_eq!(
5557 ct, "application/json",
5558 "User Content-Type should be sent for Bytes body"
5559 );
5560
5561 token.cancel();
5562 }
5563
5564 #[tokio::test]
5569 async fn monitor_task_silent_on_clean_exit() {
5570 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
5571 monitor_axum_task(
5573 handle,
5574 "127.0.0.1:0".to_string(),
5575 noop_rt(),
5576 "test-monitor".into(),
5577 )
5578 .await;
5579 }
5580
5581 #[tokio::test]
5582 async fn monitor_task_handles_panicked_task() {
5583 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
5584 panic!("simulated server crash");
5585 });
5586 monitor_axum_task(
5588 handle,
5589 "127.0.0.1:9999".to_string(),
5590 noop_rt(),
5591 "test-monitor".into(),
5592 )
5593 .await;
5594 }
5595
5596 #[test]
5601 fn http_auth_basic_debug_redacts_password() {
5602 let auth = HttpAuth::Basic {
5603 username: "admin".to_string(),
5604 password: "hunter2".to_string(),
5605 };
5606 let debug = format!("{:?}", auth);
5607 assert!(
5608 !debug.contains("hunter2"),
5609 "password must be redacted: {debug}"
5610 );
5611 assert!(debug.contains("admin"), "username should appear: {debug}");
5612 }
5613
5614 #[test]
5615 fn http_auth_bearer_debug_redacts_token() {
5616 let auth = HttpAuth::Bearer {
5617 token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
5618 };
5619 let debug = format!("{:?}", auth);
5620 assert!(
5621 !debug.contains("eyJhbGci"),
5622 "token must be redacted: {debug}"
5623 );
5624 }
5625
5626 #[test]
5627 fn http_auth_none_debug_shows_variant() {
5628 let debug = format!("{:?}", HttpAuth::None);
5629 assert!(
5630 debug.contains("None"),
5631 "None variant should appear: {debug}"
5632 );
5633 }
5634
5635 #[test]
5636 fn http_endpoint_config_debug_redacts_auth_credentials() {
5637 let config = HttpEndpointConfig::from_uri(
5638 "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
5639 )
5640 .unwrap();
5641 let debug = format!("{:?}", config);
5642 assert!(
5643 !debug.contains("secret123"),
5644 "password must be redacted in HttpEndpointConfig debug: {debug}"
5645 );
5646 }
5647
5648 use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
5653 use tower_http::services::ServeDir;
5654
5655 fn make_test_registry() -> HttpRouteRegistry {
5656 HttpRouteRegistry::new()
5657 }
5658
5659 fn make_test_state(registry: HttpRouteRegistry) -> AppState {
5660 AppState {
5661 registry,
5662 max_request_body: 2 * 1024 * 1024,
5663 max_response_body: 10 * 1024 * 1024,
5664 inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
5665 }
5666 }
5667
5668 #[allow(clippy::await_holding_lock)]
5669 #[tokio::test]
5670 async fn test_static_file_serving_serves_file_contents() {
5671 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5672 ServerRegistry::reset();
5673
5674 let temp_dir =
5676 std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
5677 std::fs::create_dir_all(&temp_dir).unwrap();
5678 std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
5679 std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
5680
5681 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
5682
5683 let registry = make_test_registry();
5684 let serve_dir = ServeDir::new(&canonical_dir)
5685 .precompressed_gzip()
5686 .precompressed_br()
5687 .append_index_html_on_directories(true);
5688
5689 let mount = StaticMount {
5690 mount_path: "/".to_string(),
5691 mode: MountMode::Static,
5692 dir: canonical_dir.clone(),
5693 cache_control: "public, max-age=3600".to_string(),
5694 error_pages: std::collections::HashMap::new(),
5695 serve_dir,
5696 };
5697 registry.register_static_mount(mount).await.unwrap();
5698
5699 let state = make_test_state(registry);
5700
5701 let req = Request::builder()
5703 .uri("/hello.txt")
5704 .body(AxumBody::empty())
5705 .unwrap();
5706 let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
5707 assert_eq!(resp.status(), StatusCode::OK);
5708 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5709 .await
5710 .unwrap();
5711 assert_eq!(&body[..], b"Hello, static world!");
5712
5713 let req = Request::builder()
5715 .uri("/style.css")
5716 .body(AxumBody::empty())
5717 .unwrap();
5718 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
5719 assert_eq!(resp.status(), StatusCode::OK);
5720 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5721 .await
5722 .unwrap();
5723 assert_eq!(&body[..], b"body { color: red; }");
5724
5725 let req = Request::builder()
5727 .uri("/missing.txt")
5728 .body(AxumBody::empty())
5729 .unwrap();
5730 let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
5731 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
5732
5733 std::fs::remove_dir_all(&temp_dir).ok();
5735 }
5736
5737 #[allow(clippy::await_holding_lock)]
5738 #[tokio::test]
5739 async fn test_spa_fallback_serves_index_for_unknown_paths() {
5740 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5741 ServerRegistry::reset();
5742
5743 let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
5744 std::fs::create_dir_all(&temp_dir).unwrap();
5745 std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
5746 std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
5747
5748 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
5749
5750 let registry = make_test_registry();
5751 let serve_dir = ServeDir::new(&canonical_dir)
5752 .precompressed_gzip()
5753 .precompressed_br()
5754 .append_index_html_on_directories(true);
5755
5756 let mount = StaticMount {
5757 mount_path: "/".to_string(),
5758 mode: MountMode::Spa,
5759 dir: canonical_dir.clone(),
5760 cache_control: "public, max-age=0".to_string(),
5761 error_pages: std::collections::HashMap::new(),
5762 serve_dir,
5763 };
5764 registry.register_static_mount(mount).await.unwrap();
5766
5767 let state = make_test_state(registry);
5768
5769 let req = Request::builder()
5771 .method("GET")
5772 .uri("/dashboard")
5773 .header("Accept", "text/html")
5774 .body(AxumBody::empty())
5775 .unwrap();
5776 let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
5777 assert_eq!(resp.status(), StatusCode::OK);
5778 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5779 .await
5780 .unwrap();
5781 assert_eq!(&body[..], b"<h1>SPA App</h1>");
5782
5783 let req = Request::builder()
5785 .method("GET")
5786 .uri("/app.js")
5787 .body(AxumBody::empty())
5788 .unwrap();
5789 let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
5790 assert_eq!(resp.status(), StatusCode::OK);
5791 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5792 .await
5793 .unwrap();
5794 assert_eq!(&body[..], b"console.log('app')");
5795
5796 let req = Request::builder()
5798 .method("GET")
5799 .uri("/api/data")
5800 .header("Accept", "application/json")
5801 .body(AxumBody::empty())
5802 .unwrap();
5803 let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
5804 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
5805
5806 let req = Request::builder()
5808 .method("GET")
5809 .uri("/style.css")
5810 .header("Accept", "text/html")
5811 .body(AxumBody::empty())
5812 .unwrap();
5813 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
5814 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
5815
5816 std::fs::remove_dir_all(&temp_dir).ok();
5818 }
5819
5820 #[allow(clippy::await_holding_lock)]
5821 #[tokio::test]
5822 async fn test_error_page_mapping_serves_custom_404() {
5823 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5824 ServerRegistry::reset();
5825
5826 let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
5827 let errors_dir = temp_dir.join("errors");
5828 std::fs::create_dir_all(&errors_dir).unwrap();
5829 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
5830 std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
5831
5832 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
5833 let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
5834
5835 let registry = make_test_registry();
5836 let serve_dir = ServeDir::new(&canonical_dir)
5837 .precompressed_gzip()
5838 .precompressed_br()
5839 .append_index_html_on_directories(true);
5840
5841 let mut error_pages = std::collections::HashMap::new();
5842 error_pages.insert(404, canonical_404);
5843
5844 let mount = StaticMount {
5845 mount_path: "/".to_string(),
5846 mode: MountMode::Static,
5847 dir: canonical_dir.clone(),
5848 cache_control: "public, max-age=0".to_string(),
5849 error_pages,
5850 serve_dir,
5851 };
5852 registry.register_static_mount(mount).await.unwrap();
5853
5854 let state = make_test_state(registry);
5855
5856 let req = Request::builder()
5858 .method("GET")
5859 .uri("/missing.html")
5860 .body(AxumBody::empty())
5861 .unwrap();
5862 let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
5863 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
5864 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5865 .await
5866 .unwrap();
5867 assert_eq!(&body[..], b"<h1>Custom 404</h1>");
5868
5869 let req = Request::builder()
5871 .method("GET")
5872 .uri("/index.html")
5873 .body(AxumBody::empty())
5874 .unwrap();
5875 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
5876 assert_eq!(resp.status(), StatusCode::OK);
5877 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5878 .await
5879 .unwrap();
5880 assert_eq!(&body[..], b"<h1>Home</h1>");
5881
5882 std::fs::remove_dir_all(&temp_dir).ok();
5884 }
5885
5886 #[tokio::test]
5887 async fn http_consumer_returns_body_and_code_on_stop() {
5888 use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
5889 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
5890 use tower::ServiceExt;
5891
5892 let set_body_step = CompiledStep::Process {
5894 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
5895 ex.input.body = Body::Text("nope".into());
5896 Box::pin(async move { Ok(ex) })
5897 }),
5898 body_contract: None,
5899 lifecycle: None,
5900 };
5901 let set_status_step = CompiledStep::Process {
5902 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
5903 ex.input.set_header(
5904 "CamelHttpResponseCode",
5905 serde_json::Value::Number(409.into()),
5906 );
5907 Box::pin(async move { Ok(ex) })
5908 }),
5909 body_contract: None,
5910 lifecycle: None,
5911 };
5912 let pipeline = compose_pipeline_with_handler(
5913 vec![set_body_step, set_status_step, CompiledStep::Stop],
5914 None,
5915 PipelineRuntimeCtx::compile_time(),
5916 );
5917
5918 let ex = Exchange::new(Message::default());
5919 let result = pipeline.oneshot(ex).await;
5920 assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
5921 let returned = result.unwrap();
5922 assert_eq!(returned.input.body.as_text(), Some("nope"));
5923 assert_eq!(
5924 returned
5925 .input
5926 .header("CamelHttpResponseCode")
5927 .and_then(|v| v.as_u64()),
5928 Some(409)
5929 );
5930 }
5931
5932 #[tokio::test]
5933 async fn http_consumer_returns_200_when_body_empty_on_stop() {
5934 use camel_api::{Exchange, Message};
5942 use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
5943 use tower::ServiceExt;
5944
5945 let pipeline = compose_pipeline_with_handler(
5946 vec![CompiledStep::Stop],
5947 None,
5948 PipelineRuntimeCtx::compile_time(),
5949 );
5950 let ex = Exchange::new(Message::default());
5951 let result = pipeline.oneshot(ex).await;
5952 assert!(result.is_ok(), "Stop with empty body arrives as Ok");
5953 }
5956
5957 async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
5965 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5966 let port = listener.local_addr().unwrap().port();
5967 let registry = HttpRouteRegistry::new();
5968 tokio::spawn(run_axum_server(
5969 listener,
5970 registry.clone(),
5971 2 * 1024 * 1024,
5972 10 * 1024 * 1024,
5973 Arc::new(tokio::sync::Semaphore::new(1024)),
5974 test_rt(),
5975 "test-route".into(),
5976 ));
5977 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5979 (port, registry)
5980 }
5981
5982 fn spawn_responder(
5987 mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
5988 status: u16,
5989 body: String,
5990 ) -> tokio::task::JoinHandle<()> {
5991 tokio::spawn(async move {
5992 if let Some(envelope) = rx.recv().await {
5993 let _ = envelope.reply_tx.send(HttpReply {
5994 status,
5995 headers: vec![],
5996 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
5997 });
5998 }
5999 })
6000 }
6001
6002 #[tokio::test]
6003 async fn method_aware_dispatch_same_path_different_verbs() {
6004 let (port, registry) = spawn_test_server().await;
6005
6006 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
6011 registry
6012 .register_rest_endpoint(
6013 "GET".into(),
6014 vec![PathSegment::Literal("users".into())],
6015 get_tx,
6016 )
6017 .await;
6018
6019 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
6020 registry
6021 .register_rest_endpoint(
6022 "POST".into(),
6023 vec![PathSegment::Literal("users".into())],
6024 post_tx,
6025 )
6026 .await;
6027
6028 let get_handle = spawn_responder(get_rx, 200, "list".into());
6029 let post_handle = spawn_responder(post_rx, 201, "create".into());
6030
6031 let client = reqwest::Client::new();
6032
6033 let resp = client
6035 .get(format!("http://127.0.0.1:{port}/users"))
6036 .send()
6037 .await
6038 .unwrap();
6039 assert_eq!(resp.status().as_u16(), 200);
6040 let body = resp.text().await.unwrap();
6041 assert_eq!(body, "list");
6042
6043 let resp = client
6045 .post(format!("http://127.0.0.1:{port}/users"))
6046 .send()
6047 .await
6048 .unwrap();
6049 assert_eq!(resp.status().as_u16(), 201);
6050 let body = resp.text().await.unwrap();
6051 assert_eq!(body, "create");
6052
6053 let _ = tokio::join!(get_handle, post_handle);
6054 }
6055
6056 #[tokio::test]
6057 async fn method_aware_dispatch_templated_path_extracts_params() {
6058 let (port, registry) = spawn_test_server().await;
6059
6060 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
6064 registry
6065 .register_rest_endpoint(
6066 "GET".into(),
6067 vec![
6068 PathSegment::Literal("users".into()),
6069 PathSegment::Param("id".into()),
6070 ],
6071 tx,
6072 )
6073 .await;
6074
6075 let handle = tokio::spawn(async move {
6078 if let Some(envelope) = rx.recv().await {
6079 let id = envelope.path_params.get("id").cloned().unwrap_or_default();
6080 let _ = envelope.reply_tx.send(HttpReply {
6081 status: 200,
6082 headers: vec![],
6083 body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
6084 });
6085 }
6086 });
6087
6088 let client = reqwest::Client::new();
6089 let resp = client
6090 .get(format!("http://127.0.0.1:{port}/users/42"))
6091 .send()
6092 .await
6093 .unwrap();
6094 assert_eq!(resp.status().as_u16(), 200);
6095 let body = resp.text().await.unwrap();
6096 assert_eq!(body, "id=42");
6097
6098 let _ = handle.await;
6099 }
6100
6101 #[tokio::test]
6102 async fn method_aware_dispatch_unmatched_method_falls_through() {
6103 let (port, _registry) = spawn_test_server().await;
6108
6109 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
6111 _registry
6112 .register_rest_endpoint(
6113 "GET".into(),
6114 vec![PathSegment::Literal("users".into())],
6115 get_tx,
6116 )
6117 .await;
6118
6119 let drain = tokio::spawn(async move {
6122 let mut get_rx = get_rx;
6123 while get_rx.recv().await.is_some() {}
6124 });
6125
6126 let client = reqwest::Client::new();
6127 let resp = client
6128 .delete(format!("http://127.0.0.1:{port}/users"))
6129 .send()
6130 .await
6131 .unwrap();
6132 assert_eq!(resp.status().as_u16(), 404);
6133
6134 drop(drain);
6135 }
6136
6137 #[tokio::test]
6138 async fn regression_legacy_exact_api_route_still_works() {
6139 let (port, registry) = spawn_test_server().await;
6144
6145 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
6146 registry.register_api_route("/legacy/path".into(), tx).await;
6147
6148 let handle = tokio::spawn(async move {
6149 if let Some(envelope) = rx.recv().await {
6150 let _ = envelope.reply_tx.send(HttpReply {
6151 status: 200,
6152 headers: vec![],
6153 body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
6154 });
6155 }
6156 });
6157
6158 let client = reqwest::Client::new();
6159 let resp = client
6160 .get(format!("http://127.0.0.1:{port}/legacy/path"))
6161 .send()
6162 .await
6163 .unwrap();
6164 assert_eq!(resp.status().as_u16(), 200);
6165 let body = resp.text().await.unwrap();
6166 assert_eq!(body, "legacy ok");
6167
6168 let _ = handle.await;
6169 }
6170
6171 #[allow(clippy::await_holding_lock)]
6172 #[tokio::test]
6173 async fn regression_static_mount_still_works() {
6174 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6178 ServerRegistry::reset();
6179
6180 let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
6181 std::fs::create_dir_all(&temp_dir).unwrap();
6182 std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
6183 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
6184
6185 let registry = make_test_registry();
6186 let serve_dir = ServeDir::new(&canonical_dir)
6187 .precompressed_gzip()
6188 .precompressed_br()
6189 .append_index_html_on_directories(true);
6190 let mount = StaticMount {
6191 mount_path: "/".to_string(),
6192 mode: MountMode::Static,
6193 dir: canonical_dir.clone(),
6194 cache_control: "public, max-age=3600".to_string(),
6195 error_pages: std::collections::HashMap::new(),
6196 serve_dir,
6197 };
6198 registry.register_static_mount(mount).await.unwrap();
6199
6200 let state = make_test_state(registry);
6201 let req = Request::builder()
6202 .uri("/regress.txt")
6203 .body(AxumBody::empty())
6204 .unwrap();
6205 let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
6206 assert_eq!(resp.status(), StatusCode::OK);
6207 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6208 .await
6209 .unwrap();
6210 assert_eq!(&body[..], b"static works");
6211
6212 std::fs::remove_dir_all(&temp_dir).ok();
6213 }
6214
6215 #[tokio::test]
6224 async fn deregister_one_method_keeps_sibling_verbs() {
6225 let (port, registry) = spawn_test_server().await;
6229
6230 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
6231 registry
6232 .register_rest_endpoint(
6233 "GET".into(),
6234 vec![PathSegment::Literal("users".into())],
6235 get_tx,
6236 )
6237 .await;
6238
6239 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
6240 registry
6241 .register_rest_endpoint(
6242 "POST".into(),
6243 vec![PathSegment::Literal("users".into())],
6244 post_tx,
6245 )
6246 .await;
6247
6248 let drain = tokio::spawn(async move {
6250 let mut get_rx = get_rx;
6251 while get_rx.recv().await.is_some() {}
6252 });
6253
6254 registry.unregister_rest_endpoint("GET", "/users").await;
6256 drop(drain);
6257
6258 let post_handle = spawn_responder(post_rx, 201, "create".into());
6259
6260 let client = reqwest::Client::new();
6261 let resp = client
6263 .post(format!("http://127.0.0.1:{port}/users"))
6264 .send()
6265 .await
6266 .unwrap();
6267 assert_eq!(resp.status().as_u16(), 201);
6268 assert_eq!(resp.text().await.unwrap(), "create");
6269
6270 let _ = post_handle.await;
6271 }
6272
6273 #[tokio::test]
6274 async fn dispatch_exact_legacy_beats_rest_template() {
6275 let (port, registry) = spawn_test_server().await;
6280
6281 let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
6283 registry
6284 .register_api_route("/api/users".into(), exact_tx)
6285 .await;
6286 let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
6287
6288 let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
6290 registry
6291 .register_rest_endpoint(
6292 "GET".into(),
6293 vec![
6294 PathSegment::Literal("api".into()),
6295 PathSegment::Param("resource".into()),
6296 ],
6297 tpl_tx,
6298 )
6299 .await;
6300 let _tpl_drain = tokio::spawn(async move {
6306 let mut tpl_rx = tpl_rx;
6307 if let Some(env) = tpl_rx.recv().await {
6308 let _ = env.reply_tx.send(HttpReply {
6309 status: 200,
6310 headers: vec![],
6311 body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
6312 });
6313 }
6314 });
6315
6316 let client = reqwest::Client::new();
6317 let resp = client
6318 .get(format!("http://127.0.0.1:{port}/api/users"))
6319 .send()
6320 .await
6321 .unwrap();
6322 assert_eq!(resp.status().as_u16(), 200);
6323 assert_eq!(resp.text().await.unwrap(), "exact");
6325
6326 let _ = exact_handle.await;
6327 }
6328
6329 #[tokio::test]
6330 async fn ambiguous_rest_templates_return_500_not_silent_404() {
6331 let (port, registry) = spawn_test_server().await;
6336
6337 let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
6338 registry
6339 .register_rest_endpoint(
6340 "GET".into(),
6341 vec![
6342 PathSegment::Literal("users".into()),
6343 PathSegment::Param("id".into()),
6344 ],
6345 a_tx,
6346 )
6347 .await;
6348
6349 let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
6350 registry
6351 .register_rest_endpoint(
6352 "GET".into(),
6353 vec![
6354 PathSegment::Literal("users".into()),
6355 PathSegment::Param("name".into()),
6356 ],
6357 b_tx,
6358 )
6359 .await;
6360
6361 let client = reqwest::Client::new();
6362 let resp = client
6363 .get(format!("http://127.0.0.1:{port}/users/42"))
6364 .send()
6365 .await
6366 .unwrap();
6367 assert_eq!(resp.status().as_u16(), 500);
6369 }
6370
6371 #[test]
6372 fn from_uri_round_trips_templated_path_with_http_method() {
6373 use crate::UriConfig;
6379 let cfg =
6380 HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
6381 assert_eq!(cfg.host, "0.0.0.0");
6382 assert_eq!(cfg.port, 8080);
6383 assert_eq!(cfg.path, "/users/{id}");
6384 assert_eq!(cfg.method.as_deref(), Some("GET"));
6385
6386 let cfg_lc =
6388 HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
6389 assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
6390 assert_eq!(cfg_lc.path, "/orders");
6391 }
6392
6393 #[test]
6398 fn type_conversion_failed_maps_to_400() {
6399 let reply = pipeline_error_to_reply(
6400 CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
6401 "/api/users",
6402 );
6403 assert_eq!(reply.status, 400);
6404 let ct = reply
6406 .headers
6407 .iter()
6408 .find(|(k, _)| k == "Content-Type")
6409 .map(|(_, v)| v.as_str());
6410 assert_eq!(ct, Some("application/json"));
6411 let body = match &reply.body {
6413 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
6414 _ => panic!("expected bytes body"),
6415 };
6416 assert!(body.contains("\"error\""));
6417 assert!(body.contains("bad_request"));
6418 assert!(body.contains("invalid JSON at line 1"));
6419 }
6420
6421 #[test]
6422 fn other_error_still_maps_to_500() {
6423 let reply =
6424 pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
6425 assert_eq!(reply.status, 500);
6426 }
6427
6428 #[test]
6429 fn unauthenticated_maps_to_401() {
6430 let reply = pipeline_error_to_reply(
6431 CamelError::Unauthenticated("no token".to_string()),
6432 "/api/users",
6433 );
6434 assert_eq!(reply.status, 401);
6435 }
6436
6437 #[test]
6438 fn unauthorized_maps_to_403() {
6439 let reply = pipeline_error_to_reply(
6440 CamelError::Unauthorized("forbidden".to_string()),
6441 "/api/users",
6442 );
6443 assert_eq!(reply.status, 403);
6444 }
6445
6446 #[test]
6447 fn validation_error_maps_to_400() {
6448 let reply = pipeline_error_to_reply(
6449 CamelError::ValidationError("body does not match schema".to_string()),
6450 "/api/users",
6451 );
6452 assert_eq!(reply.status, 400);
6453 let ct = reply
6454 .headers
6455 .iter()
6456 .find(|(k, _)| k == "Content-Type")
6457 .map(|(_, v)| v.as_str());
6458 assert_eq!(ct, Some("application/json"));
6459 let body = match &reply.body {
6460 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
6461 _ => panic!("expected bytes body"),
6462 };
6463 assert!(body.contains("\"error\""));
6464 assert!(body.contains("validation_error"));
6465 assert!(body.contains("body does not match schema"));
6466 }
6467
6468 #[test]
6469 fn https_consumer_without_tls_cert_errors() {
6470 let endpoint = HttpEndpoint {
6471 uri: "https://0.0.0.0:8443/api".to_string(),
6472 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
6473 server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
6474 client: reqwest::Client::new(),
6475 http_config: HttpConfig::default(),
6476 };
6477 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
6478 let result = endpoint.create_consumer(rt);
6479 assert!(result.is_err(), "expected error for https without tls cert");
6480 if let Err(e) = result {
6481 let msg = e.to_string();
6482 assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
6483 }
6484 }
6485
6486 #[test]
6487 fn http_consumer_with_tls_config_errors() {
6488 let endpoint = HttpEndpoint {
6489 uri: "http://0.0.0.0:8080/api".to_string(),
6490 config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
6491 server_config: HttpServerConfig::from_uri(
6492 "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
6493 )
6494 .unwrap(),
6495 client: reqwest::Client::new(),
6496 http_config: HttpConfig::default(),
6497 };
6498 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
6499 let result = endpoint.create_consumer(rt);
6500 assert!(result.is_err(), "expected error for http with tls config");
6501 if let Err(e) = result {
6502 let msg = e.to_string();
6503 assert!(msg.contains("https"), "error must mention https: {msg}");
6504 }
6505 }
6506
6507 #[test]
6508 fn https_consumer_with_partial_tls_cert_only_errors() {
6509 let server_config =
6512 HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
6513 assert!(
6514 server_config.tls_config.is_none(),
6515 "partial tlsCert must not create ServerTlsConfig"
6516 );
6517 let endpoint = HttpEndpoint {
6518 uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
6519 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
6520 .unwrap(),
6521 server_config,
6522 client: reqwest::Client::new(),
6523 http_config: HttpConfig::default(),
6524 };
6525 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
6526 let result = endpoint.create_consumer(rt);
6527 assert!(
6528 result.is_err(),
6529 "must error: https:// requires both tlsCert and tlsKey"
6530 );
6531 }
6532
6533 #[test]
6534 fn load_tls_config_parses_valid_pem() {
6535 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
6537 use camel_component_api::test_support::tls;
6538 let (_, cert_pem, key_pem) = tls::gen_server_cert();
6539 let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
6540 let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
6541
6542 let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
6543 assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
6544 }
6545
6546 #[tokio::test(flavor = "multi_thread")]
6547 #[allow(clippy::await_holding_lock)]
6548 async fn consumer_tls_handshake_roundtrip() {
6549 use camel_component_api::test_support::tls;
6550 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6551
6552 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
6554
6555 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6557
6558 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
6560 let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
6561 let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
6562 let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
6563
6564 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6566 let port = probe.local_addr().unwrap().port();
6567 drop(probe);
6568
6569 ServerRegistry::reset();
6570
6571 let component = HttpComponent::new();
6573 let endpoint_ctx = NoOpComponentContext;
6574 let uri = format!(
6575 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
6576 cert_path.to_string_lossy(),
6577 key_path.to_string_lossy(),
6578 );
6579 let endpoint = component
6580 .create_endpoint(&uri, &endpoint_ctx)
6581 .expect("create TLS endpoint");
6582 let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
6583
6584 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6586 let token = tokio_util::sync::CancellationToken::new();
6587 let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
6588 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6589
6590 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
6592
6593 let ca_bytes = std::fs::read(&ca_path).unwrap();
6595 let client = reqwest::Client::builder()
6596 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
6597 .build()
6598 .unwrap();
6599
6600 let send_fut = client
6601 .post(format!("https://localhost:{port}/test"))
6602 .body("ping")
6603 .send();
6604
6605 let (http_result, _) = tokio::join!(send_fut, async {
6607 if let Some(mut envelope) = rx.recv().await {
6608 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
6609 if let Some(reply_tx) = envelope.reply_tx {
6610 let _ = reply_tx.send(Ok(envelope.exchange));
6611 }
6612 }
6613 });
6614
6615 let resp = http_result.expect("TLS handshake + request must succeed");
6616
6617 assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
6618 let body = resp.text().await.unwrap();
6619 assert_eq!(body, "pong");
6620
6621 token.cancel();
6622 }
6623
6624 #[tokio::test(flavor = "multi_thread")]
6625 #[allow(clippy::await_holding_lock)]
6626 async fn consumer_tls_rejects_client_without_ca() {
6627 use camel_component_api::test_support::tls;
6628 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6629
6630 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
6631
6632 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6634
6635 let (_, cert_pem, key_pem) = tls::gen_server_cert();
6636 let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
6637 let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
6638
6639 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6640 let port = probe.local_addr().unwrap().port();
6641 drop(probe);
6642
6643 ServerRegistry::reset();
6644
6645 let component = HttpComponent::new();
6647 let endpoint_ctx = NoOpComponentContext;
6648 let uri = format!(
6649 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
6650 cert_path.to_string_lossy(),
6651 key_path.to_string_lossy(),
6652 );
6653 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
6654 let mut consumer = endpoint.create_consumer(rt()).unwrap();
6655 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6656 let token = tokio_util::sync::CancellationToken::new();
6657 let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
6658 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6659
6660 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
6661
6662 let client = reqwest::Client::builder().build().unwrap();
6664
6665 let result = client
6666 .get(format!("https://localhost:{port}/test"))
6667 .send()
6668 .await;
6669
6670 assert!(
6671 result.is_err(),
6672 "must reject without CA — proves real verification"
6673 );
6674
6675 token.cancel();
6676 }
6677
6678 #[test]
6679 fn server_config_partial_tls_cert_without_key() {
6680 let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
6682 assert!(cfg.tls_config.is_none());
6684 }
6685
6686 #[test]
6687 fn endpoint_uri_options_count_parity() {
6688 assert_eq!(
6690 HttpEndpointConfig::uri_options().len(),
6691 20,
6692 "HttpEndpointUriConfig #[uri_param] count drifted from parser"
6693 );
6694 }
6695}