Skip to main content

camel_component_http/
lib.rs

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