Skip to main content

mq_bridge/endpoints/
mod.rs

1//  mq-bridge
2//  © Copyright 2025, by Marco Mengelkoch
3//  Licensed under MIT License, see License file for more details
4//  git clone https://github.com/marcomq/mq-bridge
5
6#[cfg(feature = "amqp")]
7pub mod amqp;
8#[cfg(feature = "aws")]
9pub mod aws;
10#[cfg(feature = "clickhouse")]
11pub mod clickhouse;
12pub mod file;
13#[cfg(feature = "grpc")]
14pub mod grpc;
15#[cfg(feature = "http")]
16pub mod http;
17#[cfg(any(feature = "ibm-mq-static", feature = "ibm-mq"))]
18pub mod ibm_mq;
19#[cfg(feature = "kafka")]
20pub mod kafka;
21pub mod memory;
22#[cfg(feature = "mongodb")]
23pub mod mongodb;
24#[cfg(feature = "mqtt")]
25pub mod mqtt;
26#[cfg(feature = "nats")]
27pub mod nats;
28#[cfg(feature = "object-store")]
29pub mod object_store;
30#[cfg(any(feature = "sqlx", feature = "clickhouse"))]
31mod poll;
32#[cfg(feature = "postgres-cdc")]
33pub mod postgres;
34#[cfg(feature = "redis-streams")]
35pub mod redis_streams;
36#[cfg(feature = "sled")]
37pub mod sled;
38#[cfg(feature = "sqlx")]
39pub mod sqlx;
40/// Structural endpoints (`fanout`, `switch`, `request`, `response`, `reader`,
41/// `static`, `stream_buffer`, `null`) that route or terminate a flow instead of
42/// talking to an external system.
43pub mod structural;
44#[cfg(feature = "websocket")]
45pub mod websocket;
46#[cfg(any(feature = "zeromq", feature = "zeromq-omq"))]
47pub mod zeromq;
48use crate::endpoints::memory::{get_or_create_channel, MemoryChannel};
49/// Backwards-compatible aliases for the structural endpoints, which used to live
50/// directly under `endpoints`. Prefer `endpoints::structural::*`.
51#[doc(hidden)]
52pub use crate::endpoints::structural::{
53    fanout, null, reader, request, response, static_endpoint, stream_buffer, switch,
54};
55use crate::middleware::apply_middlewares_to_consumer;
56use crate::models::{
57    Endpoint, EndpointType, MemoryConfig, Middleware, ResponseConfig, StreamBufferConfig,
58};
59use crate::route::get_endpoint_factory;
60use crate::traits::{BoxFuture, MessageConsumer, MessagePublisher};
61use anyhow::{anyhow, Result};
62use std::sync::Arc;
63
64impl Endpoint {
65    pub fn new(endpoint_type: EndpointType) -> Self {
66        Self {
67            middlewares: Vec::new(),
68            endpoint_type,
69            handler: None,
70        }
71    }
72    /// Creates a new in-memory endpoint with the specified topic and capacity.
73    ///
74    /// # Examples
75    ///
76    /// ```
77    /// use mq_bridge::models::Endpoint;
78    /// let endpoint = Endpoint::new_memory("my_topic", 100);
79    /// ```
80    pub fn new_memory(topic: &str, capacity: usize) -> Self {
81        Self::new(EndpointType::Memory(MemoryConfig::new(
82            topic,
83            Some(capacity),
84        )))
85    }
86    pub fn new_response() -> Self {
87        Self::new(EndpointType::Response(ResponseConfig::default()))
88    }
89    pub fn new_stream_buffer(topic: &str, correlation_id: Option<&str>, capacity: usize) -> Self {
90        Self::new(EndpointType::StreamBuffer(StreamBufferConfig {
91            topic: topic.to_string(),
92            correlation_id: correlation_id.map(str::to_string),
93            capacity: Some(capacity),
94        }))
95    }
96    pub fn has_retry_middleware(&self) -> bool {
97        self.middlewares
98            .iter()
99            .any(|m| matches!(m, Middleware::Retry(_)))
100    }
101    pub fn add_middleware(mut self, middleware: Middleware) -> Self {
102        self.middlewares.push(middleware);
103        self
104    }
105    pub fn add_middlewares(mut self, mut middlewares: Vec<Middleware>) -> Self {
106        self.middlewares.append(&mut middlewares);
107        self
108    }
109    ///
110    /// Returns a reference to the in-memory channel associated with this Endpoint.
111    /// This function will only succeed if the Endpoint is of type EndpointType::Memory.
112    /// If the Endpoint is not a memory endpoint, this function will return an error.
113    /// This function is primarily used for testing purposes where a Queue is needed.
114    pub fn channel(&self) -> anyhow::Result<MemoryChannel> {
115        match &self.endpoint_type {
116            EndpointType::Memory(cfg) => Ok(get_or_create_channel(cfg)),
117            _ => Err(anyhow::anyhow!("channel() called on non-memory Endpoint")),
118        }
119    }
120    pub fn null() -> Self {
121        Self::new(EndpointType::Null)
122    }
123
124    pub fn with_retry(mut self, retry: crate::models::RetryMiddleware) -> Self {
125        // Retry should be inner to DLQ and Metrics.
126        // We insert it before any existing DLQ or Metrics middleware.
127        let mut insert_idx = self.middlewares.len();
128        for (i, m) in self.middlewares.iter().enumerate() {
129            if matches!(m, Middleware::Dlq(_) | Middleware::Metrics(_)) {
130                insert_idx = i;
131                break;
132            }
133        }
134        self.middlewares
135            .insert(insert_idx, Middleware::Retry(retry));
136        self
137    }
138
139    pub fn with_dlq(mut self, dlq: crate::models::DeadLetterQueueMiddleware) -> Self {
140        // DLQ should be outer to Retry, but inner to Metrics.
141        let mut insert_idx = self.middlewares.len();
142        for (i, m) in self.middlewares.iter().enumerate() {
143            if matches!(m, Middleware::Metrics(_)) {
144                insert_idx = i;
145                break;
146            }
147        }
148        self.middlewares
149            .insert(insert_idx, Middleware::Dlq(Box::new(dlq)));
150        self
151    }
152
153    pub fn with_deduplication(mut self, dedup: crate::models::DeduplicationMiddleware) -> Self {
154        // Deduplication is consumer-only.
155        // We insert it at the beginning so it is applied last (innermost) for consumers,
156        // or second to last if metrics are at 0.
157        // List: [Dedup, ...] -> Consumer: ... ( Dedup ( base ) )
158        self.middlewares.insert(0, Middleware::Deduplication(dedup));
159        self
160    }
161
162    pub fn with_consumer_metrics(mut self) -> Self {
163        // For consumers, the first middleware in the list is the outermost (applied last).
164        // Inserting at 0 ensures it wraps everything else (Ingestion Metrics).
165        // List: [Metrics, Dedup] -> Consumer: Metrics ( Dedup ( base ) )
166        if !self
167            .middlewares
168            .iter()
169            .any(|m| matches!(m, Middleware::Metrics(_)))
170        {
171            self.middlewares
172                .insert(0, Middleware::Metrics(crate::models::MetricsMiddleware {}));
173        }
174        self
175    }
176
177    pub fn with_metrics(mut self) -> Self {
178        // Metrics should be outer to everything (last in the list for publishers).
179        if !self
180            .middlewares
181            .iter()
182            .any(|m| matches!(m, Middleware::Metrics(_)))
183        {
184            self.middlewares
185                .push(Middleware::Metrics(crate::models::MetricsMiddleware {}));
186        }
187        self
188    }
189
190    pub async fn create_consumer(
191        &self,
192        route_name: &str,
193    ) -> anyhow::Result<Box<dyn crate::traits::MessageConsumer>> {
194        crate::endpoints::create_consumer_from_route(route_name, self).await
195    }
196
197    pub async fn create_publisher(&self, _route_name: &str) -> anyhow::Result<crate::Publisher> {
198        crate::Publisher::new(self.clone()).await
199    }
200
201    pub fn check_consumer(
202        &self,
203        route_name: &str,
204        allowed_endpoints: Option<&[&str]>,
205    ) -> anyhow::Result<Vec<String>> {
206        crate::endpoints::check_consumer(route_name, self, allowed_endpoints)
207    }
208
209    pub fn check_publisher(
210        &self,
211        route_name: &str,
212        allowed_endpoints: Option<&[&str]>,
213    ) -> anyhow::Result<Vec<String>> {
214        crate::endpoints::check_publisher(route_name, self, allowed_endpoints)
215    }
216}
217
218/// Validates the consumer configuration for a route.
219pub fn check_consumer(
220    route_name: &str,
221    endpoint: &Endpoint,
222    allowed_types: Option<&[&str]>,
223) -> Result<Vec<String>> {
224    check_consumer_recursive(route_name, endpoint, 0, allowed_types)
225}
226
227fn check_consumer_recursive(
228    route_name: &str,
229    endpoint: &Endpoint,
230    depth: usize,
231    allowed_types: Option<&[&str]>,
232) -> Result<Vec<String>> {
233    const MAX_DEPTH: usize = 16;
234    if depth > MAX_DEPTH {
235        return Err(anyhow!(
236            "Ref recursion depth exceeded limit of {}",
237            MAX_DEPTH
238        ));
239    }
240    let mut warnings = Vec::new();
241    if endpoint.handler.is_some() {
242        warnings.push(
243            "Endpoint 'handler' is set on an input endpoint. Handlers are currently only supported on output endpoints (publishers) and will be ignored here."
244            .to_string()
245        );
246    }
247
248    if let Some(allowed) = allowed_types {
249        if !endpoint.endpoint_type.is_core() {
250            let name = endpoint.endpoint_type.name();
251            if !allowed.contains(&name) {
252                return Err(anyhow!(
253                    "[route:{}] Endpoint type '{}' is not allowed by policy",
254                    route_name,
255                    name
256                ));
257            }
258        }
259    }
260    match &endpoint.endpoint_type {
261        EndpointType::Ref(name) => {
262            let referenced = crate::route::get_endpoint(name).ok_or_else(|| {
263                anyhow!(
264                    "[route:{}] Referenced endpoint '{}' not found",
265                    route_name,
266                    name
267                )
268            })?;
269            // We need to check the referenced endpoint, but we don't need to merge middlewares
270            // for the check itself, as we just want to validate the core type.
271            // However, to be thorough, we recurse on the referenced endpoint.
272            // Note: This check ignores the middlewares on the 'ref' itself, which is acceptable for type checking.
273            warnings.extend(check_consumer_recursive(
274                route_name,
275                &referenced,
276                depth + 1,
277                allowed_types,
278            )?);
279            Ok(warnings)
280        }
281        #[cfg(feature = "aws")]
282        EndpointType::Aws(cfg) => {
283            if cfg.topic_arn.is_some() {
284                warnings.push(
285                    "Endpoint 'aws' is used as a consumer, but 'topic_arn' is a publisher-only option and will be ignored."
286                    .to_string()
287                );
288            }
289            Ok(warnings)
290        }
291        #[cfg(feature = "kafka")]
292        EndpointType::Kafka(cfg) => {
293            if cfg.delayed_ack {
294                warnings.push(
295                    "Endpoint 'kafka' is used as a consumer, but 'delayed_ack' is a publisher-only option and will be ignored."
296                    .to_string()
297                );
298            }
299            if cfg.producer_options.is_some() {
300                warnings.push(
301                    "Endpoint 'kafka' is used as a consumer, but 'producer_options' is a publisher-only option and will be ignored."
302                    .to_string()
303                );
304            }
305            Ok(warnings)
306        }
307        #[cfg(feature = "nats")]
308        EndpointType::Nats(cfg) => {
309            if cfg.stream.is_none() {
310                return Err(anyhow!(
311                    "[route:{}] NATS consumer must specify a 'stream'",
312                    route_name
313                ));
314            }
315            if cfg.request_reply {
316                warnings.push(
317                    "Endpoint 'nats' is used as a consumer, but 'request_reply' is a publisher-only option and will be ignored."
318                    .to_string()
319                );
320            }
321            if cfg.request_timeout_ms.is_some() {
322                warnings.push(
323                    "Endpoint 'nats' is used as a consumer, but 'request_timeout_ms' is a publisher-only option and will be ignored."
324                    .to_string()
325                );
326            }
327            if cfg.delayed_ack {
328                warnings.push(
329                    "Endpoint 'nats' is used as a consumer, but 'delayed_ack' is a publisher-only option and will be ignored."
330                    .to_string()
331                );
332            }
333            if cfg.stream_max_messages.is_some() {
334                warnings.push(
335                    "Endpoint 'nats' is used as a consumer, but 'stream_max_messages' is a publisher-only option and will be ignored."
336                    .to_string()
337                );
338            }
339            if cfg.stream_max_bytes.is_some() {
340                warnings.push(
341                    "Endpoint 'nats' is used as a consumer, but 'stream_max_bytes' is a publisher-only option and will be ignored."
342                    .to_string()
343                );
344            }
345            Ok(warnings)
346        }
347        #[cfg(feature = "amqp")]
348        EndpointType::Amqp(cfg) => {
349            if cfg.delayed_ack {
350                warnings.push(
351                    "Endpoint 'amqp' is used as a consumer, but 'delayed_ack' is a publisher-only option and will be ignored."
352                    .to_string()
353                );
354            }
355            Ok(warnings)
356        }
357        #[cfg(feature = "mqtt")]
358        EndpointType::Mqtt(cfg) => {
359            if cfg.delayed_ack {
360                warnings.push(
361                    "Endpoint 'mqtt' is used as a consumer, but 'delayed_ack' is a publisher-only option and will be ignored."
362                    .to_string()
363                );
364            }
365            Ok(warnings)
366        }
367        #[cfg(feature = "zeromq")]
368        EndpointType::ZeroMq(_) => Ok(warnings),
369        #[cfg(feature = "redis-streams")]
370        EndpointType::RedisStreams(cfg) => {
371            if cfg.maxlen.is_some() {
372                warnings.push(
373                    "Endpoint 'redis_streams' is used as a consumer, but 'maxlen' is a publisher-only option and will be ignored."
374                    .to_string()
375                );
376            }
377            if cfg.approx_trim.is_some() {
378                warnings.push(
379                    "Endpoint 'redis_streams' is used as a consumer, but 'approx_trim' is a publisher-only option and will be ignored."
380                    .to_string()
381                );
382            }
383            Ok(warnings)
384        }
385        #[cfg(any(feature = "ibm-mq-static", feature = "ibm-mq"))]
386        EndpointType::IbmMq(_) => Ok(warnings),
387        #[cfg(feature = "mongodb")]
388        EndpointType::MongoDb(cfg) => {
389            use crate::models::MongoConsume;
390            let mode = cfg.resolved_consume();
391            if mode == MongoConsume::Subscriber
392                && matches!(cfg.format, crate::models::MongoDbFormat::Raw)
393            {
394                return Err(anyhow!(
395                    "[route:{}] MongoDB raw format cannot be used with subscriber mode because raw documents do not include the seq ordering field",
396                    route_name
397                ));
398            }
399            if cfg.consume.is_some() && cfg.change_stream {
400                warnings.push(
401                    "Endpoint 'mongodb' sets 'consume'; the deprecated 'change_stream' boolean is ignored and should be removed."
402                    .to_string(),
403                );
404            } else if cfg.change_stream {
405                warnings.push(
406                    "Endpoint 'mongodb' option 'change_stream' is deprecated; use 'consume: subscriber'."
407                    .to_string(),
408                );
409            }
410            if cfg.reply_polling_ms.is_some() {
411                warnings.push(
412                    "Endpoint 'mongodb' is used as a consumer, but 'reply_polling_ms' is a publisher-only option and will be ignored."
413                    .to_string()
414                );
415            }
416            if cfg.request_reply {
417                warnings.push(
418                    "Endpoint 'mongodb' is used as a consumer, but 'request_reply' is a publisher-only option and will be ignored."
419                    .to_string()
420                );
421            }
422            if cfg.request_timeout_ms.is_some() {
423                warnings.push(
424                    "Endpoint 'mongodb' is used as a consumer, but 'request_timeout_ms' is a publisher-only option and will be ignored."
425                    .to_string()
426                );
427            }
428            if cfg.ttl_seconds.is_some() {
429                warnings.push(
430                    "Endpoint 'mongodb' is used as a consumer, but 'ttl_seconds' is a publisher-only option and will be ignored."
431                    .to_string()
432                );
433            }
434            if cfg.capped_size_bytes.is_some() {
435                warnings.push(
436                    "Endpoint 'mongodb' is used as a consumer, but 'capped_size_bytes' is a publisher-only option and will be ignored."
437                    .to_string()
438                );
439            }
440            Ok(warnings)
441        }
442        #[cfg(feature = "grpc")]
443        EndpointType::Grpc(_) => Ok(warnings),
444        #[cfg(feature = "http")]
445        EndpointType::Http(cfg) => {
446            if cfg.batch_concurrency.is_some() {
447                warnings.push("Endpoint 'http' is used as a consumer, but 'batch_concurrency' is a publisher-only option and will be ignored.".to_string());
448            }
449            if cfg.tcp_keepalive_ms.is_some() {
450                warnings.push("Endpoint 'http' is used as a consumer, but 'tcp_keepalive_ms' is a publisher-only option and will be ignored.".to_string());
451            }
452            if cfg.pool_idle_timeout_ms.is_some() {
453                warnings.push(
454                        "Endpoint 'http' is used as a consumer, but 'pool_idle_timeout_ms' is a publisher-only option and will be ignored."
455                        .to_string(),
456                    );
457            }
458            if cfg.stream_response_to.is_some() {
459                warnings.push("Endpoint 'http' is used as a consumer, but 'stream_response_to' is a publisher-only option and will be ignored.".to_string());
460            }
461            Ok(warnings)
462        }
463        #[cfg(feature = "clickhouse")]
464        EndpointType::ClickHouse(cfg) => {
465            if cfg.cursor_column.is_none() {
466                return Err(anyhow!(
467                    "ClickHouse endpoint used as a consumer requires 'cursor_column' (ClickHouse has no native queue; only non-destructive cursor reads are supported)."
468                ));
469            }
470            if cfg.columns.is_some() {
471                warnings.push("Endpoint 'clickhouse' is used as a consumer, but 'columns' is a publisher-only option and will be ignored.".to_string());
472            }
473            if cfg.async_insert {
474                warnings.push("Endpoint 'clickhouse' is used as a consumer, but 'async_insert' is a publisher-only option and will be ignored.".to_string());
475            }
476            Ok(warnings)
477        }
478        #[cfg(feature = "sqlx")]
479        EndpointType::Sqlx(cfg) => {
480            if cfg.insert_query.is_some() {
481                warnings.push(
482                    "Endpoint 'sqlx' is used as a consumer, but 'insert_query' is a publisher-only option and will be ignored."
483                    .to_string()
484                );
485            }
486            Ok(warnings)
487        }
488        #[cfg(feature = "postgres-cdc")]
489        EndpointType::PostgresCdc(cfg) => {
490            if cfg.publication.trim().is_empty() {
491                return Err(anyhow!(
492                    "postgres_cdc consumer requires a 'publication' (defines which tables are captured)."
493                ));
494            }
495            Ok(warnings)
496        }
497        #[cfg(feature = "sled")]
498        EndpointType::Sled(_) => Ok(warnings),
499        EndpointType::Static(_) => Ok(warnings),
500        EndpointType::Memory(cfg) => {
501            if cfg.request_reply {
502                warnings.push(
503                    "Endpoint 'memory' is used as a consumer, but 'request_reply' is a publisher-only option and will be ignored."
504                    .to_string()
505                );
506            }
507            if cfg.request_timeout_ms.is_some() {
508                warnings.push(
509                    "Endpoint 'memory' is used as a consumer, but 'request_timeout_ms' is a publisher-only option and will be ignored."
510                    .to_string()
511                );
512            }
513            Ok(warnings)
514        }
515        EndpointType::StreamBuffer(cfg) => {
516            if cfg.correlation_id.is_none() {
517                return Err(anyhow!(
518                    "[route:{}] stream_buffer consumer must specify 'correlation_id'",
519                    route_name
520                ));
521            }
522            Ok(warnings)
523        }
524        EndpointType::File(_) => Ok(warnings),
525        #[cfg(feature = "object-store")]
526        EndpointType::ObjectStore(cfg) => {
527            if cfg.extension.is_some() {
528                warnings.push("Endpoint 'object_store' is used as a consumer, but 'extension' is a publisher-only option and will be ignored.".to_string());
529            }
530            if cfg.date_partition {
531                warnings.push("Endpoint 'object_store' is used as a consumer, but 'date_partition' is a publisher-only option and will be ignored.".to_string());
532            }
533            Ok(warnings)
534        }
535        #[cfg(feature = "websocket")]
536        EndpointType::WebSocket(_) => Ok(warnings),
537        EndpointType::Custom { .. } => Ok(warnings),
538        EndpointType::Switch(_) => Err(anyhow!(
539            "[route:{}] Switch endpoint is only supported as an output",
540            route_name
541        )),
542        EndpointType::Reader(_) => Err(anyhow!(
543            "[route:{}] Reader endpoint is only supported as an output",
544            route_name
545        )),
546        #[allow(unreachable_patterns)]
547        _ => {
548            if let Some(allowed) = allowed_types {
549                let name = endpoint.endpoint_type.name();
550                if allowed.contains(&name) {
551                    return Ok(warnings);
552                }
553            }
554            Err(anyhow!(
555                "[route:{}] Unsupported consumer endpoint type '{:?}'",
556                route_name,
557                endpoint.endpoint_type
558            ))
559        }
560    }
561}
562
563fn resolve_endpoint(endpoint: &Endpoint, route_name: &str) -> Result<Endpoint> {
564    let mut visited = std::collections::HashSet::new();
565    resolve_endpoint_recursive(endpoint, route_name, &mut visited)
566}
567
568fn resolve_endpoint_recursive(
569    endpoint: &Endpoint,
570    route_name: &str,
571    visited: &mut std::collections::HashSet<String>,
572) -> Result<Endpoint> {
573    const MAX_DEPTH: usize = 16;
574    if visited.len() > MAX_DEPTH {
575        return Err(anyhow!(
576            "Reference recursion depth exceeded limit of {}",
577            MAX_DEPTH
578        ));
579    }
580
581    if let EndpointType::Ref(name) = &endpoint.endpoint_type {
582        if !visited.insert(name.clone()) {
583            return Err(anyhow!(
584                "[route:{}] Circular reference detected for endpoint '{}'",
585                route_name,
586                name
587            ));
588        }
589
590        let referenced_endpoint = crate::route::get_endpoint(name).ok_or_else(|| {
591            anyhow!(
592                "[route:{}] Referenced endpoint '{}' not found",
593                route_name,
594                name
595            )
596        })?;
597
598        let mut resolved = resolve_endpoint_recursive(&referenced_endpoint, route_name, visited)?;
599        // Merge middlewares: The ref's middlewares should be outer (applied last in the rev() loop).
600        // Since apply_middlewares_to_consumer iterates in reverse, we prepend the ref's middlewares.
601        let mut new_middlewares = endpoint.middlewares.clone();
602        new_middlewares.extend(resolved.middlewares);
603        resolved.middlewares = new_middlewares;
604        Ok(resolved)
605    } else {
606        Ok(endpoint.clone())
607    }
608}
609
610/// Map a `sqlx` consumer config that requested CDC (via `publication`) onto a
611/// `PostgresCdcConfig`, so a Postgres `sqlx` endpoint can transparently fall back
612/// to logical-replication CDC. Postgres-only; other drivers error.
613#[cfg(all(feature = "sqlx", feature = "postgres-cdc"))]
614fn sqlx_cfg_to_cdc(
615    cfg: &crate::models::SqlxConfig,
616) -> anyhow::Result<crate::models::PostgresCdcConfig> {
617    let url = cfg.url.trim();
618    if !(url.starts_with("postgres://") || url.starts_with("postgresql://")) {
619        return Err(anyhow!(
620            "sqlx `publication` (CDC) is only supported for PostgreSQL URLs; got '{}'. \
621             Use a postgres:// URL or the dedicated `postgres_cdc` endpoint.",
622            cfg.url
623        ));
624    }
625    if cfg.cursor_column.is_some() {
626        return Err(anyhow!(
627            "sqlx endpoint sets both `publication` (CDC) and `cursor_column` (polling); pick one."
628        ));
629    }
630    Ok(crate::models::PostgresCdcConfig {
631        url: cfg.url.clone(),
632        publication: cfg.publication.clone().unwrap_or_default(),
633        slot_name: cfg
634            .slot_name
635            .clone()
636            .unwrap_or_else(|| "mq_bridge_slot".to_string()),
637        create_slot: true,
638        create_publication: cfg.create_publication,
639        publication_tables: if cfg.create_publication {
640            vec![cfg.table.clone()]
641        } else {
642            Vec::new()
643        },
644        temporary_slot: false,
645        cursor_id: cfg.cursor_id.clone(),
646        checkpoint_store: cfg.checkpoint_store.clone(),
647        status_interval_ms: 10_000,
648        tls: cfg.tls.clone(),
649    })
650}
651
652/// Creates a `MessageConsumer` based on the route's "in" configuration.
653pub async fn create_consumer_from_route(
654    route_name: &str,
655    endpoint: &Endpoint,
656) -> Result<Box<dyn MessageConsumer>> {
657    let resolved_endpoint = resolve_endpoint(endpoint, route_name)?;
658    check_consumer(route_name, &resolved_endpoint, None)?;
659    let consumer = create_base_consumer(route_name, &resolved_endpoint).await?;
660    apply_middlewares_to_consumer(consumer, &resolved_endpoint, route_name).await
661}
662
663pub(crate) async fn try_run_fast_path_route(
664    route: &crate::models::Route,
665    name: &str,
666    shutdown_rx: async_channel::Receiver<()>,
667    ready_tx: Option<async_channel::Sender<()>>,
668) -> Option<anyhow::Result<bool>> {
669    #[cfg(feature = "http")]
670    {
671        // The inline fast path applies to outputs that reply synchronously
672        // without the route worker/disposition pipeline: `response` (handler- or
673        // request-derived reply) and `static` (a fixed, pre-rendered reply).
674        let output_is_inline = matches!(
675            route.output.endpoint_type,
676            EndpointType::Response(_) | EndpointType::Static(_)
677        );
678        if let EndpointType::Http(cfg) = &route.input.endpoint_type {
679            if output_is_inline
680                && cfg.inline_response_fast_path_enabled()
681                && route.input.middlewares.is_empty()
682                && output_middlewares_allow_http_inline_fast_path(&route.output.middlewares)
683                && !cfg.fire_and_forget
684            {
685                return Some(
686                    run_http_inline_response_fast_path(
687                        route,
688                        name,
689                        shutdown_rx,
690                        ready_tx,
691                        cfg.clone(),
692                    )
693                    .await,
694                );
695            }
696        }
697    }
698
699    #[cfg(feature = "websocket")]
700    {
701        if let EndpointType::WebSocket(cfg) = &route.input.endpoint_type {
702            match websocket_direct_route_support(route) {
703                WebSocketDirectRouteSupport::Supported => {
704                    return Some(
705                        websocket::run_direct_response_route(
706                            name,
707                            cfg.clone(),
708                            route.output.handler.clone(),
709                            shutdown_rx,
710                            ready_tx,
711                        )
712                        .await,
713                    );
714                }
715                WebSocketDirectRouteSupport::Unsupported(reason) => match cfg.execution_mode {
716                    crate::models::WebSocketExecutionMode::Auto => {
717                        tracing::warn!(
718                            route = name,
719                            reason = reason,
720                            "WebSocket route cannot run in direct mode; falling back to routed mode"
721                        );
722                    }
723                    crate::models::WebSocketExecutionMode::DirectOnly => {
724                        return Some(Err(anyhow!(
725                            "WebSocket route '{}' is configured for direct_only, but direct mode is unsupported: {}",
726                            name,
727                            reason
728                        )));
729                    }
730                    crate::models::WebSocketExecutionMode::Routed => {}
731                },
732            }
733        }
734    }
735
736    let _ = route;
737    let _ = name;
738    let _ = shutdown_rx;
739    let _ = ready_tx;
740    None
741}
742
743#[cfg(feature = "websocket")]
744enum WebSocketDirectRouteSupport {
745    Supported,
746    Unsupported(&'static str),
747}
748
749#[cfg(feature = "websocket")]
750fn websocket_direct_route_support(route: &crate::models::Route) -> WebSocketDirectRouteSupport {
751    let EndpointType::WebSocket(cfg) = &route.input.endpoint_type else {
752        return WebSocketDirectRouteSupport::Unsupported("input is not websocket");
753    };
754
755    if cfg.execution_mode == crate::models::WebSocketExecutionMode::Routed {
756        return WebSocketDirectRouteSupport::Unsupported("execution_mode is routed");
757    }
758    if !matches!(route.output.endpoint_type, EndpointType::Response(_)) {
759        return WebSocketDirectRouteSupport::Unsupported("output is not response");
760    }
761    if !websocket_direct_route_options_allowed(&route.options) {
762        return WebSocketDirectRouteSupport::Unsupported(
763            "custom route options require routed mode",
764        );
765    }
766    if !route.input.middlewares.is_empty() || !route.output.middlewares.is_empty() {
767        return WebSocketDirectRouteSupport::Unsupported("middleware requires routed mode");
768    }
769
770    WebSocketDirectRouteSupport::Supported
771}
772
773#[cfg(feature = "websocket")]
774fn websocket_direct_route_options_allowed(options: &crate::models::RouteOptions) -> bool {
775    let mut defaults = crate::models::RouteOptions::default();
776    defaults.description.clone_from(&options.description);
777    options == &defaults
778}
779
780#[cfg(feature = "http")]
781fn output_middlewares_allow_http_inline_fast_path(middlewares: &[Middleware]) -> bool {
782    middlewares.iter().all(|middleware| {
783        matches!(
784            middleware,
785            Middleware::Buffer(_)
786                | Middleware::Delay(_)
787                | Middleware::Limiter(_)
788                | Middleware::Metrics(_)
789        )
790    })
791}
792
793#[cfg(feature = "http")]
794async fn run_http_inline_response_fast_path(
795    route: &crate::models::Route,
796    name: &str,
797    shutdown_rx: async_channel::Receiver<()>,
798    ready_tx: Option<async_channel::Sender<()>>,
799    http_config: crate::models::HttpConfig,
800) -> anyhow::Result<bool> {
801    let publisher = create_publisher_from_route(name, &route.output).await?;
802    let consumer =
803        http::HttpConsumer::new_with_inline_publisher(&http_config, Some(publisher.clone()))
804            .await?;
805
806    if let Err(err) = crate::route::run_publisher_connect_hook(name, &publisher).await {
807        crate::route::run_publisher_disconnect_hook(name, &publisher).await;
808        return Err(err);
809    }
810    if let Err(err) = crate::route::run_consumer_connect_hook(name, &consumer).await {
811        crate::route::run_consumer_disconnect_hook(name, &consumer).await;
812        crate::route::run_publisher_disconnect_hook(name, &publisher).await;
813        return Err(err);
814    }
815
816    tracing::info!(
817        route = name,
818        has_output_handler = route.output.handler.is_some(),
819        output_middlewares = route.output.middlewares.len(),
820        "Running HTTP inline response fast path; bypassing the normal route consumer/worker/disposition pipeline while keeping the output publisher chain active"
821    );
822    tracing::debug!(
823        route = name,
824        "HTTP inline response fast path differences: no input middlewares, no fire-and-forget, only buffer/metrics output middlewares allowed, and unchanged request metadata is not echoed back as response headers"
825    );
826    if let Some(tx) = ready_tx {
827        let _ = tx.send(()).await;
828    }
829
830    let stopped = shutdown_rx.recv().await.is_ok();
831    if stopped {
832        tracing::info!(
833            "Shutdown signal received in HTTP inline response runner for route '{}'.",
834            name
835        );
836    }
837    crate::route::run_consumer_disconnect_hook(name, &consumer).await;
838    crate::route::run_publisher_disconnect_hook(name, &publisher).await;
839    Ok(true)
840}
841
842async fn create_base_consumer(
843    route_name: &str,
844    endpoint: &Endpoint,
845) -> Result<Box<dyn MessageConsumer>> {
846    // Helper to coerce concrete consumers to the trait object, fixing type inference issues in the match block.
847    fn boxed<T: MessageConsumer + 'static>(c: T) -> Box<dyn MessageConsumer> {
848        Box::new(c)
849    }
850
851    match &endpoint.endpoint_type {
852        #[cfg(feature = "aws")]
853        EndpointType::Aws(cfg) => Ok(boxed(aws::AwsConsumer::new(cfg).await?)),
854        #[cfg(feature = "kafka")]
855        EndpointType::Kafka(cfg) => {
856            let mut config = cfg.clone();
857            if config.topic.is_none() {
858                config.topic = Some(route_name.to_string());
859            }
860            Ok(boxed(kafka::KafkaConsumer::new(&config).await?))
861        }
862        #[cfg(feature = "nats")]
863        EndpointType::Nats(cfg) => {
864            let mut config = cfg.clone();
865            if config.subject.is_none() {
866                config.subject = Some(route_name.to_string());
867            }
868            Ok(boxed(nats::NatsConsumer::new(&config).await?))
869        }
870        #[cfg(feature = "amqp")]
871        EndpointType::Amqp(cfg) => {
872            let mut config = cfg.clone();
873            if config.queue.is_none() {
874                config.queue = Some(route_name.to_string());
875            }
876            Ok(boxed(amqp::AmqpConsumer::new(&config).await?))
877        }
878        #[cfg(feature = "mqtt")]
879        EndpointType::Mqtt(cfg) => {
880            let mut config = cfg.clone();
881            if config.topic.is_none() {
882                config.topic = Some(route_name.to_string());
883            }
884            if config.client_id.is_none() && !config.clean_session {
885                // For persistent sessions, default client_id to route_name if not provided
886                config.client_id = Some(format!("{}-{}", crate::APP_NAME, route_name));
887            }
888            Ok(boxed(mqtt::MqttConsumer::new(&config).await?))
889        }
890        #[cfg(any(feature = "ibm-mq-static", feature = "ibm-mq"))]
891        EndpointType::IbmMq(cfg) => {
892            let mut config = cfg.clone();
893            if config.queue.is_none() && config.topic.is_none() {
894                config.queue = Some(route_name.to_string());
895            }
896            Ok(boxed(ibm_mq::IbmMqConsumer::new(&config).await?))
897        }
898        #[cfg(any(feature = "zeromq", feature = "zeromq-omq"))]
899        EndpointType::ZeroMq(cfg) => zeromq::create_consumer(cfg).await,
900        #[cfg(feature = "redis-streams")]
901        EndpointType::RedisStreams(cfg) => {
902            let mut config = cfg.clone();
903            if config.stream.is_none() {
904                config.stream = Some(route_name.to_string());
905            }
906            Ok(boxed(
907                redis_streams::RedisStreamsConsumer::new(&config).await?,
908            ))
909        }
910        EndpointType::File(cfg) => Ok(boxed(file::FileConsumer::new(cfg).await?)),
911        #[cfg(feature = "object-store")]
912        EndpointType::ObjectStore(cfg) => {
913            Ok(boxed(object_store::ObjectStoreConsumer::new(cfg).await?))
914        }
915        #[cfg(feature = "grpc")]
916        EndpointType::Grpc(cfg) => {
917            let mut config = cfg.clone();
918            if config.topic.is_none() {
919                config.topic = Some(route_name.to_string());
920            }
921            Ok(boxed(grpc::GrpcConsumer::new(&config).await?))
922        }
923        #[cfg(feature = "sqlx")]
924        EndpointType::Sqlx(cfg) => {
925            if cfg.publication.is_some() {
926                // A Postgres publication means CDC (logical replication), not polling —
927                // delegate to the postgres_cdc consumer. See `sqlx_cfg_to_cdc`.
928                #[cfg(feature = "postgres-cdc")]
929                {
930                    Ok(boxed(
931                        postgres::PostgresCdcConsumer::new(&sqlx_cfg_to_cdc(cfg)?).await?,
932                    ))
933                }
934                #[cfg(not(feature = "postgres-cdc"))]
935                {
936                    Err(anyhow!(
937                        "sqlx endpoint with `publication` set uses Postgres CDC, which requires \
938                         the `postgres-cdc` feature to be enabled."
939                    ))
940                }
941            } else if cfg.cursor_column.is_some() {
942                // Non-destructive, resumable cursor read of an arbitrary table.
943                Ok(boxed(sqlx::SqlxCursorReader::new(cfg).await?))
944            } else {
945                Ok(boxed(sqlx::SqlxConsumer::new(cfg).await?))
946            }
947        }
948        #[cfg(feature = "clickhouse")]
949        EndpointType::ClickHouse(cfg) => {
950            if cfg.cursor_column.is_some() {
951                // ClickHouse has no native queue; only non-destructive cursor reads are supported.
952                Ok(boxed(clickhouse::ClickHouseCursorReader::new(cfg).await?))
953            } else {
954                Err(anyhow::anyhow!(
955                    "ClickHouse endpoint used as a consumer requires 'cursor_column' (ClickHouse has no native queue; only non-destructive cursor reads are supported)."
956                ))
957            }
958        }
959        #[cfg(feature = "postgres-cdc")]
960        EndpointType::PostgresCdc(cfg) => Ok(boxed(postgres::PostgresCdcConsumer::new(cfg).await?)),
961        #[cfg(feature = "http")]
962        EndpointType::Http(cfg) => Ok(boxed(http::HttpConsumer::new(cfg).await?)),
963        #[cfg(feature = "websocket")]
964        EndpointType::WebSocket(cfg) => Ok(boxed(websocket::WebSocketConsumer::new(cfg).await?)),
965        EndpointType::Static(cfg) => Ok(boxed(static_endpoint::StaticRequestConsumer::new(cfg)?)),
966        EndpointType::Memory(cfg) => Ok(boxed(memory::MemoryConsumer::new_async(cfg).await?)),
967        EndpointType::StreamBuffer(cfg) => {
968            Ok(boxed(stream_buffer::StreamBufferConsumer::new(cfg)?))
969        }
970        #[cfg(feature = "sled")]
971        EndpointType::Sled(cfg) => Ok(boxed(sled::SledConsumer::new(cfg)?)),
972        #[cfg(feature = "mongodb")]
973        EndpointType::MongoDb(cfg) => {
974            use crate::models::MongoConsume;
975            let mut config = cfg.clone();
976            if config.collection.is_none() {
977                config.collection = Some(route_name.to_string());
978            }
979            match config.resolved_consume() {
980                MongoConsume::Consumer => {
981                    // Durable queue drain (auto-uses a change stream when available, else polls).
982                    Ok(boxed(mongodb::MongoDbConsumer::new(&config).await?))
983                }
984                MongoConsume::Subscriber => {
985                    if config.ttl_seconds.is_none() {
986                        config.ttl_seconds = Some(86400); // Remove events by default after 24 hours
987                    }
988                    Ok(boxed(mongodb::MongoDbSubscriber::new(&config).await?))
989                }
990                MongoConsume::CaptureNew => {
991                    // Watch an existing collection for changes from now on (needs a replica set;
992                    // otherwise the change-stream open returns a clear error).
993                    Ok(boxed(
994                        mongodb::MongoDbChangeStreamReader::new(&config, false).await?,
995                    ))
996                }
997                MongoConsume::CaptureAll => {
998                    // Read existing documents first, then capture changes. Prefer a change stream
999                    // (captures updates/deletes); on a standalone server change streams are
1000                    // unavailable, so fall back to a non-destructive `_id` read (inserts only).
1001                    match mongodb::MongoDbChangeStreamReader::new(&config, true).await {
1002                        Ok(reader) => Ok(boxed(reader)),
1003                        // Only fall back for the "change streams need a replica set" error (40573);
1004                        // auth/network/config and other startup errors propagate untouched.
1005                        Err(e) if mongodb::is_change_stream_unsupported(&e) => {
1006                            tracing::warn!(error = %e, "MongoDB change streams unavailable (needs a replica set); 'capture_all' falling back to an insert-only read");
1007                            Ok(boxed(mongodb::MongoDbIdReader::new(&config).await?))
1008                        }
1009                        Err(e) => Err(e),
1010                    }
1011                }
1012            }
1013        }
1014        EndpointType::Custom { name, config } => {
1015            let factory = get_endpoint_factory(name)
1016                .ok_or_else(|| anyhow!("Custom endpoint factory '{}' not found", name))?;
1017            factory.create_consumer(route_name, config).await
1018        }
1019        EndpointType::Switch(_) => Err(anyhow!(
1020            "[route:{}] Switch endpoint is only supported as an output",
1021            route_name
1022        )),
1023        #[allow(unreachable_patterns)]
1024        _ => Err(anyhow!(
1025            "[route:{}] Unsupported consumer endpoint type '{:?}'",
1026            route_name,
1027            endpoint.endpoint_type
1028        )),
1029    }
1030}
1031
1032/// Validates the publisher configuration for a route.
1033pub fn check_publisher(
1034    route_name: &str,
1035    endpoint: &Endpoint,
1036    allowed_types: Option<&[&str]>,
1037) -> Result<Vec<String>> {
1038    check_publisher_recursive(route_name, endpoint, 0, allowed_types)
1039}
1040
1041fn check_publisher_recursive(
1042    route_name: &str,
1043    endpoint: &Endpoint,
1044    depth: usize,
1045    allowed_types: Option<&[&str]>,
1046) -> Result<Vec<String>> {
1047    let mut warnings = Vec::new();
1048    if let Some(allowed) = allowed_types {
1049        if !endpoint.endpoint_type.is_core() {
1050            let name = endpoint.endpoint_type.name();
1051            if !allowed.contains(&name) {
1052                return Err(anyhow!(
1053                    "[route:{}] Endpoint type '{}' is not allowed by policy",
1054                    route_name,
1055                    name
1056                ));
1057            }
1058        }
1059    }
1060    const MAX_DEPTH: usize = 16;
1061    if depth > MAX_DEPTH {
1062        return Err(anyhow!(
1063            "Fanout recursion depth exceeded limit of {}",
1064            MAX_DEPTH
1065        ));
1066    }
1067    match &endpoint.endpoint_type {
1068        EndpointType::Ref(name) => {
1069            let referenced = crate::route::get_endpoint(name).ok_or_else(|| {
1070                anyhow!(
1071                    "[route:{}] Referenced endpoint '{}' not found in endpoint registry",
1072                    route_name,
1073                    name
1074                )
1075            });
1076            if let Ok(referenced) = referenced {
1077                warnings.extend(check_publisher_recursive(
1078                    route_name,
1079                    &referenced,
1080                    depth + 1,
1081                    allowed_types,
1082                )?);
1083                return Ok(warnings);
1084            }
1085            if crate::publisher::get_publisher(name).is_some() {
1086                return Ok(warnings);
1087            }
1088            Err(anyhow!(
1089                "[route:{}] Referenced endpoint '{}' not found in any registry",
1090                route_name,
1091                name
1092            ))
1093        }
1094        #[cfg(feature = "aws")]
1095        EndpointType::Aws(cfg) => {
1096            if cfg.max_messages.is_some() {
1097                warnings.push(
1098                    "Endpoint 'aws' is used as a publisher, but 'max_messages' is a consumer-only option and will be ignored."
1099                    .to_string()
1100                );
1101            }
1102            if cfg.wait_time_seconds.is_some() {
1103                warnings.push(
1104                    "Endpoint 'aws' is used as a publisher, but 'wait_time_seconds' is a consumer-only option and will be ignored."
1105                    .to_string()
1106                );
1107            }
1108            Ok(warnings)
1109        }
1110        #[cfg(feature = "kafka")]
1111        EndpointType::Kafka(cfg) => {
1112            if cfg.group_id.is_some() {
1113                warnings.push(
1114                    "Endpoint 'kafka' is used as a publisher, but 'group_id' is a consumer-only option and will be ignored."
1115                    .to_string()
1116                );
1117            }
1118            if cfg.consumer_options.is_some() {
1119                warnings.push(
1120                    "Endpoint 'kafka' is used as a publisher, but 'consumer_options' is a consumer-only option and will be ignored."
1121                    .to_string()
1122                );
1123            }
1124            Ok(warnings)
1125        }
1126        #[cfg(feature = "nats")]
1127        EndpointType::Nats(cfg) => {
1128            if cfg.stream.is_some() {
1129                warnings.push(
1130                    "Endpoint 'nats' is used as a publisher, but 'stream' is a consumer-only option and will be ignored."
1131                    .to_string()
1132                );
1133            }
1134            if cfg.subscriber_mode {
1135                warnings.push(
1136                    "Endpoint 'nats' is used as a publisher, but 'subscriber_mode' is a consumer-only option and will be ignored."
1137                    .to_string()
1138                );
1139            }
1140            if cfg.prefetch_count.is_some() {
1141                warnings.push(
1142                    "Endpoint 'nats' is used as a publisher, but 'prefetch_count' is a consumer-only option and will be ignored."
1143                    .to_string()
1144                );
1145            }
1146            Ok(warnings)
1147        }
1148        #[cfg(feature = "amqp")]
1149        EndpointType::Amqp(cfg) => {
1150            if cfg.subscribe_mode {
1151                warnings.push(
1152                    "Endpoint 'amqp' is used as a publisher, but 'subscribe_mode' is a consumer-only option and will be ignored."
1153                    .to_string()
1154                );
1155            }
1156            if cfg.prefetch_count.is_some() {
1157                warnings.push(
1158                    "Endpoint 'amqp' is used as a publisher, but 'prefetch_count' is a consumer-only option and will be ignored."
1159                    .to_string()
1160                );
1161            }
1162            Ok(warnings)
1163        }
1164        #[cfg(feature = "mqtt")]
1165        EndpointType::Mqtt(cfg) => {
1166            if cfg.clean_session {
1167                warnings.push(
1168                    "Endpoint 'mqtt' is used as a publisher, but 'clean_session' is a consumer-only option and will be ignored."
1169                    .to_string()
1170                );
1171            }
1172            Ok(warnings)
1173        }
1174        #[cfg(feature = "zeromq")]
1175        EndpointType::ZeroMq(cfg) => {
1176            if cfg.topic.is_some() {
1177                warnings.push(
1178                    "Endpoint 'zeromq' is used as a publisher, but 'topic' is a consumer-only option and will be ignored."
1179                    .to_string()
1180                );
1181            }
1182            Ok(warnings)
1183        }
1184
1185        #[cfg(feature = "http")]
1186        EndpointType::Http(_cfg) => {
1187            if _cfg.path.is_some() {
1188                warnings.push(
1189                    "Endpoint 'http' is used as a publisher, but 'path' is a consumer-only option and will be ignored."
1190                    .to_string()
1191                );
1192            }
1193            if _cfg.workers.is_some() {
1194                warnings.push(
1195                    "Endpoint 'http' is used as a publisher, but 'workers' is a consumer-only option and will be ignored."
1196                    .to_string()
1197                );
1198            }
1199            if _cfg.message_id_header.is_some() {
1200                warnings.push(
1201                    "Endpoint 'http' is used as a publisher, but 'message_id_header' is a consumer-only option and will be ignored."
1202                    .to_string()
1203                );
1204            }
1205            if _cfg.internal_buffer_size.is_some() {
1206                warnings.push(
1207                    "Endpoint 'http' is used as a publisher, but 'internal_buffer_size' is a consumer-only option and will be ignored."
1208                    .to_string()
1209                );
1210            }
1211            if _cfg.fire_and_forget {
1212                warnings.push(
1213                    "Endpoint 'http' is used as a publisher, but 'fire_and_forget' is a consumer-only option and will be ignored."
1214                    .to_string()
1215                );
1216            }
1217            if _cfg.receive_streamable {
1218                warnings.push(
1219                    "Endpoint 'http' is used as a publisher, but 'receive_streamable' is a consumer-only option and will be ignored."
1220                    .to_string()
1221                );
1222            }
1223            Ok(warnings)
1224        }
1225        #[cfg(feature = "redis-streams")]
1226        EndpointType::RedisStreams(cfg) => {
1227            if cfg.group.is_some() {
1228                warnings.push(
1229                    "Endpoint 'redis_streams' is used as a publisher, but 'group' is a consumer-only option and will be ignored."
1230                    .to_string()
1231                );
1232            }
1233            if cfg.consumer_name.is_some() {
1234                warnings.push(
1235                    "Endpoint 'redis_streams' is used as a publisher, but 'consumer_name' is a consumer-only option and will be ignored."
1236                    .to_string()
1237                );
1238            }
1239            if cfg.subscriber_mode {
1240                warnings.push(
1241                    "Endpoint 'redis_streams' is used as a publisher, but 'subscriber_mode' is a consumer-only option and will be ignored."
1242                    .to_string()
1243                );
1244            }
1245            if cfg.block_ms.is_some() {
1246                warnings.push(
1247                    "Endpoint 'redis_streams' is used as a publisher, but 'block_ms' is a consumer-only option and will be ignored."
1248                    .to_string()
1249                );
1250            }
1251            if cfg.read_from_start {
1252                warnings.push(
1253                    "Endpoint 'redis_streams' is used as a publisher, but 'read_from_start' is a consumer-only option and will be ignored."
1254                    .to_string()
1255                );
1256            }
1257            if cfg.redelivery_timeout_ms.is_some() {
1258                warnings.push(
1259                    "Endpoint 'redis_streams' is used as a publisher, but 'redelivery_timeout_ms' is a consumer-only option and will be ignored."
1260                    .to_string()
1261                );
1262            }
1263            if cfg.internal_buffer_size.is_some() {
1264                warnings.push(
1265                    "Endpoint 'redis_streams' is used as a publisher, but 'internal_buffer_size' is a consumer-only option and will be ignored."
1266                    .to_string()
1267                );
1268            }
1269            Ok(warnings)
1270        }
1271        #[cfg(feature = "grpc")]
1272        EndpointType::Grpc(_) => Ok(warnings),
1273        #[cfg(feature = "sqlx")]
1274        EndpointType::Sqlx(cfg) => {
1275            if cfg.select_query.is_some() {
1276                warnings.push(
1277                    "Endpoint 'sqlx' is used as a publisher, but 'select_query' is a consumer-only option and will be ignored."
1278                    .to_string()
1279                );
1280            }
1281            if cfg.delete_after_read {
1282                warnings.push(
1283                    "Endpoint 'sqlx' is used as a publisher, but 'delete_after_read' is a consumer-only option and will be ignored."
1284                    .to_string()
1285                );
1286            }
1287            if cfg.polling_interval_ms.is_some() {
1288                warnings.push(
1289                    "Endpoint 'sqlx' is used as a publisher, but 'polling_interval_ms' is a consumer-only option and will be ignored."
1290                    .to_string()
1291                );
1292            }
1293            Ok(warnings)
1294        }
1295        #[cfg(feature = "clickhouse")]
1296        EndpointType::ClickHouse(cfg) => {
1297            if cfg.cursor_column.is_some() {
1298                warnings.push("Endpoint 'clickhouse' is used as a publisher, but 'cursor_column' is a consumer-only option and will be ignored.".to_string());
1299            }
1300            if cfg.checkpoint_store.is_some() {
1301                warnings.push("Endpoint 'clickhouse' is used as a publisher, but 'checkpoint_store' is a consumer-only option and will be ignored.".to_string());
1302            }
1303            if cfg.polling_interval_ms.is_some() {
1304                warnings.push("Endpoint 'clickhouse' is used as a publisher, but 'polling_interval_ms' is a consumer-only option and will be ignored.".to_string());
1305            }
1306            Ok(warnings)
1307        }
1308        #[cfg(any(feature = "ibm-mq-static", feature = "ibm-mq"))]
1309        EndpointType::IbmMq(cfg) => {
1310            if cfg.wait_timeout_ms != 1000 {
1311                warnings.push(
1312                    "Endpoint 'ibmmq' is used as a publisher, but 'wait_timeout_ms' is a consumer-only option and will be ignored."
1313                    .to_string()
1314                );
1315            }
1316            Ok(warnings)
1317        }
1318        #[cfg(feature = "mongodb")]
1319        EndpointType::MongoDb(cfg) => {
1320            if cfg.polling_interval_ms.is_some() {
1321                warnings.push(
1322                    "Endpoint 'mongodb' is used as a publisher, but 'polling_interval_ms' is a consumer-only option and will be ignored."
1323                    .to_string()
1324                );
1325            }
1326            if cfg.change_stream {
1327                warnings.push(
1328                    "Endpoint 'mongodb' is used as a publisher, but 'change_stream' is a consumer-only option and will be ignored."
1329                    .to_string()
1330                );
1331            }
1332            if cfg.cursor_id.is_some() {
1333                warnings.push(
1334                    "Endpoint 'mongodb' is used as a publisher, but 'cursor_id' is a consumer-only option and will be ignored."
1335                    .to_string()
1336                );
1337            }
1338            Ok(warnings)
1339        }
1340        EndpointType::File(_) => Ok(warnings),
1341        #[cfg(feature = "object-store")]
1342        EndpointType::ObjectStore(cfg) => {
1343            if cfg.checkpoint_store.is_some() {
1344                warnings.push("Endpoint 'object_store' is used as a publisher, but 'checkpoint_store' is a consumer-only option and will be ignored.".to_string());
1345            }
1346            if cfg.cursor_id.is_some() {
1347                warnings.push("Endpoint 'object_store' is used as a publisher, but 'cursor_id' is a consumer-only option and will be ignored.".to_string());
1348            }
1349            if cfg.polling_interval_ms.is_some() {
1350                warnings.push("Endpoint 'object_store' is used as a publisher, but 'polling_interval_ms' is a consumer-only option and will be ignored.".to_string());
1351            }
1352            if cfg.max_object_bytes.is_some() {
1353                warnings.push("Endpoint 'object_store' is used as a publisher, but 'max_object_bytes' is a consumer-only option and will be ignored.".to_string());
1354            }
1355            Ok(warnings)
1356        }
1357        #[cfg(feature = "websocket")]
1358        EndpointType::WebSocket(_) => Ok(warnings),
1359        EndpointType::Static(_) => Ok(warnings),
1360        EndpointType::Memory(cfg) => {
1361            if cfg.subscribe_mode {
1362                warnings.push(
1363                    "Endpoint 'memory' is used as a publisher, but 'subscribe_mode' is a consumer-only option and will be ignored."
1364                    .to_string()
1365                );
1366            }
1367            if cfg.enable_nack {
1368                warnings.push(
1369                    "Endpoint 'memory' is used as a publisher, but 'enable_nack' is a consumer-only option and will be ignored."
1370                    .to_string()
1371                );
1372            }
1373            Ok(warnings)
1374        }
1375        EndpointType::StreamBuffer(cfg) => {
1376            if cfg.correlation_id.is_some() {
1377                warnings.push(
1378                    "Endpoint 'stream_buffer' is used as a publisher, but 'correlation_id' is a consumer-only option and will be ignored."
1379                    .to_string()
1380                );
1381            }
1382            Ok(warnings)
1383        }
1384        #[cfg(feature = "sled")]
1385        EndpointType::Sled(cfg) => {
1386            if cfg.read_from_start {
1387                warnings.push(
1388                    "Endpoint 'sled' is used as a publisher, but 'read_from_start' is a consumer-only option and will be ignored."
1389                    .to_string()
1390                );
1391            }
1392            if cfg.delete_after_read {
1393                warnings.push(
1394                    "Endpoint 'sled' is used as a publisher, but 'delete_after_read' is a consumer-only option and will be ignored."
1395                    .to_string()
1396                );
1397            }
1398            Ok(warnings)
1399        }
1400        EndpointType::Null => Ok(warnings),
1401        EndpointType::Fanout(endpoints) => {
1402            for endpoint in endpoints {
1403                warnings.extend(check_publisher_recursive(
1404                    route_name,
1405                    endpoint,
1406                    depth + 1,
1407                    allowed_types,
1408                )?);
1409            }
1410            Ok(warnings)
1411        }
1412        EndpointType::Switch(cfg) => {
1413            for endpoint in cfg.cases.values() {
1414                warnings.extend(check_publisher_recursive(
1415                    route_name,
1416                    endpoint,
1417                    depth + 1,
1418                    allowed_types,
1419                )?);
1420            }
1421            if let Some(endpoint) = &cfg.default {
1422                warnings.extend(check_publisher_recursive(
1423                    route_name,
1424                    endpoint,
1425                    depth + 1,
1426                    allowed_types,
1427                )?);
1428            }
1429            Ok(warnings)
1430        }
1431        EndpointType::Response(_) => Ok(warnings),
1432        EndpointType::Custom { .. } => Ok(warnings),
1433        EndpointType::Reader(inner) => check_consumer(route_name, inner, allowed_types),
1434        EndpointType::Request(cfg) => {
1435            warnings.extend(check_publisher_recursive(
1436                route_name,
1437                &cfg.to,
1438                depth + 1,
1439                allowed_types,
1440            )?);
1441            warnings.extend(check_publisher_recursive(
1442                route_name,
1443                &cfg.forward_to,
1444                depth + 1,
1445                allowed_types,
1446            )?);
1447            Ok(warnings)
1448        }
1449        #[allow(unreachable_patterns)]
1450        _ => {
1451            if let Some(allowed) = allowed_types {
1452                let name = endpoint.endpoint_type.name();
1453                if allowed.contains(&name) {
1454                    return Ok(warnings);
1455                }
1456            }
1457            Err(anyhow!(
1458                "[route:{}] Unsupported publisher endpoint type '{:?}'",
1459                route_name,
1460                endpoint.endpoint_type
1461            ))
1462        }
1463    }
1464}
1465
1466/// Creates a `MessagePublisher` based on the route's "out" configuration.
1467pub async fn create_publisher_from_route(
1468    route_name: &str,
1469    endpoint: &Endpoint,
1470) -> Result<Arc<dyn MessagePublisher>> {
1471    check_publisher(route_name, endpoint, None)?;
1472    create_publisher_with_depth(route_name.to_string(), endpoint.clone(), 0).await
1473}
1474
1475fn create_publisher_with_depth(
1476    route_name: String,
1477    endpoint: Endpoint,
1478    depth: usize,
1479) -> BoxFuture<'static, Result<Arc<dyn MessagePublisher>>> {
1480    Box::pin(async move {
1481        const MAX_DEPTH: usize = 16;
1482        if depth > MAX_DEPTH {
1483            return Err(anyhow!(
1484                "Fanout/Ref recursion depth exceeded limit of {}",
1485                MAX_DEPTH
1486            ));
1487        }
1488
1489        if let EndpointType::Ref(name) = &endpoint.endpoint_type {
1490            let referenced_opt = crate::route::get_endpoint(name);
1491
1492            if referenced_opt.is_none() {
1493                if let Some(pub_instance) = crate::publisher::get_publisher(name) {
1494                    let inner = pub_instance.inner();
1495                    let mut publisher: Box<dyn MessagePublisher> = Box::new(inner);
1496
1497                    if let Some(handler) = &endpoint.handler {
1498                        publisher = Box::new(crate::command_handler::CommandPublisher::new(
1499                            publisher,
1500                            handler.clone(),
1501                        ));
1502                    }
1503                    return crate::middleware::apply_middlewares_to_publisher(
1504                        publisher,
1505                        &endpoint,
1506                        &route_name,
1507                    )
1508                    .await;
1509                }
1510            }
1511
1512            let referenced = referenced_opt.ok_or_else(|| {
1513                anyhow!(
1514                    "[route:{}] Referenced endpoint '{}' not found",
1515                    route_name,
1516                    name
1517                )
1518            })?;
1519
1520            let mut merged = referenced;
1521            // Merge middlewares: The ref's middlewares should be outer (applied last).
1522            // Since apply_middlewares_to_publisher iterates forward, we append the ref's middlewares to the referenced ones.
1523            merged.middlewares.extend(endpoint.middlewares);
1524
1525            if endpoint.handler.is_some() {
1526                if merged.handler.is_some() {
1527                    return Err(anyhow!("[route:{}] Both ref endpoint and referenced endpoint '{}' have handlers defined. This is ambiguous.", route_name, name));
1528                }
1529                merged.handler = endpoint.handler;
1530            }
1531
1532            return create_publisher_with_depth(route_name, merged, depth + 1).await;
1533        }
1534
1535        let mut publisher =
1536            create_base_publisher(&route_name, &endpoint.endpoint_type, depth).await?;
1537        if let Some(handler) = &endpoint.handler {
1538            publisher = Box::new(crate::command_handler::CommandPublisher::new(
1539                publisher,
1540                handler.clone(),
1541            ));
1542        }
1543        crate::middleware::apply_middlewares_to_publisher(publisher, &endpoint, &route_name).await
1544    })
1545}
1546
1547async fn create_base_publisher(
1548    route_name: &str,
1549    endpoint_type: &EndpointType,
1550    depth: usize,
1551) -> Result<Box<dyn MessagePublisher>> {
1552    let publisher = match endpoint_type {
1553        #[cfg(feature = "aws")]
1554        EndpointType::Aws(cfg) => {
1555            Ok(Box::new(aws::AwsPublisher::new(cfg).await?) as Box<dyn MessagePublisher>)
1556        }
1557        #[cfg(feature = "kafka")]
1558        EndpointType::Kafka(cfg) => {
1559            let mut config = cfg.clone();
1560            if config.topic.is_none() {
1561                config.topic = Some(route_name.to_string());
1562            }
1563            Ok(Box::new(kafka::KafkaPublisher::new(&config).await?) as Box<dyn MessagePublisher>)
1564        }
1565        #[cfg(feature = "nats")]
1566        EndpointType::Nats(cfg) => {
1567            let mut config = cfg.clone();
1568            if config.subject.is_none() {
1569                config.subject = Some(route_name.to_string());
1570            }
1571            Ok(Box::new(nats::NatsPublisher::new(&config).await?) as Box<dyn MessagePublisher>)
1572        }
1573        #[cfg(feature = "amqp")]
1574        EndpointType::Amqp(cfg) => {
1575            let mut config = cfg.clone();
1576            if config.queue.is_none() {
1577                config.queue = Some(route_name.to_string());
1578            }
1579            Ok(Box::new(amqp::AmqpPublisher::new(&config).await?) as Box<dyn MessagePublisher>)
1580        }
1581        #[cfg(feature = "mqtt")]
1582        EndpointType::Mqtt(cfg) => {
1583            let mut config = cfg.clone();
1584            if config.topic.is_none() {
1585                config.topic = Some(route_name.to_string());
1586            }
1587            if config.client_id.is_none() {
1588                config.client_id = Some(format!("{}-{}", crate::APP_NAME, route_name));
1589            }
1590            Ok(Box::new(mqtt::MqttPublisher::new(&config).await?) as Box<dyn MessagePublisher>)
1591        }
1592        #[cfg(any(feature = "zeromq", feature = "zeromq-omq"))]
1593        EndpointType::ZeroMq(cfg) => zeromq::create_publisher(cfg).await,
1594        #[cfg(feature = "redis-streams")]
1595        EndpointType::RedisStreams(cfg) => {
1596            let mut config = cfg.clone();
1597            if config.stream.is_none() {
1598                config.stream = Some(route_name.to_string());
1599            }
1600            Ok(
1601                Box::new(redis_streams::RedisStreamsPublisher::new(&config).await?)
1602                    as Box<dyn MessagePublisher>,
1603            )
1604        }
1605        #[cfg(feature = "grpc")]
1606        EndpointType::Grpc(cfg) => {
1607            Ok(Box::new(grpc::GrpcPublisher::new(cfg).await?) as Box<dyn MessagePublisher>)
1608        }
1609        #[cfg(feature = "sqlx")]
1610        EndpointType::Sqlx(cfg) => {
1611            Ok(Box::new(sqlx::SqlxPublisher::new(cfg).await?) as Box<dyn MessagePublisher>)
1612        }
1613        #[cfg(feature = "clickhouse")]
1614        EndpointType::ClickHouse(cfg) => {
1615            Ok(Box::new(clickhouse::ClickHousePublisher::new(cfg).await?)
1616                as Box<dyn MessagePublisher>)
1617        }
1618        #[cfg(feature = "http")]
1619        EndpointType::Http(cfg) => {
1620            let stream_response_sink =
1621                if let Some(stream_response_to) = cfg.stream_response_to.as_deref() {
1622                    Some(
1623                        create_publisher_with_depth(
1624                            route_name.to_string(),
1625                            stream_response_to.clone(),
1626                            depth + 1,
1627                        )
1628                        .await?,
1629                    )
1630                } else {
1631                    None
1632                };
1633            let sink =
1634                http::HttpPublisher::new_with_stream_response_sink(cfg, stream_response_sink)
1635                    .await?;
1636            Ok(Box::new(sink) as Box<dyn MessagePublisher>)
1637        }
1638        #[cfg(feature = "websocket")]
1639        EndpointType::WebSocket(cfg) => {
1640            let sink = websocket::WebSocketPublisher::new(cfg);
1641            Ok(Box::new(sink) as Box<dyn MessagePublisher>)
1642        }
1643        #[cfg(feature = "mongodb")]
1644        EndpointType::MongoDb(cfg) => {
1645            let mut config = cfg.clone();
1646            if config.collection.is_none() {
1647                config.collection = Some(route_name.to_string());
1648            }
1649            Ok(Box::new(mongodb::MongoDbPublisher::new(&config).await?)
1650                as Box<dyn MessagePublisher>)
1651        }
1652        EndpointType::File(cfg) => {
1653            Ok(Box::new(file::FilePublisher::new(cfg).await?) as Box<dyn MessagePublisher>)
1654        }
1655        #[cfg(feature = "object-store")]
1656        EndpointType::ObjectStore(cfg) => Ok(Box::new(
1657            object_store::ObjectStorePublisher::new(cfg).await?,
1658        ) as Box<dyn MessagePublisher>),
1659        EndpointType::Static(cfg) => Ok(Box::new(static_endpoint::StaticEndpointPublisher::new(
1660            cfg,
1661        )?) as Box<dyn MessagePublisher>),
1662        EndpointType::Memory(cfg) => {
1663            Ok(Box::new(memory::MemoryPublisher::new_async(cfg).await?)
1664                as Box<dyn MessagePublisher>)
1665        }
1666        EndpointType::StreamBuffer(cfg) => {
1667            Ok(Box::new(stream_buffer::StreamBufferPublisher::new(cfg)?)
1668                as Box<dyn MessagePublisher>)
1669        }
1670        #[cfg(feature = "sled")]
1671        EndpointType::Sled(cfg) => {
1672            Ok(Box::new(sled::SledPublisher::new(cfg)?) as Box<dyn MessagePublisher>)
1673        }
1674        #[cfg(any(feature = "ibm-mq-static", feature = "ibm-mq"))]
1675        EndpointType::IbmMq(cfg) => {
1676            Ok(Box::new(ibm_mq::IbmMqPublisher::new(cfg).await?) as Box<dyn MessagePublisher>)
1677        }
1678        EndpointType::Null => Ok(Box::new(null::NullPublisher) as Box<dyn MessagePublisher>),
1679        EndpointType::Fanout(endpoints) => {
1680            let mut publishers = Vec::with_capacity(endpoints.len());
1681            for endpoint in endpoints {
1682                let p = create_publisher_with_depth(
1683                    route_name.to_string(),
1684                    endpoint.clone(),
1685                    depth + 1,
1686                )
1687                .await?;
1688                publishers.push(p);
1689            }
1690            Ok(Box::new(fanout::FanoutPublisher::new(publishers)) as Box<dyn MessagePublisher>)
1691        }
1692        EndpointType::Switch(cfg) => {
1693            let mut cases = std::collections::HashMap::new();
1694            for (key, endpoint) in &cfg.cases {
1695                let p = create_publisher_with_depth(
1696                    route_name.to_string(),
1697                    endpoint.clone(),
1698                    depth + 1,
1699                )
1700                .await?;
1701                cases.insert(key.clone(), p);
1702            }
1703            let default = if let Some(endpoint) = &cfg.default {
1704                Some(
1705                    create_publisher_with_depth(
1706                        route_name.to_string(),
1707                        (**endpoint).clone(),
1708                        depth + 1,
1709                    )
1710                    .await?,
1711                )
1712            } else {
1713                None
1714            };
1715            Ok(Box::new(switch::SwitchPublisher::new(
1716                cfg.metadata_key.clone(),
1717                cases,
1718                default,
1719            )) as Box<dyn MessagePublisher>)
1720        }
1721        EndpointType::Response(_) => {
1722            Ok(Box::new(response::ResponsePublisher) as Box<dyn MessagePublisher>)
1723        }
1724        EndpointType::Reader(inner) => {
1725            let consumer = create_consumer_from_route(route_name, inner).await?;
1726            Ok(Box::new(reader::ReaderPublisher::new(consumer)) as Box<dyn MessagePublisher>)
1727        }
1728        EndpointType::Request(cfg) => {
1729            let request =
1730                create_publisher_with_depth(route_name.to_string(), (*cfg.to).clone(), depth + 1)
1731                    .await?;
1732            let forward = create_publisher_with_depth(
1733                route_name.to_string(),
1734                (*cfg.forward_to).clone(),
1735                depth + 1,
1736            )
1737            .await?;
1738            Ok(
1739                Box::new(request::RequestForwardPublisher::new(request, forward))
1740                    as Box<dyn MessagePublisher>,
1741            )
1742        }
1743        EndpointType::Custom { name, config } => {
1744            let factory = get_endpoint_factory(name)
1745                .ok_or_else(|| anyhow!("Custom endpoint factory '{}' not found", name))?;
1746            factory.create_publisher(route_name, config).await
1747        }
1748        #[allow(unreachable_patterns)]
1749        _ => Err(anyhow!(
1750            "[route:{}] Unsupported publisher endpoint type '{:?}'",
1751            route_name,
1752            endpoint_type
1753        )),
1754    }?;
1755    Ok(publisher)
1756}
1757
1758/// Returns the active process-level rustls `CryptoProvider`, or a descriptive error if none
1759/// has been installed yet.
1760///
1761/// This is called by every endpoint that creates a rustls `ClientConfig` / `ServerConfig`.
1762/// As a library, mq-bridge never installs a provider itself; the choice belongs to the
1763/// application binary.  To resolve the error, either:
1764///
1765/// * Enable the **`rustls-ring`** or **`rustls-aws-lc`** feature of `mq-bridge`, or
1766/// * Call `rustls::crypto::CryptoProvider::install_default()` early in your `main()`.
1767#[cfg(feature = "rustls")]
1768#[allow(unused)]
1769pub(crate) fn get_crypto_provider() -> anyhow::Result<std::sync::Arc<rustls::crypto::CryptoProvider>>
1770{
1771    rustls::crypto::CryptoProvider::get_default()
1772        .cloned()
1773        .ok_or_else(|| {
1774            anyhow!("No rustls CryptoProvider is installed.\n\
1775Fix: enable the `rustls-ring` or `rustls-aws-lc` feature of mq-bridge, or call `rustls::crypto::CryptoProvider::install_default()` in your application binary before creating any TLS endpoint.")
1776        })
1777}
1778
1779#[cfg(test)]
1780mod tests {
1781    use super::*;
1782    use crate::models::{Endpoint, EndpointType};
1783    use crate::CanonicalMessage;
1784
1785    #[tokio::test]
1786    async fn test_fanout_publisher_integration() {
1787        let ep1 = Endpoint::new_memory("fanout_1", 10);
1788        let ep2 = Endpoint::new_memory("fanout_2", 10);
1789
1790        let chan1 = ep1.channel().unwrap();
1791        let chan2 = ep2.channel().unwrap();
1792        let fanout_ep = Endpoint::new(EndpointType::Fanout(vec![ep1, ep2]));
1793
1794        let publisher = create_publisher_from_route("test_fanout", &fanout_ep)
1795            .await
1796            .expect("Failed to create fanout publisher");
1797
1798        let msg = CanonicalMessage::new(b"fanout_payload".to_vec(), None);
1799        publisher.send(msg).await.expect("Failed to send message");
1800
1801        assert_eq!(chan1.len(), 1);
1802        assert_eq!(chan2.len(), 1);
1803
1804        let msg1 = chan1.drain_messages().pop().unwrap();
1805        let msg2 = chan2.drain_messages().pop().unwrap();
1806
1807        assert_eq!(msg1.payload, "fanout_payload".as_bytes());
1808        assert_eq!(msg2.payload, "fanout_payload".as_bytes());
1809    }
1810
1811    use crate::models::MemoryConfig;
1812    #[tokio::test]
1813    async fn test_factory_creates_memory_subscriber() {
1814        let endpoint = Endpoint {
1815            endpoint_type: EndpointType::Memory(
1816                MemoryConfig::new("mem".to_string(), None).with_subscribe(true),
1817            ),
1818            middlewares: vec![],
1819            handler: None,
1820        };
1821
1822        let consumer = create_consumer_from_route("test", &endpoint).await.unwrap();
1823        // Check if it is a MemoryConsumer (MemorySubscriber was merged)
1824        let is_subscriber = consumer
1825            .as_any()
1826            .is::<crate::endpoints::memory::MemoryConsumer>();
1827        assert!(is_subscriber, "Factory should create MemoryConsumer");
1828    }
1829
1830    #[cfg(feature = "websocket")]
1831    #[test]
1832    fn websocket_direct_route_support_requires_default_route_options() {
1833        let mut options = crate::models::RouteOptions::default();
1834        assert!(websocket_direct_route_options_allowed(&options));
1835
1836        options.batch_size = 128;
1837        assert!(!websocket_direct_route_options_allowed(&options));
1838    }
1839
1840    #[cfg(feature = "websocket")]
1841    #[test]
1842    fn websocket_direct_route_support_respects_execution_mode_and_output() {
1843        let input = Endpoint::new(EndpointType::WebSocket(
1844            crate::models::WebSocketConfig::new("127.0.0.1:0"),
1845        ));
1846        let response_route = crate::models::Route::new(input.clone(), Endpoint::new_response());
1847        assert!(matches!(
1848            websocket_direct_route_support(&response_route),
1849            WebSocketDirectRouteSupport::Supported
1850        ));
1851
1852        let memory_route = crate::models::Route::new(input.clone(), Endpoint::new_memory("ws", 1));
1853        assert!(matches!(
1854            websocket_direct_route_support(&memory_route),
1855            WebSocketDirectRouteSupport::Unsupported("output is not response")
1856        ));
1857
1858        let routed_input = Endpoint::new(EndpointType::WebSocket(
1859            crate::models::WebSocketConfig::new("127.0.0.1:0")
1860                .with_execution_mode(crate::models::WebSocketExecutionMode::Routed),
1861        ));
1862        let routed_route = crate::models::Route::new(routed_input, Endpoint::new_response());
1863        assert!(matches!(
1864            websocket_direct_route_support(&routed_route),
1865            WebSocketDirectRouteSupport::Unsupported("execution_mode is routed")
1866        ));
1867    }
1868
1869    #[test]
1870    fn test_endpoint_middleware_ordering_helpers() {
1871        let endpoint = Endpoint::new_memory("test", 10)
1872            .with_metrics()
1873            .with_dlq(crate::models::DeadLetterQueueMiddleware::default())
1874            .with_retry(crate::models::RetryMiddleware::default());
1875
1876        // Expected order: Retry, Dlq, Metrics
1877        assert_eq!(endpoint.middlewares.len(), 3);
1878        assert!(matches!(endpoint.middlewares[0], Middleware::Retry(_)));
1879        assert!(matches!(endpoint.middlewares[1], Middleware::Dlq(_)));
1880        assert!(matches!(endpoint.middlewares[2], Middleware::Metrics(_)));
1881    }
1882
1883    #[cfg(feature = "http")]
1884    #[test]
1885    fn test_http_inline_fast_path_allows_simple_output_publisher_middlewares() {
1886        assert!(output_middlewares_allow_http_inline_fast_path(&[
1887            Middleware::Buffer(crate::models::BufferMiddleware {
1888                max_messages: 16,
1889                max_delay_ms: 0,
1890            }),
1891            Middleware::Delay(crate::models::DelayMiddleware { delay_ms: 0 }),
1892            Middleware::Limiter(crate::models::LimiterMiddleware {
1893                messages_per_second: 1_000_000.0,
1894            }),
1895        ]));
1896
1897        assert!(!output_middlewares_allow_http_inline_fast_path(&[
1898            Middleware::Retry(crate::models::RetryMiddleware::default()),
1899        ]));
1900        assert!(!output_middlewares_allow_http_inline_fast_path(&[
1901            Middleware::Dlq(Box::default()),
1902        ]));
1903    }
1904
1905    #[test]
1906    fn test_consumer_middleware_ordering() {
1907        let endpoint = Endpoint::new_memory("test", 10)
1908            .with_deduplication(crate::models::DeduplicationMiddleware {
1909                store: None,
1910                sled_path: Some("".into()),
1911                ttl_seconds: 10,
1912            })
1913            .with_consumer_metrics();
1914
1915        // Expected order in list: [Metrics, Dedup]
1916        // Consumer application (rev): Dedup -> Metrics.
1917        // Execution: Metrics( Dedup ( base ) ). Metrics is Outer.
1918        assert_eq!(endpoint.middlewares.len(), 2);
1919        assert!(matches!(endpoint.middlewares[0], Middleware::Metrics(_)));
1920        assert!(matches!(
1921            endpoint.middlewares[1],
1922            Middleware::Deduplication(_)
1923        ));
1924    }
1925
1926    #[test]
1927    fn test_check_consumer_invalid_config() {
1928        let config = crate::models::MemoryConfig {
1929            topic: "test".to_string(),
1930            request_reply: true, // Invalid for consumer
1931            ..Default::default()
1932        };
1933        let endpoint = Endpoint::new(EndpointType::Memory(config));
1934
1935        let warnings = check_consumer("test_route", &endpoint, None).unwrap();
1936        assert!(warnings
1937            .iter()
1938            .any(|w| w.contains("request_reply") && w.contains("publisher-only")));
1939    }
1940}