Skip to main content

mq_bridge/
models.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
6use serde::{
7    de::{MapAccess, Visitor},
8    Deserialize, Deserializer, Serialize,
9};
10use std::{
11    collections::HashMap,
12    sync::{atomic::AtomicUsize, Arc},
13};
14
15use crate::traits::Handler;
16use tracing::trace;
17
18/// The top-level configuration is a map of named routes.
19/// The key is the route name (e.g., "kafka_to_nats").
20///
21/// # Examples
22///
23/// Deserializing a complex configuration from YAML:
24///
25/// ```
26/// use mq_bridge::models::{Config, EndpointType, Middleware};
27///
28/// let yaml = r#"
29/// kafka_to_nats:
30///   concurrency: 10
31///   input:
32///     middlewares:
33///       - deduplication:
34///           sled_path: "/tmp/mq-bridge/dedup_db"
35///           ttl_seconds: 3600
36///       - metrics: {}
37///       - retry:
38///           max_attempts: 5
39///           initial_interval_ms: 200
40///       - random_panic:
41///           mode: nack
42///       - dlq:
43///           endpoint:
44///             nats:
45///               subject: "dlq-subject"
46///               url: "nats://localhost:4222"
47///     kafka:
48///       topic: "input-topic"
49///       url: "localhost:9092"
50///       group_id: "my-consumer-group"
51///       tls:
52///         required: true
53///         ca_file: "/path_to_ca"
54///         cert_file: "/path_to_cert"
55///         key_file: "/path_to_key"
56///         cert_password: "password"
57///         accept_invalid_certs: true
58///   output:
59///     middlewares:
60///       - metrics: {}
61///       - dlq:
62///           endpoint:
63///             file:
64///               path: "error.out"
65///     nats:
66///       subject: "output-subject"
67///       url: "nats://localhost:4222"
68/// "#;
69///
70/// let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
71/// let route = config.get("kafka_to_nats").unwrap();
72///
73/// assert_eq!(route.options.concurrency, 10);
74/// // Check input middleware
75/// assert!(route.input.middlewares.iter().any(|m| matches!(m, Middleware::Deduplication(_))));
76/// // Check output endpoint
77/// assert!(matches!(route.output.endpoint_type, EndpointType::Nats(_)));
78/// ```
79pub type Config = HashMap<String, Route>;
80
81/// A configuration map for named publishers (endpoints).
82/// The key is the publisher name.
83pub type PublisherConfig = HashMap<String, Endpoint>;
84
85/// Defines a single message processing route from an input to an output.
86#[derive(Debug, Deserialize, Serialize, Clone)]
87#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
88#[cfg_attr(feature = "schema", schemars(transform = route_schema_transform))]
89#[serde(deny_unknown_fields)]
90pub struct Route {
91    /// The input/source endpoint for the route.
92    pub input: Endpoint,
93    /// The output/sink endpoint for the route.
94    #[serde(default = "default_output_endpoint")]
95    pub output: Endpoint,
96    /// (Optional) Fine-tuning options for the route's execution.
97    #[serde(flatten, default)]
98    pub options: RouteOptions,
99}
100
101impl Default for Route {
102    fn default() -> Self {
103        Self {
104            input: Endpoint::null(),
105            output: Endpoint::null(),
106            options: RouteOptions::default(),
107        }
108    }
109}
110
111/// Fine-tuning options for a route's execution.
112///
113/// These options control concurrency, batching, and commit behavior for message processing.
114///
115/// # Examples
116///
117/// ```
118/// use mq_bridge::models::RouteOptions;
119///
120/// let options = RouteOptions {
121///     description: "My Route".to_string(),
122///     concurrency: 10,
123///     batch_size: 5,
124///     commit_concurrency_limit: 1024,
125///     ..Default::default()
126/// };
127/// ```
128#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
129#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
130#[serde(deny_unknown_fields)]
131pub struct RouteOptions {
132    /// A human-readable description of the route's purpose. Defaults to an empty string.
133    #[serde(default, skip_serializing_if = "String::is_empty")]
134    pub description: String,
135    /// (Optional) Number of concurrent processing tasks for this route. While it improves throughput for high-latency
136    /// handlers, it adds synchronization overhead for ordered commits and may lead to out-of-order processing
137    /// in the handler. Defaults to 1.
138    #[serde(default = "default_concurrency")]
139    #[cfg_attr(feature = "schema", schemars(range(min = 1)))]
140    pub concurrency: usize,
141    /// (Optional) Maximum number of messages to process in a single batch. The consumer waits for at least one message
142    /// and then attempts to fetch more if available. Increasing this improves throughput but also increases
143    /// the potential impact of a single batch processing failure. Defaults to 1.
144    #[serde(default = "default_batch_size")]
145    #[cfg_attr(feature = "schema", schemars(range(min = 1)))]
146    pub batch_size: usize,
147    /// (Optional) The maximum number of in-flight commit requests queued for ordered sequencing.
148    /// Lower values apply backpressure earlier; higher values allow larger commit backlogs.
149    /// Defaults to 4096.
150    #[serde(default = "default_commit_concurrency_limit")]
151    #[cfg_attr(feature = "schema", schemars(range(min = 1)))]
152    pub commit_concurrency_limit: usize,
153    /// Time to wait for a route to establish connections before startup fails. Defaults to 5000ms.
154    #[serde(default = "default_startup_timeout_ms")]
155    pub startup_timeout_ms: u64,
156    /// Time to wait before reconnecting after a transient route failure. Defaults to 5000ms.
157    #[serde(default = "default_reconnect_interval_ms")]
158    pub reconnect_interval_ms: u64,
159    /// Delay after an empty receive batch to avoid hot polling. Set to 0 to only yield. Defaults to 10ms.
160    #[serde(default = "default_empty_batch_delay_ms")]
161    pub empty_batch_delay_ms: u64,
162    /// Allows fault-injection middleware such as random_panic. Disabled by default.
163    #[serde(default = "default_false", skip_serializing_if = "is_false")]
164    #[cfg_attr(feature = "schema", schemars(default = "default_false"))]
165    pub allow_fault_injection: bool,
166    /// If true, the route exits gracefully once the source yields an empty batch
167    /// (drain-then-exit). Off by default — routes normally poll indefinitely.
168    #[serde(default = "default_false", skip_serializing_if = "is_false")]
169    #[cfg_attr(feature = "schema", schemars(default = "default_false"))]
170    pub exit_on_empty: bool,
171}
172
173impl Default for RouteOptions {
174    fn default() -> Self {
175        Self {
176            description: String::new(),
177            concurrency: default_concurrency(),
178            batch_size: default_batch_size(),
179            commit_concurrency_limit: default_commit_concurrency_limit(),
180            startup_timeout_ms: default_startup_timeout_ms(),
181            reconnect_interval_ms: default_reconnect_interval_ms(),
182            empty_batch_delay_ms: default_empty_batch_delay_ms(),
183            allow_fault_injection: false,
184            exit_on_empty: false,
185        }
186    }
187}
188
189impl RouteOptions {
190    pub fn validate(&self) -> anyhow::Result<()> {
191        if self.concurrency == 0 {
192            return Err(anyhow::anyhow!("route concurrency must be at least 1"));
193        }
194        if self.batch_size == 0 {
195            return Err(anyhow::anyhow!("route batch_size must be at least 1"));
196        }
197        if self.commit_concurrency_limit == 0 {
198            return Err(anyhow::anyhow!(
199                "route commit_concurrency_limit must be at least 1"
200            ));
201        }
202        Ok(())
203    }
204}
205
206pub(crate) fn default_concurrency() -> usize {
207    1
208}
209
210pub(crate) fn default_batch_size() -> usize {
211    1
212}
213
214pub(crate) fn default_commit_concurrency_limit() -> usize {
215    4096
216}
217
218pub(crate) fn default_startup_timeout_ms() -> u64 {
219    5000
220}
221
222pub(crate) fn default_reconnect_interval_ms() -> u64 {
223    5000
224}
225
226pub(crate) fn default_empty_batch_delay_ms() -> u64 {
227    10
228}
229
230fn is_false(value: &bool) -> bool {
231    !*value
232}
233
234fn default_false() -> bool {
235    false
236}
237
238#[cfg(feature = "schema")]
239fn default_inline_response_fast_path_schema() -> Option<bool> {
240    Some(true)
241}
242
243/// Schema default for `shared` fields, whose runtime default is `true`.
244#[cfg(feature = "schema")]
245fn default_shared_schema() -> Option<bool> {
246    Some(true)
247}
248
249/// Schema default for Kafka `partitions`, whose runtime default is 6.
250#[cfg(feature = "schema")]
251fn default_kafka_partitions_schema() -> Option<i32> {
252    Some(DEFAULT_KAFKA_PARTITIONS)
253}
254
255/// Partition count used when auto-creating a Kafka topic if none is configured.
256/// ordering is per-key (we key by message_id), not global across the topic if > 1
257pub const DEFAULT_KAFKA_PARTITIONS: i32 = 6;
258
259fn default_output_endpoint() -> Endpoint {
260    Endpoint::new(EndpointType::Null)
261}
262
263fn default_retry_attempts() -> usize {
264    3
265}
266fn default_initial_interval_ms() -> u64 {
267    100
268}
269fn default_max_interval_ms() -> u64 {
270    5000
271}
272fn default_multiplier() -> f64 {
273    2.0
274}
275fn default_clean_session() -> bool {
276    false
277}
278fn default_cookie_metadata_key() -> String {
279    "cookie".to_string()
280}
281fn default_set_cookie_metadata_key() -> String {
282    "set-cookie".to_string()
283}
284
285fn is_known_endpoint_name(name: &str) -> bool {
286    matches!(
287        name,
288        "aws"
289            | "kafka"
290            | "nats"
291            | "file"
292            | "static"
293            | "memory"
294            | "sled"
295            | "amqp"
296            | "mongodb"
297            | "mqtt"
298            | "http"
299            | "websocket"
300            | "ibmmq"
301            | "zeromq"
302            | "grpc"
303            | "fanout"
304            | "stream_buffer"
305            | "ref"
306            | "switch"
307            | "response"
308            | "reader"
309            | "null"
310            | "sqlx"
311    )
312}
313
314/// Represents a connection point for messages, which can be a source (input) or a sink (output).
315#[derive(Serialize, Clone, Default)]
316#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
317#[cfg_attr(feature = "schema", schemars(transform = endpoint_schema_transform))]
318#[serde(deny_unknown_fields)]
319pub struct Endpoint {
320    /// (Optional) A list of middlewares to apply to the endpoint.
321    #[serde(default)]
322    pub middlewares: Vec<Middleware>,
323
324    /// The specific endpoint implementation, determined by the configuration key (e.g., "kafka", "nats").
325    #[serde(flatten)]
326    pub endpoint_type: EndpointType,
327
328    #[serde(skip_serializing)]
329    #[cfg_attr(feature = "schema", schemars(skip))]
330    /// Internal handler for processing messages (not serialized).
331    pub handler: Option<Arc<dyn Handler>>,
332}
333
334impl std::fmt::Debug for Endpoint {
335    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336        f.debug_struct("Endpoint")
337            .field("middlewares", &self.middlewares)
338            .field("endpoint_type", &self.endpoint_type)
339            .field(
340                "handler",
341                &if self.handler.is_some() {
342                    "Some(<Handler>)"
343                } else {
344                    "None"
345                },
346            )
347            .finish()
348    }
349}
350
351impl<'de> Deserialize<'de> for Endpoint {
352    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
353    where
354        D: Deserializer<'de>,
355    {
356        struct EndpointVisitor;
357
358        impl<'de> Visitor<'de> for EndpointVisitor {
359            type Value = Endpoint;
360
361            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
362                formatter.write_str("a map representing an endpoint, the string \"null\", or null")
363            }
364
365            fn visit_unit<E>(self) -> Result<Self::Value, E>
366            where
367                E: serde::de::Error,
368            {
369                Ok(Endpoint::new(EndpointType::Null))
370            }
371
372            /// Unit variants of `EndpointType` serialize as a bare string (and are advertised
373            /// that way in the JSON schema). `Null` is currently the only one.
374            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
375            where
376                E: serde::de::Error,
377            {
378                if value == "null" {
379                    Ok(Endpoint::new(EndpointType::Null))
380                } else {
381                    Err(serde::de::Error::unknown_variant(value, &["null"]))
382                }
383            }
384
385            fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
386            where
387                E: serde::de::Error,
388            {
389                self.visit_str(&value)
390            }
391
392            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
393            where
394                A: MapAccess<'de>,
395            {
396                // Buffer the map into a temporary serde_json::Map.
397                // This allows us to separate the `middlewares` field from the rest.
398                let mut temp_map = serde_json::Map::new();
399                let mut middlewares_val = None;
400
401                while let Some((key, value)) = map.next_entry::<String, serde_json::Value>()? {
402                    if key == "middlewares" {
403                        middlewares_val = Some(value);
404                    } else {
405                        temp_map.insert(key, value);
406                    }
407                }
408
409                // Deserialize the rest of the map into the flattened EndpointType.
410                let temp_val = serde_json::Value::Object(temp_map);
411                let endpoint_type: EndpointType = match serde_json::from_value(temp_val.clone()) {
412                    Ok(et) => et,
413                    Err(original_err) => {
414                        if let serde_json::Value::Object(map) = &temp_val {
415                            if map.len() == 1 {
416                                let (name, config) = map.iter().next().unwrap();
417                                if is_known_endpoint_name(name) {
418                                    return Err(serde::de::Error::custom(original_err));
419                                }
420                                trace!("Falling back to Custom endpoint for key: {}", name);
421                                EndpointType::Custom {
422                                    name: name.clone(),
423                                    config: config.clone(),
424                                }
425                            } else if map.is_empty() {
426                                EndpointType::Null
427                            } else {
428                                return Err(serde::de::Error::custom(
429                                    "Invalid endpoint configuration: multiple keys found or unknown endpoint type",
430                                ));
431                            }
432                        } else {
433                            return Err(serde::de::Error::custom("Invalid endpoint configuration"));
434                        }
435                    }
436                };
437
438                // Deserialize the extracted middlewares value using the existing helper logic.
439                let middlewares = match middlewares_val {
440                    Some(val) => {
441                        deserialize_middlewares_from_value(val).map_err(serde::de::Error::custom)?
442                    }
443                    None => Vec::new(),
444                };
445
446                Ok(Endpoint {
447                    middlewares,
448                    endpoint_type,
449                    handler: None,
450                })
451            }
452        }
453
454        deserializer.deserialize_any(EndpointVisitor)
455    }
456}
457
458fn is_known_middleware_name(name: &str) -> bool {
459    matches!(
460        name,
461        "deduplication"
462            | "metrics"
463            | "dlq"
464            | "retry"
465            | "random_panic"
466            | "delay"
467            | "weak_join"
468            | "limiter"
469            | "buffer"
470            | "cookie_jar"
471            | "custom"
472    )
473}
474
475/// Deserialize middlewares from a generic serde_json::Value.
476///
477/// This logic was extracted from `deserialize_middlewares_from_map_or_seq` to be reused by the custom `Endpoint` deserializer.
478fn deserialize_middlewares_from_value(value: serde_json::Value) -> anyhow::Result<Vec<Middleware>> {
479    let arr = match value {
480        serde_json::Value::Array(arr) => arr,
481        serde_json::Value::Object(map) => {
482            let mut middlewares: Vec<_> = map
483                .into_iter()
484                // The config crate can produce maps with numeric string keys ("0", "1", ...)
485                // from environment variables. We need to sort by these keys to maintain order.
486                .filter_map(|(key, value)| key.parse::<usize>().ok().map(|index| (index, value)))
487                .collect();
488            middlewares.sort_by_key(|(index, _)| *index);
489
490            middlewares.into_iter().map(|(_, value)| value).collect()
491        }
492        _ => return Err(anyhow::anyhow!("Expected an array or object")),
493    };
494
495    let mut middlewares = Vec::new();
496    for item in arr {
497        // Check if it is a map with a single key that matches a known middleware
498        let known_name = if let serde_json::Value::Object(map) = &item {
499            if map.len() == 1 {
500                let (name, _) = map.iter().next().unwrap();
501                if is_known_middleware_name(name) {
502                    Some(name.clone())
503                } else {
504                    None
505                }
506            } else {
507                None
508            }
509        } else {
510            None
511        };
512
513        if let Some(name) = known_name {
514            match serde_json::from_value::<Middleware>(item.clone()) {
515                Ok(m) => middlewares.push(m),
516                Err(e) => {
517                    return Err(anyhow::anyhow!(
518                        "Failed to deserialize known middleware '{}': {}",
519                        name,
520                        e
521                    ))
522                }
523            }
524        } else if let Ok(m) = serde_json::from_value::<Middleware>(item.clone()) {
525            middlewares.push(m);
526        } else if let serde_json::Value::Object(map) = &item {
527            if map.len() == 1 {
528                let (name, config) = map.iter().next().unwrap();
529                middlewares.push(Middleware::Custom {
530                    name: name.clone(),
531                    config: config.clone(),
532                });
533            } else {
534                return Err(anyhow::anyhow!(
535                    "Invalid middleware configuration: {:?}",
536                    item
537                ));
538            }
539        } else {
540            return Err(anyhow::anyhow!(
541                "Invalid middleware configuration: {:?}",
542                item
543            ));
544        }
545    }
546    Ok(middlewares)
547}
548
549/// Configuration for the `static` endpoint.
550///
551/// Accepts either a bare string (the response body, JSON-encoded for backward
552/// compatibility) or a map for full control:
553///
554/// ```yaml
555/// # bare string  -> body is JSON-encoded ("Hello" comes back quoted)
556/// static: "Hello, World!"
557///
558/// # map form -> raw body + custom metadata (HTTP maps metadata to headers)
559/// static:
560///   body: "Hello, World!"
561///   raw: true
562///   metadata:
563///     content-type: "text/plain"
564///     server: "mq-bridge"
565/// ```
566///
567/// When `raw` is true the body is sent verbatim; otherwise it is JSON-encoded as
568/// a string. Every entry in `metadata` is attached to the produced message; when
569/// this endpoint feeds an HTTP response, those entries become response headers
570/// (e.g. `content-type`), otherwise they are ordinary message metadata.
571///
572/// The `body` supports `${…}` placeholders (compiled once at startup): request
573/// fields `${payload:a.b}` / `${metadata:key}` / `${message:id}`, generators
574/// `${gen:uuid|now|timestamp|counter|random(1,100)}`, and `${env:VAR}`. When the
575/// `content-type` metadata is a JSON type, interpolated request values are
576/// JSON-escaped by default; append `| raw` to splice verbatim, and write `$${…}`
577/// to emit a literal `${…}`. See [`crate::support::interpolation`] for the full reference.
578#[derive(Debug, Clone, Default)]
579pub struct StaticConfig {
580    /// The static response body.
581    pub body: String,
582    /// Send the body verbatim instead of JSON-encoding it as a string.
583    pub raw: bool,
584    /// Extra metadata entries attached to the produced message.
585    pub metadata: std::collections::HashMap<String, String>,
586}
587
588// Hand-written schema: the `Deserialize` impl below accepts either a bare string
589// or a map where only `body` is required, so the derived all-fields-required
590// object schema would reject valid configs.
591#[cfg(feature = "schema")]
592impl schemars::JsonSchema for StaticConfig {
593    fn schema_name() -> std::borrow::Cow<'static, str> {
594        "StaticConfig".into()
595    }
596
597    fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
598        schemars::json_schema!({
599            "description": "Configuration for the `static` endpoint. Accepts either a bare string (the response body, JSON-encoded for backward compatibility) or a map where only `body` is required and `raw` / `metadata` are optional.",
600            "oneOf": [
601                {
602                    "type": "string",
603                    "description": "The response body, JSON-encoded as a string."
604                },
605                {
606                    "type": "object",
607                    "properties": {
608                        "body": {
609                            "type": "string",
610                            "description": "The static response body."
611                        },
612                        "raw": {
613                            "type": "boolean",
614                            "description": "Send the body verbatim instead of JSON-encoding it as a string.",
615                            "default": false
616                        },
617                        "metadata": {
618                            "type": "object",
619                            "description": "Extra metadata entries attached to the produced message.",
620                            "additionalProperties": { "type": "string" }
621                        }
622                    },
623                    "required": ["body"],
624                    "additionalProperties": false
625                }
626            ]
627        })
628    }
629}
630
631impl Serialize for StaticConfig {
632    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
633    where
634        S: serde::Serializer,
635    {
636        // Backward-compatible: when no extra options are set, serialize as a bare
637        // string exactly like the historical `Static(String)` so configs written
638        // by this version remain readable by older versions.
639        if !self.raw && self.metadata.is_empty() {
640            return serializer.serialize_str(&self.body);
641        }
642        use serde::ser::SerializeStruct;
643        let mut state = serializer.serialize_struct("StaticConfig", 3)?;
644        state.serialize_field("body", &self.body)?;
645        state.serialize_field("raw", &self.raw)?;
646        state.serialize_field("metadata", &self.metadata)?;
647        state.end()
648    }
649}
650
651impl From<String> for StaticConfig {
652    fn from(body: String) -> Self {
653        StaticConfig {
654            body,
655            raw: false,
656            metadata: std::collections::HashMap::new(),
657        }
658    }
659}
660
661impl From<&str> for StaticConfig {
662    fn from(body: &str) -> Self {
663        StaticConfig::from(body.to_string())
664    }
665}
666
667impl<'de> Deserialize<'de> for StaticConfig {
668    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
669    where
670        D: serde::Deserializer<'de>,
671    {
672        #[derive(Deserialize)]
673        #[serde(untagged)]
674        enum Repr {
675            Str(String),
676            Map {
677                body: String,
678                #[serde(default)]
679                raw: bool,
680                #[serde(default)]
681                metadata: std::collections::HashMap<String, String>,
682            },
683        }
684        Ok(match Repr::deserialize(deserializer)? {
685            Repr::Str(body) => StaticConfig {
686                body,
687                raw: false,
688                metadata: std::collections::HashMap::new(),
689            },
690            Repr::Map {
691                body,
692                raw,
693                metadata,
694            } => StaticConfig {
695                body,
696                raw,
697                metadata,
698            },
699        })
700    }
701}
702
703/// An enumeration of all supported endpoint types.
704/// `#[serde(rename_all = "lowercase")]` ensures that the keys in the config (e.g., "kafka")
705/// match the enum variants.
706///
707/// # Examples
708///
709/// Configuring a Fanout endpoint in YAML:
710/// ```
711/// use mq_bridge::models::{Endpoint, EndpointType};
712///
713/// let yaml = r#"
714/// fanout:
715///   - memory: { topic: "out1" }
716///   - memory: { topic: "out2" }
717/// "#;
718///
719/// let endpoint: Endpoint = serde_yaml_ng::from_str(yaml).unwrap();
720/// if let EndpointType::Fanout(targets) = endpoint.endpoint_type {
721///     assert_eq!(targets.len(), 2);
722/// }
723/// ```
724#[derive(Debug, Deserialize, Serialize, Clone, Default)]
725#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
726#[serde(rename_all = "lowercase")]
727pub enum EndpointType {
728    Aws(AwsConfig),
729    Kafka(KafkaConfig),
730    Nats(NatsConfig),
731    File(FileConfig),
732    #[serde(rename = "object_store", alias = "objectstore", alias = "s3")]
733    ObjectStore(ObjectStoreConfig),
734    #[cfg_attr(feature = "schema", schemars(extend("format" = "structural_endpoint")))]
735    Static(StaticConfig),
736    #[cfg_attr(feature = "schema", schemars(extend("format" = "structural_endpoint")))]
737    Ref(String),
738    Memory(MemoryConfig),
739    Sled(SledConfig),
740    Amqp(AmqpConfig),
741    MongoDb(MongoDbConfig),
742    Mqtt(MqttConfig),
743    Http(HttpConfig),
744    WebSocket(WebSocketConfig),
745    IbmMq(IbmMqConfig),
746    ZeroMq(ZeroMqConfig),
747    #[serde(rename = "redis_streams", alias = "redis")]
748    RedisStreams(RedisStreamsConfig),
749    Grpc(GrpcConfig),
750    Sqlx(SqlxConfig),
751    #[serde(rename = "clickhouse", alias = "click_house")]
752    ClickHouse(ClickHouseConfig),
753    #[serde(rename = "postgres_cdc", alias = "postgres-cdc")]
754    PostgresCdc(PostgresCdcConfig),
755    #[cfg_attr(feature = "schema", schemars(extend("format" = "structural_endpoint")))]
756    Fanout(Vec<Endpoint>),
757    #[serde(rename = "stream_buffer")]
758    #[cfg_attr(feature = "schema", schemars(extend("format" = "structural_endpoint")))]
759    StreamBuffer(StreamBufferConfig),
760    #[cfg_attr(feature = "schema", schemars(extend("format" = "structural_endpoint")))]
761    Switch(SwitchConfig),
762    #[cfg_attr(feature = "schema", schemars(extend("format" = "structural_endpoint")))]
763    Response(ResponseConfig),
764    #[cfg_attr(feature = "schema", schemars(extend("format" = "structural_endpoint")))]
765    Reader(Box<Endpoint>),
766    #[cfg_attr(feature = "schema", schemars(extend("format" = "structural_endpoint")))]
767    Request(RequestForwardConfig),
768    #[cfg_attr(feature = "schema", schemars(extend("format" = "structural_endpoint")))]
769    Custom {
770        name: String,
771        config: serde_json::Value,
772    },
773    #[default]
774    #[cfg_attr(feature = "schema", schemars(extend("format" = "structural_endpoint")))]
775    Null,
776}
777
778impl EndpointType {
779    pub fn name(&self) -> &'static str {
780        match self {
781            EndpointType::Aws(_) => "aws",
782            EndpointType::Kafka(_) => "kafka",
783            EndpointType::Nats(_) => "nats",
784            EndpointType::File(_) => "file",
785            EndpointType::ObjectStore(_) => "object_store",
786            EndpointType::Static(_) => "static",
787            EndpointType::Ref(_) => "ref",
788            EndpointType::Memory(_) => "memory",
789            EndpointType::Sled(_) => "sled",
790            EndpointType::Amqp(_) => "amqp",
791            EndpointType::MongoDb(_) => "mongodb",
792            EndpointType::Mqtt(_) => "mqtt",
793            EndpointType::Http(_) => "http",
794            EndpointType::WebSocket(_) => "websocket",
795            EndpointType::IbmMq(_) => "ibmmq",
796            EndpointType::ZeroMq(_) => "zeromq",
797            EndpointType::RedisStreams(_) => "redis_streams",
798            EndpointType::Grpc(_) => "grpc",
799            EndpointType::Sqlx(_) => "sqlx",
800            EndpointType::ClickHouse(_) => "clickhouse",
801            EndpointType::PostgresCdc(_) => "postgres_cdc",
802            EndpointType::Fanout(_) => "fanout",
803            EndpointType::StreamBuffer(_) => "stream_buffer",
804            EndpointType::Switch(_) => "switch",
805            EndpointType::Response(_) => "response",
806            EndpointType::Reader(_) => "reader",
807            EndpointType::Request(_) => "request",
808            EndpointType::Custom { .. } => "custom",
809            EndpointType::Null => "null",
810        }
811    }
812
813    pub fn is_core(&self) -> bool {
814        matches!(
815            self,
816            EndpointType::File(_)
817                | EndpointType::Static(_)
818                | EndpointType::Ref(_)
819                | EndpointType::Memory(_)
820                | EndpointType::Fanout(_)
821                | EndpointType::StreamBuffer(_)
822                | EndpointType::Switch(_)
823                | EndpointType::Response(_)
824                | EndpointType::Reader(_)
825                | EndpointType::Request(_)
826                | EndpointType::Custom { .. }
827                | EndpointType::Null
828        )
829    }
830}
831
832/// AEAD cipher selection for [`EncryptionConfig`].
833#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default, PartialEq, Eq)]
834#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
835#[serde(rename_all = "snake_case")]
836pub enum CipherKind {
837    /// XChaCha20-Poly1305 (default): 192-bit random nonce, safe at high message rates.
838    #[default]
839    Xchacha20poly1305,
840    /// AES-256-GCM: 96-bit random nonce; prefer the default for very high volumes.
841    Aes256gcm,
842}
843
844/// AEAD encryption settings, shared by the `encryption` middleware (per-message
845/// payload encryption) and the at-rest `encryption` field of the file and
846/// object_store endpoints. Requires the `encryption` feature.
847#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
848#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
849#[serde(deny_unknown_fields)]
850pub struct EncryptionConfig {
851    /// AEAD cipher. Defaults to `xchacha20poly1305`.
852    #[serde(default)]
853    pub cipher: CipherKind,
854    /// Key identifier written into each envelope; selects the key when decrypting. Defaults to `default`.
855    #[serde(default = "default_encryption_key_id")]
856    pub key_id: String,
857    /// Base64-encoded 32-byte key. Supports `${env:VAR}` to read it from the environment.
858    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
859    pub key: String,
860    /// Extra `key_id -> base64 key` entries accepted when decrypting (key rotation).
861    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
862    #[cfg_attr(feature = "schema", schemars(extend("format" = "password")))]
863    pub decrypt_keys: HashMap<String, String>,
864}
865
866fn default_encryption_key_id() -> String {
867    "default".to_string()
868}
869
870impl Default for EncryptionConfig {
871    fn default() -> Self {
872        Self {
873            cipher: CipherKind::default(),
874            key_id: default_encryption_key_id(),
875            key: String::new(),
876            decrypt_keys: HashMap::new(),
877        }
878    }
879}
880
881/// An enumeration of all supported middleware types.
882#[derive(Debug, Deserialize, Serialize, Clone)]
883#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
884#[serde(rename_all = "snake_case")]
885pub enum Middleware {
886    Deduplication(DeduplicationMiddleware),
887    Metrics(MetricsMiddleware),
888    Dlq(Box<DeadLetterQueueMiddleware>),
889    Retry(RetryMiddleware),
890    RandomPanic(RandomPanicMiddleware),
891    Delay(DelayMiddleware),
892    WeakJoin(WeakJoinMiddleware),
893    Limiter(LimiterMiddleware),
894    Buffer(BufferMiddleware),
895    CookieJar(CookieJarMiddleware),
896    Transform(TransformMiddleware),
897    Encryption(EncryptionConfig),
898    Compression(CompressionMiddleware),
899    Custom {
900        name: String,
901        config: serde_json::Value,
902    },
903}
904
905/// Deduplication middleware configuration.
906///
907/// Prevents duplicate messages from being processed using a sled, MongoDB, or SQL backend.
908/// Messages are identified by their deduplication key and removed after the TTL expires.
909#[derive(Debug, Deserialize, Serialize, Clone)]
910#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
911#[serde(deny_unknown_fields)]
912pub struct DeduplicationMiddleware {
913    /// Store URL: `sled:///path` (local), `mongodb://host/db[/collection]`, or `postgres|mysql|mariadb|sqlite://…[/table]` (shared).
914    #[serde(default)]
915    pub store: Option<String>,
916    /// Local Sled directory (legacy). Prefer `store`.
917    #[serde(default)]
918    pub sled_path: Option<String>,
919    /// Time-to-live for deduplication entries in seconds.
920    pub ttl_seconds: u64,
921    /// Dedup key template, e.g. `${payload:order_id}`. Defaults to `message_id`.
922    #[serde(default)]
923    pub key: Option<String>,
924}
925
926/// Metrics middleware configuration.
927///
928/// Enables collection and reporting of message processing metrics such as throughput,
929/// latency, and error rates. The presence of this middleware in the configuration
930/// enables metrics collection for the endpoint.
931///
932/// Metrics are typically exported via Prometheus or similar monitoring systems.
933#[derive(Debug, Deserialize, Serialize, Clone)]
934#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
935#[serde(deny_unknown_fields)]
936pub struct MetricsMiddleware {}
937
938/// Dead-Letter Queue (DLQ) middleware configuration.
939///
940/// Routes failed messages to a designated endpoint for later analysis and recovery.
941/// It is recommended to pair this with the Retry middleware to avoid message loss.
942///
943/// Failed messages are sent to the configured endpoint when they are exhausted after retry attempts.
944#[derive(Debug, Deserialize, Serialize, Clone, Default)]
945#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
946#[serde(deny_unknown_fields)]
947pub struct DeadLetterQueueMiddleware {
948    /// The endpoint to send failed messages to.
949    pub endpoint: Endpoint,
950}
951
952/// Retry middleware configuration.
953///
954/// Implements exponential backoff retry logic for failed message processing.
955/// Failed messages are automatically retried with increasing delays between attempts.
956#[derive(Debug, Deserialize, Serialize, Clone, Default)]
957#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
958#[serde(deny_unknown_fields)]
959pub struct RetryMiddleware {
960    /// Maximum number of retry attempts. Defaults to 3.
961    #[serde(default = "default_retry_attempts")]
962    pub max_attempts: usize,
963    /// Initial retry interval in milliseconds. Defaults to 100ms.
964    #[serde(default = "default_initial_interval_ms")]
965    pub initial_interval_ms: u64,
966    /// Maximum retry interval in milliseconds. Defaults to 5000ms.
967    #[serde(default = "default_max_interval_ms")]
968    pub max_interval_ms: u64,
969    /// Multiplier for exponential backoff. Defaults to 2.0.
970    #[serde(default = "default_multiplier")]
971    pub multiplier: f64,
972}
973
974/// Delay middleware configuration.
975///
976/// Introduces a fixed delay before processing each message.
977/// Useful for rate limiting, testing, or allowing time for dependent systems to become ready.
978#[derive(Debug, Deserialize, Serialize, Clone)]
979#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
980#[serde(deny_unknown_fields)]
981pub struct DelayMiddleware {
982    /// Delay duration in milliseconds.
983    pub delay_ms: u64,
984}
985
986/// Throughput limiter middleware configuration.
987///
988/// Applies a best-effort pacing delay so an endpoint does not exceed the configured
989/// message rate. For batch operations the limiter accounts for the number of messages
990/// in the batch, not just the batch count.
991#[derive(Debug, Deserialize, Serialize, Clone)]
992#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
993#[serde(deny_unknown_fields)]
994pub struct LimiterMiddleware {
995    /// Target throughput in messages per second. Must be greater than zero.
996    pub messages_per_second: f64,
997}
998
999/// Publisher-side buffer middleware configuration.
1000///
1001/// Buffers outbound messages briefly so multiple single-message sends can be
1002/// forwarded as one `send_batch` call to the wrapped publisher.
1003#[derive(Debug, Deserialize, Serialize, Clone)]
1004#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1005#[serde(deny_unknown_fields)]
1006pub struct BufferMiddleware {
1007    /// Maximum number of messages to accumulate before flushing immediately.
1008    pub max_messages: usize,
1009    /// Maximum time to wait before flushing a non-full buffer.
1010    pub max_delay_ms: u64,
1011}
1012
1013/// Cookie/session jar middleware configuration.
1014///
1015/// Optimized for HTTP by default: it can read `cookie` and `set-cookie` metadata,
1016/// persist session cookies, and inject them into later outgoing requests.
1017///
1018/// The middleware can also capture arbitrary metadata values into the same session store
1019/// and optionally expose stored values back into message metadata.
1020#[derive(Debug, Deserialize, Serialize, Clone)]
1021#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1022#[serde(deny_unknown_fields)]
1023pub struct CookieJarMiddleware {
1024    /// Optional shared scope name. When set, middleware instances using the same scope
1025    /// share one session store across endpoints/routes in the process.
1026    #[serde(default)]
1027    pub shared_scope: Option<String>,
1028    /// Metadata key used to read/write HTTP Cookie headers. Defaults to `cookie`.
1029    #[serde(default = "default_cookie_metadata_key")]
1030    pub cookie_metadata_key: String,
1031    /// Metadata key used to read HTTP Set-Cookie responses. Defaults to `set-cookie`.
1032    #[serde(default = "default_set_cookie_metadata_key")]
1033    pub set_cookie_metadata_key: String,
1034    /// Additional metadata keys to persist into the session value store.
1035    #[serde(default)]
1036    pub capture_metadata_keys: Vec<String>,
1037    /// Optional metadata prefix used to export stored values back onto each message.
1038    ///
1039    /// Exported keys use `PREFIXcookie.<name>` for cookies and `PREFIXvalue.<name>` for
1040    /// captured generic values.
1041    #[serde(default)]
1042    pub export_metadata_prefix: Option<String>,
1043    /// Optional mapping of outgoing metadata keys to stored session value names.
1044    ///
1045    /// Example: `{ "authorization": "access_token" }` copies the stored value
1046    /// `access_token` into outgoing metadata key `authorization` when not already present.
1047    #[serde(default)]
1048    pub inject_metadata: HashMap<String, String>,
1049}
1050
1051impl Default for CookieJarMiddleware {
1052    fn default() -> Self {
1053        Self {
1054            shared_scope: None,
1055            cookie_metadata_key: default_cookie_metadata_key(),
1056            set_cookie_metadata_key: default_set_cookie_metadata_key(),
1057            capture_metadata_keys: Vec::new(),
1058            export_metadata_prefix: None,
1059            inject_metadata: HashMap::new(),
1060        }
1061    }
1062}
1063
1064/// Weak Join middleware configuration.
1065///
1066/// Correlates messages by a metadata key and joins them within a timeout window.
1067/// Count mode (default) waits for `expected_count` messages and emits a JSON array.
1068/// Branch mode (set `branch_by`) waits for named branches and emits a branch-keyed object.
1069#[derive(Debug, Deserialize, Serialize, Clone)]
1070#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1071#[serde(deny_unknown_fields)]
1072pub struct WeakJoinMiddleware {
1073    /// The metadata key to group messages by (e.g., "correlation_id").
1074    pub group_by: String,
1075    /// The number of messages (count mode) or distinct branches (branch mode) to wait for.
1076    pub expected_count: usize,
1077    /// Timeout in milliseconds.
1078    pub timeout_ms: u64,
1079    /// Metadata key naming each message's branch; enables branch mode when set.
1080    #[serde(default)]
1081    pub branch_by: Option<String>,
1082    /// Branch names that must all arrive before firing (branch mode; overrides expected_count).
1083    #[serde(default)]
1084    pub required: Vec<String>,
1085    /// What to do with an incomplete group when the timeout expires.
1086    #[serde(default)]
1087    pub on_timeout: WeakJoinTimeout,
1088}
1089
1090/// Action taken on an incomplete weak-join group when its timeout expires.
1091#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default, PartialEq, Eq)]
1092#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1093#[serde(rename_all = "snake_case")]
1094pub enum WeakJoinTimeout {
1095    /// Emit the partial join (current behavior).
1096    #[default]
1097    Fire,
1098    /// Drop the incomplete group without emitting.
1099    Discard,
1100}
1101
1102/// JSON transform middleware configuration.
1103///
1104/// Reshapes JSON payloads declaratively, in two stages over a single parse: field
1105/// `mapping` (rename/move/nest), then `schema` (type coercion, defaults, validation).
1106/// Either stage may be omitted; with neither configured the message passes through
1107/// untouched and is never parsed.
1108///
1109/// On an output endpoint a rejected message becomes a non-retryable failure, so a `dlq`
1110/// middleware listed *after* this one captures it (publisher middlewares are wrapped in
1111/// list order, so the last entry is the outermost layer). On an input endpoint a rejected
1112/// message is dropped from the batch and acknowledged, which is how invalid input is kept
1113/// out of the route.
1114#[derive(Debug, Deserialize, Serialize, Clone)]
1115#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1116#[cfg_attr(feature = "schema", schemars(transform = transform_middleware_schema_transform))]
1117#[serde(deny_unknown_fields)]
1118pub struct TransformMiddleware {
1119    /// Output field name -> source path (e.g. `firstName: "$.first_name"`). Dots nest the output.
1120    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1121    pub mapping: HashMap<String, MappingRule>,
1122    /// Inline JSON Schema subset (type, properties, required, default, items, nullable, enum).
1123    #[serde(default, skip_serializing_if = "Option::is_none")]
1124    pub schema: Option<serde_json::Value>,
1125    /// Path to a JSON Schema file. Read once at startup; never re-read per message.
1126    #[serde(default, skip_serializing_if = "Option::is_none")]
1127    pub schema_file: Option<String>,
1128    /// Coerce safely convertible types (e.g. `"42"` -> `42`) instead of rejecting. Defaults to true.
1129    #[serde(default = "default_true")]
1130    pub coerce: bool,
1131    /// Insert `default` values from the schema for missing fields. Defaults to true.
1132    #[serde(default = "default_true")]
1133    pub apply_defaults: bool,
1134    /// What to do with a message that fails to transform. Defaults to `reject`.
1135    #[serde(default)]
1136    pub on_error: TransformErrorPolicy,
1137}
1138
1139// Hand-written rather than derived: `coerce` and `apply_defaults` default to *true*, which
1140// a derived `Default` would silently turn into `false`. That would make
1141// `TransformMiddleware { ..Default::default() }` in Rust behave differently from the same
1142// config parsed from YAML.
1143impl Default for TransformMiddleware {
1144    fn default() -> Self {
1145        Self {
1146            mapping: HashMap::new(),
1147            schema: None,
1148            schema_file: None,
1149            coerce: default_true(),
1150            apply_defaults: default_true(),
1151            on_error: TransformErrorPolicy::default(),
1152        }
1153    }
1154}
1155
1156/// How one output field is produced from the input document.
1157///
1158/// Either a bare path string (`"$.first_name"`) or an object with a `path` plus an
1159/// optional `default` and `required` flag.
1160#[derive(Debug, Deserialize, Serialize, Clone)]
1161#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1162#[serde(untagged)]
1163pub enum MappingRule {
1164    /// Shorthand: just the source path.
1165    Path(String),
1166    /// Full form with a fallback value and/or a presence requirement.
1167    Detailed(DetailedMappingRule),
1168}
1169
1170/// Full mapping form with a fallback value and/or a presence requirement.
1171#[derive(Debug, Deserialize, Serialize, Clone)]
1172#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1173#[serde(deny_unknown_fields)]
1174pub struct DetailedMappingRule {
1175    /// Source path in the input document (e.g. `$.user.id`, `user.id`, `$.items[0]`).
1176    pub path: String,
1177    /// Value used when the source path is absent.
1178    #[serde(default, skip_serializing_if = "Option::is_none")]
1179    pub default: Option<serde_json::Value>,
1180    /// Reject the message when the source path is absent and no `default` is set.
1181    #[serde(default)]
1182    pub required: bool,
1183}
1184
1185impl MappingRule {
1186    /// The source path this rule reads from.
1187    pub fn path(&self) -> &str {
1188        match self {
1189            MappingRule::Path(p) => p,
1190            MappingRule::Detailed(d) => &d.path,
1191        }
1192    }
1193}
1194
1195/// Action taken on a message that fails to transform.
1196#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default, PartialEq, Eq)]
1197#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1198#[serde(rename_all = "snake_case")]
1199pub enum TransformErrorPolicy {
1200    /// Reject the message: non-retryable failure on output, dropped from the batch on input.
1201    #[default]
1202    Reject,
1203    /// Forward the original payload unchanged with the error recorded in metadata.
1204    PassThrough,
1205}
1206
1207/// Fault injection modes for testing error handling and recovery mechanisms.
1208#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1209#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1210#[serde(rename_all = "snake_case")]
1211pub enum FaultMode {
1212    /// Trigger a thread panic.
1213    #[default]
1214    Panic,
1215    /// Simulate a connection/network error (retryable).
1216    Disconnect,
1217    /// Simulate a timeout error (retryable).
1218    Timeout,
1219    /// Simulate a JSON format error (non-retryable).
1220    JsonFormatError,
1221    /// Return a negative acknowledgement (for handlers).
1222    Nack,
1223}
1224
1225impl std::fmt::Display for FaultMode {
1226    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1227        match self {
1228            FaultMode::Panic => write!(f, "panic"),
1229            FaultMode::Disconnect => write!(f, "disconnect"),
1230            FaultMode::Timeout => write!(f, "timeout"),
1231            FaultMode::JsonFormatError => write!(f, "json_format_error"),
1232            FaultMode::Nack => write!(f, "nack"),
1233        }
1234    }
1235}
1236
1237/// Middleware for fault injection testing.
1238///
1239/// Allows testing error handling and recovery mechanisms by injecting faults
1240/// at specific points in the message processing pipeline.
1241///
1242/// # Examples
1243///
1244/// ```yaml
1245/// random_panic:
1246///   mode: panic
1247///   trigger_on_message: 3  # Trigger on the 3rd message
1248/// ```
1249#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1250#[serde(deny_unknown_fields)]
1251#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1252pub struct RandomPanicMiddleware {
1253    /// The type of fault to inject.
1254    #[serde(default)]
1255    pub mode: FaultMode,
1256    /// Trigger the fault on the Nth message (1-indexed). None = trigger on every message.
1257    #[cfg_attr(feature = "schema", schemars(range(min = 1)))]
1258    #[serde(default)]
1259    pub trigger_on_message: Option<usize>,
1260    /// Enable/disable the fault injection without removing the configuration.
1261    #[serde(default = "default_true")]
1262    pub enabled: bool,
1263    #[serde(skip, default = "default_atomic_usize_arc")]
1264    #[cfg_attr(feature = "schema", schemars(skip))]
1265    pub message_count: Arc<AtomicUsize>,
1266}
1267
1268fn default_true() -> bool {
1269    true
1270}
1271
1272fn default_atomic_usize_arc() -> Arc<AtomicUsize> {
1273    Arc::new(AtomicUsize::new(0))
1274}
1275
1276fn deserialize_null_as_false<'de, D>(deserializer: D) -> Result<bool, D::Error>
1277where
1278    D: Deserializer<'de>,
1279{
1280    let opt = Option::<bool>::deserialize(deserializer)?;
1281    Ok(opt.unwrap_or(false))
1282}
1283
1284// --- AWS Specific Configuration ---
1285#[derive(Debug, Deserialize, Serialize, Clone, Default)]
1286#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1287#[serde(deny_unknown_fields)]
1288pub struct AwsConfig {
1289    /// The SQS queue URL. Required for Consumer. Optional for Publisher if `topic_arn` is set. If it contains userinfo, it will be treated as a secret.
1290    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
1291    pub queue_url: Option<String>,
1292    /// (Publisher only) The SNS topic ARN.
1293    pub topic_arn: Option<String>,
1294    /// AWS Region (e.g., "us-east-1").
1295    pub region: Option<String>,
1296    /// Custom endpoint URL (e.g., for LocalStack).
1297    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
1298    pub endpoint_url: Option<String>,
1299    /// AWS Access Key ID.
1300    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
1301    pub access_key: Option<String>,
1302    /// AWS Secret Access Key.
1303    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
1304    pub secret_key: Option<String>,
1305    /// AWS Session Token.
1306    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
1307    pub session_token: Option<String>,
1308    /// (Consumer only) Maximum number of messages to receive in a batch (1-10).
1309    #[cfg_attr(feature = "schema", schemars(range(min = 1, max = 10)))]
1310    pub max_messages: Option<i32>,
1311    /// (Consumer only) Wait time for long polling in seconds (0-20).
1312    #[cfg_attr(feature = "schema", schemars(range(min = 0, max = 20)))]
1313    pub wait_time_seconds: Option<i32>,
1314    /// Use binary payloads in SQS/SNS messages.
1315    #[serde(default)]
1316    pub binary_payload_mode: bool,
1317}
1318
1319impl AwsConfig {
1320    /// Creates a new AWS configuration with default settings.
1321    pub fn new() -> Self {
1322        Self::default()
1323    }
1324
1325    pub fn with_queue_url(mut self, queue_url: impl Into<String>) -> Self {
1326        self.queue_url = Some(queue_url.into());
1327        self
1328    }
1329
1330    pub fn with_topic_arn(mut self, topic_arn: impl Into<String>) -> Self {
1331        self.topic_arn = Some(topic_arn.into());
1332        self
1333    }
1334
1335    pub fn with_region(mut self, region: impl Into<String>) -> Self {
1336        self.region = Some(region.into());
1337        self
1338    }
1339
1340    pub fn with_endpoint_url(mut self, endpoint_url: impl Into<String>) -> Self {
1341        self.endpoint_url = Some(endpoint_url.into());
1342        self
1343    }
1344
1345    pub fn with_credentials(
1346        mut self,
1347        access_key: impl Into<String>,
1348        secret_key: impl Into<String>,
1349    ) -> Self {
1350        self.access_key = Some(access_key.into());
1351        self.secret_key = Some(secret_key.into());
1352        self
1353    }
1354}
1355
1356// --- Kafka Specific Configuration ---
1357
1358/// General Kafka connection configuration.
1359#[derive(Debug, Deserialize, Serialize, Clone, Default)]
1360#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1361#[serde(deny_unknown_fields)]
1362pub struct KafkaConfig {
1363    /// Comma-separated list of Kafka broker URLs. If it contains userinfo, it will be treated as a secret.
1364    #[serde(alias = "brokers")]
1365    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
1366    pub url: String,
1367    /// The Kafka topic to produce to or consume from.
1368    pub topic: Option<String>,
1369    /// Optional username for SASL authentication.
1370    pub username: Option<String>,
1371    /// Optional password for SASL authentication.
1372    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
1373    pub password: Option<String>,
1374    /// TLS configuration.
1375    #[serde(default)]
1376    pub tls: TlsConfig,
1377    /// (Consumer only) Consumer group ID.
1378    /// If not provided, the consumer acts in **Subscriber mode**: it generates a unique, ephemeral group ID and starts consuming from the latest offset.
1379    pub group_id: Option<String>,
1380    /// (Publisher only) If true, do not wait for an acknowledgement when sending to broker. Defaults to false.
1381    #[serde(default)]
1382    pub delayed_ack: bool,
1383    /// (Publisher only) Additional librdkafka producer configuration options (key-value pairs).
1384    #[serde(default)]
1385    pub producer_options: Option<Vec<(String, String)>>,
1386    /// (Consumer only) Additional librdkafka consumer configuration options (key-value pairs).
1387    #[serde(default)]
1388    pub consumer_options: Option<Vec<(String, String)>>,
1389    /// (Publisher only) Share one producer per connection (default: true); false gives a dedicated producer.
1390    #[serde(default)]
1391    #[cfg_attr(feature = "schema", schemars(default = "default_shared_schema"))]
1392    pub shared: Option<bool>,
1393    /// (Publisher only) Partition count used when auto-creating the topic (default: 6).
1394    /// Higher values raise write/consume parallelism; ordering is only guaranteed per
1395    /// partition key (message_id), not across the whole topic. Ignored if the topic exists.
1396    #[serde(default)]
1397    #[cfg_attr(
1398        feature = "schema",
1399        schemars(default = "default_kafka_partitions_schema", range(min = 1))
1400    )]
1401    pub partitions: Option<i32>,
1402    /// (Publisher only) Name of a metadata field whose value is used as the Kafka record
1403    /// key (drives partitioning/ordering). Unset, or absent on a given message, falls back
1404    /// to the message id. Default unset.
1405    #[serde(default)]
1406    pub partition_key: Option<String>,
1407}
1408
1409impl KafkaConfig {
1410    /// Creates a new Kafka configuration with the specified broker URL.
1411    pub fn new(url: impl Into<String>) -> Self {
1412        Self {
1413            url: url.into(),
1414            ..Default::default()
1415        }
1416    }
1417
1418    pub fn with_topic(mut self, topic: impl Into<String>) -> Self {
1419        self.topic = Some(topic.into());
1420        self
1421    }
1422
1423    pub fn with_group_id(mut self, group_id: impl Into<String>) -> Self {
1424        self.group_id = Some(group_id.into());
1425        self
1426    }
1427
1428    pub fn with_credentials(
1429        mut self,
1430        username: impl Into<String>,
1431        password: impl Into<String>,
1432    ) -> Self {
1433        self.username = Some(username.into());
1434        self.password = Some(password.into());
1435        self
1436    }
1437
1438    pub fn with_producer_option(
1439        mut self,
1440        key: impl Into<String>,
1441        value: impl Into<String>,
1442    ) -> Self {
1443        let options = self.producer_options.get_or_insert_with(Vec::new);
1444        options.push((key.into(), value.into()));
1445        self
1446    }
1447
1448    pub fn with_consumer_option(
1449        mut self,
1450        key: impl Into<String>,
1451        value: impl Into<String>,
1452    ) -> Self {
1453        let options = self.consumer_options.get_or_insert_with(Vec::new);
1454        options.push((key.into(), value.into()));
1455        self
1456    }
1457}
1458
1459// --- Sled Specific Configuration ---
1460
1461/// General Sled database configuration
1462#[derive(Debug, Deserialize, Serialize, Clone, Default)]
1463#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1464#[serde(deny_unknown_fields)]
1465pub struct SledConfig {
1466    /// Path to the Sled database directory.
1467    pub path: String,
1468    /// The tree name to use as a queue. Defaults to "default".
1469    pub tree: Option<String>,
1470    /// (Consumer only) If true, start reading from the beginning of the tree.
1471    #[serde(default)]
1472    pub read_from_start: bool,
1473    /// (Consumer only) If true, delete messages after processing (Queue mode).
1474    #[serde(default)]
1475    pub delete_after_read: bool,
1476}
1477
1478impl SledConfig {
1479    /// Creates a new Sled configuration with the specified database path.
1480    pub fn new(path: impl Into<String>) -> Self {
1481        Self {
1482            path: path.into(),
1483            ..Default::default()
1484        }
1485    }
1486
1487    pub fn with_tree(mut self, tree: impl Into<String>) -> Self {
1488        self.tree = Some(tree.into());
1489        self
1490    }
1491
1492    pub fn with_read_from_start(mut self, read_from_start: bool) -> Self {
1493        self.read_from_start = read_from_start;
1494        self
1495    }
1496}
1497
1498/// Format for messages written to or read from a file.
1499#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq, Eq)]
1500#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1501#[serde(rename_all = "snake_case")]
1502pub enum FileFormat {
1503    /// The full `CanonicalMessage` is serialized to JSON. Payload is a byte array.
1504    #[default]
1505    Normal,
1506    /// The full `CanonicalMessage` is serialized to JSON. Payload is rendered as a JSON value if possible.
1507    Json,
1508    /// The full `CanonicalMessage` is serialized to JSON. Payload is rendered as a string if possible.
1509    Text,
1510    /// The raw payload of the message is written. For consumers, the line is read as raw bytes.
1511    Raw,
1512    /// CSV rows mapped to/from JSON objects (string values only). The first row is the header/schema.
1513    Csv,
1514}
1515
1516/// Compression algorithm. Used for at-rest batches (file, object_store) and for HTTP
1517/// body compression (http, clickhouse). Orthogonal to `format`.
1518#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default, PartialEq, Eq)]
1519#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1520#[serde(rename_all = "snake_case")]
1521pub enum Compression {
1522    /// No compression (default).
1523    #[default]
1524    None,
1525    /// gzip; each batch is a self-contained member, so files stay readable with `zcat`.
1526    Gzip,
1527    /// lz4 frame format; each batch is a self-contained frame (`lz4 -d` compatible).
1528    Lz4,
1529    /// zstd; each batch is a self-contained frame, concatenated frames decode as one
1530    /// stream (`zstd -d` compatible). Better ratio than lz4, still fast.
1531    Zstd,
1532}
1533
1534fn default_compression_algorithm() -> Compression {
1535    Compression::Zstd
1536}
1537
1538/// Payload-compression middleware configuration.
1539///
1540/// Compresses each message payload on the output side and decompresses it on the input
1541/// side; metadata and routing keys are left untouched. Requires the `compression` feature.
1542/// Distinct from the `file`/`object_store` batch `compression` field, which stays
1543/// CLI-decodable at rest — this operates per message on any transport.
1544#[derive(Debug, Deserialize, Serialize, Clone)]
1545#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1546#[serde(deny_unknown_fields)]
1547pub struct CompressionMiddleware {
1548    /// Algorithm: `none`, `gzip`, `lz4`, or `zstd`. Defaults to `zstd`.
1549    #[serde(default = "default_compression_algorithm")]
1550    pub algorithm: Compression,
1551    /// Reject a decompressed payload larger than this many bytes (decompression-bomb guard).
1552    /// Consumer side only; unset means no limit.
1553    #[serde(default)]
1554    pub max_decompressed_bytes: Option<u64>,
1555}
1556
1557impl Default for CompressionMiddleware {
1558    fn default() -> Self {
1559        Self {
1560            algorithm: default_compression_algorithm(),
1561            max_decompressed_bytes: None,
1562        }
1563    }
1564}
1565
1566// --- File Specific Configuration ---
1567
1568#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1569#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1570pub struct FileConfig {
1571    /// Path to the file.
1572    pub path: String,
1573    /// Optional delimiter for messages. Defaults to newline ("\n").
1574    /// Can be a string or a hex sequence (e.g. "0x00").
1575    /// Currently only single-byte delimiters are supported.
1576    pub delimiter: Option<String>,
1577    /// The consumption mode. If not specified, defaults to `consume`.
1578    /// For publishers, this setting is ignored.
1579    #[serde(flatten, default)]
1580    pub mode: Option<FileConsumerMode>,
1581    /// The format for writing messages to the file (Publisher) or interpreting them (Consumer). Defaults to `normal`.
1582    #[serde(default)]
1583    pub format: FileFormat,
1584    /// Per-batch compression (`none`, `gzip`, `lz4`, `zstd`). Requires the `compression` feature. Publishers: always. Consumers: must match, and only the default `consume` mode reads it.
1585    #[serde(default)]
1586    pub compression: Compression,
1587    /// At-rest AEAD encryption applied after compression. Requires the `encryption` feature. Publishers: always. Consumers: must match, and only the default `consume` mode reads it.
1588    #[serde(default)]
1589    pub encryption: Option<EncryptionConfig>,
1590}
1591
1592#[derive(Debug, Clone, Deserialize, Serialize)]
1593#[serde(tag = "mode", rename_all = "snake_case")]
1594#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1595pub enum FileConsumerMode {
1596    /// **Queue Mode**: Standard point-to-point consumption. Reads from the start
1597    /// of the file. If `delete` is true, processed lines are physically removed
1598    /// from the file once they are successfully acknowledged.
1599    Consume {
1600        /// If true, processed lines are physically removed from the file once
1601        /// they are successfully acknowledged.
1602        #[serde(default)]
1603        delete: bool,
1604    },
1605    /// **Broadcast Mode**: Pub-sub style consumption. Tails the file by starting
1606    /// at the current end. If `delete` is true, lines are removed only after
1607    /// all local application subscribers for this specific file have acknowledged them.
1608    Subscribe {
1609        /// If true, lines are removed only after all local application
1610        /// subscribers for this file have acknowledged them.
1611        #[serde(default)]
1612        delete: bool,
1613    },
1614    /// **Persistent Mode**: Consumption with external offset tracking.
1615    /// Saves the last read byte position to a `.offset` file identified by the `group_id`.
1616    /// This allows the consumer to resume exactly where it left off after a restart
1617    /// without deleting data or requiring the bridge to stay running.
1618    GroupSubscribe {
1619        /// The consumer group ID that is used for offset tracking. Should be unique.
1620        group_id: String,
1621        /// If true, starts reading from the end of the file if no offset is stored.
1622        /// If false, starts reading from the beginning.
1623        #[serde(default)]
1624        read_from_tail: bool,
1625    },
1626}
1627
1628impl Default for FileConsumerMode {
1629    fn default() -> Self {
1630        Self::Consume { delete: false }
1631    }
1632}
1633
1634impl FileConfig {
1635    /// Creates a new File configuration with the specified path.
1636    pub fn new(path: impl Into<String>) -> Self {
1637        Self {
1638            path: path.into(),
1639            mode: Some(FileConsumerMode::default()),
1640            delimiter: None,
1641            format: FileFormat::default(),
1642            compression: Compression::default(),
1643            encryption: None,
1644        }
1645    }
1646
1647    pub fn with_mode(mut self, mode: FileConsumerMode) -> Self {
1648        self.mode = Some(mode);
1649        self
1650    }
1651
1652    /// Returns the effective consumer mode, defaulting to `Consume` if not set.
1653    pub fn effective_mode(&self) -> FileConsumerMode {
1654        self.mode.clone().unwrap_or_default()
1655    }
1656}
1657
1658// --- Object Store (S3/GCS/Azure) Specific Configuration ---
1659
1660/// Configuration for a cloud object-store endpoint (S3, GCS, Azure Blob, R2, ...).
1661///
1662/// As a **sink**, each flushed batch is written as one immutable object under `url`,
1663/// named `<prefix>/[YYYY/MM/DD/]<uuidv7>.<ext>`. As a **source**, objects under `url`
1664/// are listed in key order, fetched, split by `delimiter`, and emitted as messages;
1665/// progress is persisted to `checkpoint_store` (the last processed object key) so a
1666/// restart resumes without re-emitting. Objects are never mutated or deleted in place.
1667#[derive(Debug, Clone, Serialize, Deserialize)]
1668#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1669pub struct ObjectStoreConfig {
1670    /// Object-store URL, e.g. `s3://bucket/prefix`, `gs://bucket/prefix`,
1671    /// `az://account/container/prefix`. Credentials are resolved from the environment by
1672    /// the `object_store` crate (same mechanism as the checkpoint backend); R2 uses
1673    /// `s3://` plus a custom `AWS_ENDPOINT_URL`.
1674    pub url: String,
1675    /// Record encoding within an object, shared with the file endpoint. Defaults to
1676    /// `normal` (one JSON `CanonicalMessage` per line). CSV is supported for sources only.
1677    #[serde(default)]
1678    pub format: FileFormat,
1679    /// Record delimiter within an object. Defaults to newline ("\n"). Can be a string or a
1680    /// hex sequence (e.g. "0x00").
1681    pub delimiter: Option<String>,
1682    /// (Source only) Durable resume store URL recording the last processed object key, e.g.
1683    /// `file:///var/lib/mqb/obj.json`, `s3://bucket/cursors`, or `postgres://…`. Without it
1684    /// every restart re-lists and re-emits all objects.
1685    pub checkpoint_store: Option<String>,
1686    /// (Source only) Cursor id namespacing the checkpoint key; enables durable resume.
1687    pub cursor_id: Option<String>,
1688    /// (Source only) Idle poll interval in milliseconds when no new objects are found.
1689    /// Defaults to 1000.
1690    pub polling_interval_ms: Option<u64>,
1691    /// (Source only) Maximum size in bytes of a single object to fetch into memory. An object
1692    /// larger than this fails the read (surfaced as a consumer error) instead of being
1693    /// buffered whole. Unset means no limit (the whole object is materialized).
1694    pub max_object_bytes: Option<u64>,
1695    /// (Sink only) Prepend a `YYYY/MM/DD/` path (write time, UTC) to each object key. Purely
1696    /// for readability / lifecycle rules — the uuidv7 name already sorts by time. Default true.
1697    #[serde(default = "default_true")]
1698    pub date_partition: bool,
1699    /// (Sink only) Extension for written objects, without the dot. Defaults to a value derived
1700    /// from `format`, `compression` and `encryption` (e.g. `jsonl`, `csv`, `bin`, `jsonl.gz`,
1701    /// `jsonl.lz4`, `jsonl.gz.enc`); encrypted objects get a trailing `.enc` since they are
1702    /// ciphertext, not a directly decompressible `.gz`.
1703    pub extension: Option<String>,
1704    /// Whole-object compression (`none`, `gzip`, `lz4`, `zstd`). Requires the `compression` feature.
1705    #[serde(default)]
1706    pub compression: Compression,
1707    /// At-rest AEAD encryption applied after compression. Requires the `encryption` feature.
1708    #[serde(default)]
1709    pub encryption: Option<EncryptionConfig>,
1710}
1711
1712impl Default for ObjectStoreConfig {
1713    fn default() -> Self {
1714        Self {
1715            url: String::new(),
1716            format: FileFormat::default(),
1717            delimiter: None,
1718            checkpoint_store: None,
1719            cursor_id: None,
1720            polling_interval_ms: None,
1721            max_object_bytes: None,
1722            date_partition: true,
1723            extension: None,
1724            compression: Compression::default(),
1725            encryption: None,
1726        }
1727    }
1728}
1729
1730// --- NATS Specific Configuration ---
1731
1732/// General NATS connection configuration.
1733#[derive(Debug, Deserialize, Serialize, Clone, Default)]
1734#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1735#[serde(deny_unknown_fields)]
1736pub struct NatsConfig {
1737    /// Comma-separated list of NATS server URLs (e.g., "nats://localhost:4222,nats://localhost:4223"). If it contains userinfo, it will be treated as a secret.
1738    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
1739    pub url: String,
1740    /// The NATS subject to publish to or subscribe to. If a stream is
1741    /// auto-created, it's scoped to `{stream}.>`, so prefix accordingly.
1742    pub subject: Option<String>,
1743    /// The JetStream stream name. Required for Consumers, even with
1744    /// `no_jetstream: true` (unused there, but still validated).
1745    pub stream: Option<String>,
1746    /// Optional username for authentication.
1747    pub username: Option<String>,
1748    /// Optional password for authentication.
1749    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
1750    pub password: Option<String>,
1751    /// TLS configuration.
1752    #[serde(default)]
1753    pub tls: TlsConfig,
1754    /// Optional token for authentication.
1755    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
1756    pub token: Option<String>,
1757    /// (Publisher only) If true, the publisher uses the request-reply pattern.
1758    /// It sends a request and waits for a response (using `core_client.request_with_headers()`).
1759    /// Defaults to false.
1760    #[serde(default)]
1761    pub request_reply: bool,
1762    /// (Publisher only) Timeout for request-reply operations in milliseconds. Defaults to 30000ms.
1763    pub request_timeout_ms: Option<u64>,
1764    /// (Publisher only) If true, do not wait for an acknowledgement when sending to broker. Defaults to false.
1765    #[serde(default)]
1766    pub delayed_ack: bool,
1767    /// (Publisher only, JetStream) If true, publish a `Nats-Msg-Id` header (from the message id) so
1768    /// JetStream deduplicates redeliveries within the stream's duplicate window. Defaults to false.
1769    #[serde(default)]
1770    pub deduplicate: bool,
1771    /// If no_jetstream: true, use Core NATS (fire-and-forget) instead of JetStream. Defaults to false.
1772    #[serde(default)]
1773    pub no_jetstream: bool,
1774    /// (Consumer only) If true, use ephemeral **Subscriber mode**. Defaults to false (durable consumer).
1775    #[serde(default)]
1776    pub subscriber_mode: bool,
1777    /// (Publisher only) Maximum number of messages in the stream (if created by the bridge). Defaults to 1,000,000.
1778    pub stream_max_messages: Option<i64>,
1779    /// (Consumer only) The delivery policy for the consumer. Defaults to "all".
1780    pub deliver_policy: Option<NatsDeliverPolicy>,
1781    /// (Publisher only) Maximum total bytes in the stream (if created by the bridge). Defaults to 1GB.
1782    pub stream_max_bytes: Option<i64>,
1783    /// (Consumer only) Number of messages to prefetch from the consumer. Defaults to 10000.
1784    pub prefetch_count: Option<usize>,
1785    /// Share one NATS client per connection (default: true); false forces a dedicated connection.
1786    #[serde(default)]
1787    #[cfg_attr(feature = "schema", schemars(default = "default_shared_schema"))]
1788    pub shared: Option<bool>,
1789}
1790
1791#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq, Eq)]
1792#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1793#[serde(rename_all = "snake_case")]
1794pub enum NatsDeliverPolicy {
1795    #[default]
1796    All,
1797    Last,
1798    New,
1799    LastPerSubject,
1800}
1801
1802impl NatsConfig {
1803    /// Creates a new NATS configuration with the specified server URL.
1804    pub fn new(url: impl Into<String>) -> Self {
1805        Self {
1806            url: url.into(),
1807            ..Default::default()
1808        }
1809    }
1810
1811    pub fn with_subject(mut self, subject: impl Into<String>) -> Self {
1812        self.subject = Some(subject.into());
1813        self
1814    }
1815
1816    pub fn with_stream(mut self, stream: impl Into<String>) -> Self {
1817        self.stream = Some(stream.into());
1818        self
1819    }
1820
1821    pub fn with_deliver_policy(mut self, policy: NatsDeliverPolicy) -> Self {
1822        self.deliver_policy = Some(policy);
1823        self
1824    }
1825
1826    pub fn with_credentials(
1827        mut self,
1828        username: impl Into<String>,
1829        password: impl Into<String>,
1830    ) -> Self {
1831        self.username = Some(username.into());
1832        self.password = Some(password.into());
1833        self
1834    }
1835}
1836
1837#[derive(Debug, Serialize, Clone, Default)]
1838#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1839#[cfg_attr(feature = "schema", schemars(transform = memory_config_schema_transform))]
1840#[serde(deny_unknown_fields)]
1841pub struct MemoryConfig {
1842    /// The topic name or transport URL. Can be:
1843    /// - Simple name: "my-topic" (defaults to memory://my-topic)
1844    /// - Memory URL: "memory://my-topic"
1845    /// - IPC URL: "ipc://my-queue" or "ipc:///path/to/socket"
1846    /// - Unix socket: "unix:///path/to/socket" (Unix only)
1847    /// - Named pipe: "pipe://my-pipe" (Windows only)
1848    ///
1849    /// Either `topic` or `url` can be specified (they are serde aliases).
1850    #[serde(default, skip_serializing_if = "String::is_empty", alias = "url")]
1851    pub topic: String,
1852    /// Transport URL (serde alias for `topic`). Use either `topic` or `url`.
1853    #[serde(skip)]
1854    pub url: Option<String>,
1855    /// The capacity of the channel. Defaults to 100.
1856    pub capacity: Option<usize>,
1857    /// (Publisher only) If true, send() waits for a response.
1858    #[serde(default)]
1859    pub request_reply: bool,
1860    /// (Publisher only) Timeout for request-reply operations in milliseconds. Defaults to 30000ms.
1861    pub request_timeout_ms: Option<u64>,
1862    /// (Consumer only) If true, act as a **Subscriber** (fan-out). Defaults to false (queue).
1863    #[serde(default)]
1864    pub subscribe_mode: bool,
1865    /// (Consumer only) If true, enables NACK support (re-queuing), which requires cloning messages.
1866    /// Defaults to false for memory:// transports, automatically true for IPC transports (ipc://, unix://, pipe://).
1867    #[serde(default)]
1868    pub enable_nack: bool,
1869    #[serde(skip)]
1870    pub enable_nack_overridden: bool,
1871}
1872
1873impl MemoryConfig {
1874    pub fn new(topic: impl Into<String>, capacity: Option<usize>) -> Self {
1875        Self {
1876            topic: topic.into(),
1877            url: None,
1878            capacity,
1879            ..Default::default()
1880        }
1881    }
1882
1883    pub fn new_with_url(url: impl Into<String>, capacity: Option<usize>) -> Self {
1884        let url = url.into();
1885        Self {
1886            topic: url.clone(),
1887            url: Some(url),
1888            capacity,
1889            ..Default::default()
1890        }
1891    }
1892
1893    pub fn with_subscribe(self, subscribe_mode: bool) -> Self {
1894        Self {
1895            subscribe_mode,
1896            ..self
1897        }
1898    }
1899
1900    pub fn with_request_reply(mut self, request_reply: bool) -> Self {
1901        self.request_reply = request_reply;
1902        self
1903    }
1904
1905    /// Gets the effective transport identifier.
1906    /// If topic contains ://, it's treated as a URL, otherwise as memory://topic.
1907    pub fn get_transport_identifier(&self) -> anyhow::Result<String> {
1908        let identifier = if !self.topic.is_empty() {
1909            &self.topic
1910        } else if let Some(url) = self.url.as_ref().filter(|url| !url.is_empty()) {
1911            url
1912        } else {
1913            return Err(anyhow::anyhow!(
1914                "MemoryConfig: 'topic' (or 'url' alias) is required."
1915            ));
1916        };
1917
1918        // If topic doesn't contain ://, treat it as memory://topic for backward compatibility
1919        if identifier.contains("://") {
1920            Ok(identifier.clone())
1921        } else {
1922            Ok(format!("memory://{}", identifier))
1923        }
1924    }
1925
1926    /// Check if the transport URL scheme suggests IPC (inter-process communication).
1927    /// IPC transports should enable nack by default for reliability.
1928    pub fn is_ipc_transport(&self) -> bool {
1929        if let Ok(identifier) = self.get_transport_identifier() {
1930            identifier.starts_with("ipc://")
1931                || identifier.starts_with("unix://")
1932                || identifier.starts_with("pipe://")
1933        } else {
1934            false
1935        }
1936    }
1937
1938    /// Apply smart defaults based on the transport type.
1939    /// For IPC transports, enable_nack defaults to true for reliability.
1940    pub fn with_smart_defaults(mut self) -> Self {
1941        if !self.enable_nack_overridden && self.is_ipc_transport() {
1942            self.enable_nack = true;
1943        }
1944        self
1945    }
1946}
1947
1948impl<'de> Deserialize<'de> for MemoryConfig {
1949    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1950    where
1951        D: Deserializer<'de>,
1952    {
1953        #[derive(Deserialize, Default)]
1954        #[serde(deny_unknown_fields)]
1955        struct MemoryConfigSerde {
1956            #[serde(default)]
1957            topic: String,
1958            #[serde(default)]
1959            url: Option<String>,
1960            capacity: Option<usize>,
1961            #[serde(default)]
1962            request_reply: bool,
1963            request_timeout_ms: Option<u64>,
1964            #[serde(default)]
1965            subscribe_mode: bool,
1966            #[serde(default)]
1967            enable_nack: Option<bool>,
1968        }
1969
1970        let raw = MemoryConfigSerde::deserialize(deserializer)?;
1971        if raw.topic.is_empty() && raw.url.as_deref().is_none_or(str::is_empty) {
1972            return Err(serde::de::Error::custom(
1973                "MemoryConfig: 'topic' (or 'url' alias) is required.",
1974            ));
1975        }
1976        let topic = if raw.topic.is_empty() {
1977            raw.url.clone().unwrap_or_default()
1978        } else {
1979            raw.topic
1980        };
1981        Ok(Self {
1982            topic,
1983            url: raw.url,
1984            capacity: raw.capacity,
1985            request_reply: raw.request_reply,
1986            request_timeout_ms: raw.request_timeout_ms,
1987            subscribe_mode: raw.subscribe_mode,
1988            enable_nack: raw.enable_nack.unwrap_or(false),
1989            enable_nack_overridden: raw.enable_nack.is_some(),
1990        })
1991    }
1992}
1993
1994#[cfg(feature = "schema")]
1995fn memory_config_schema_transform(schema: &mut schemars::Schema) {
1996    let Some(schema_obj) = schema.as_object_mut() else {
1997        return;
1998    };
1999
2000    let Some(properties) = schema_obj
2001        .get_mut("properties")
2002        .and_then(serde_json::Value::as_object_mut)
2003    else {
2004        return;
2005    };
2006
2007    properties.insert(
2008        "url".to_string(),
2009        serde_json::json!({
2010            "description": "Alias for `topic`. Use either `topic` or `url`.",
2011            "type": "string",
2012            "minLength": 1
2013        }),
2014    );
2015
2016    // Mirror the runtime check (see `MemoryConfig::deserialize`): an empty
2017    // `topic`/`url` is rejected, so the schema must require a non-empty value.
2018    if let Some(topic) = properties
2019        .get_mut("topic")
2020        .and_then(serde_json::Value::as_object_mut)
2021    {
2022        topic.insert("minLength".to_string(), serde_json::json!(1));
2023    }
2024
2025    schema_obj.insert(
2026        "anyOf".to_string(),
2027        serde_json::json!([
2028            { "required": ["topic"] },
2029            { "required": ["url"] }
2030        ]),
2031    );
2032}
2033
2034/// `null` is a unit variant, so schemars emits it as the bare string `"null"` — which can
2035/// never validate inside `Endpoint`'s object schema. Flattened, it serialises as
2036/// `{ "null": null }`; rewrite the branch to that object form.
2037#[cfg(feature = "schema")]
2038fn endpoint_schema_transform(schema: &mut schemars::Schema) {
2039    let Some(one_of) = schema
2040        .as_object_mut()
2041        .and_then(|schema_obj| schema_obj.get_mut("oneOf"))
2042        .and_then(serde_json::Value::as_array_mut)
2043    else {
2044        return;
2045    };
2046
2047    for branch in one_of.iter_mut() {
2048        if branch.get("const") == Some(&serde_json::Value::String("null".to_string())) {
2049            *branch = serde_json::json!({
2050                "type": "object",
2051                "format": "structural_endpoint",
2052                "properties": { "null": { "type": "null" } },
2053                "required": ["null"]
2054            });
2055        }
2056    }
2057}
2058
2059#[cfg(feature = "schema")]
2060fn route_schema_transform(schema: &mut schemars::Schema) {
2061    let Some(properties) = schema
2062        .as_object_mut()
2063        .and_then(|schema_obj| schema_obj.get_mut("properties"))
2064        .and_then(serde_json::Value::as_object_mut)
2065    else {
2066        return;
2067    };
2068
2069    // `output: null` (the documented "no output" form) is valid; accept an Endpoint or null.
2070    // Input stays endpoint-only.
2071    if let Some(output) = properties
2072        .get_mut("output")
2073        .and_then(serde_json::Value::as_object_mut)
2074    {
2075        let reference = output.remove("$ref");
2076        let default = output.remove("default");
2077        let description = output.remove("description");
2078        output.clear();
2079        let mut any_of = Vec::new();
2080        if let Some(reference) = reference {
2081            any_of.push(serde_json::json!({ "$ref": reference }));
2082        }
2083        any_of.push(serde_json::json!({ "type": "null" }));
2084        output.insert("anyOf".to_string(), serde_json::Value::Array(any_of));
2085        if let Some(description) = description {
2086            output.insert("description".to_string(), description);
2087        }
2088        if let Some(default) = default {
2089            output.insert("default".to_string(), default);
2090        }
2091    }
2092
2093    let Some(allow_fault_injection) = properties
2094        .get_mut("allow_fault_injection")
2095        .and_then(serde_json::Value::as_object_mut)
2096    else {
2097        return;
2098    };
2099
2100    allow_fault_injection.insert("default".to_string(), serde_json::Value::Bool(false));
2101}
2102
2103/// `schema` and `schema_file` are mutually exclusive; reject a config setting both.
2104#[cfg(feature = "schema")]
2105fn transform_middleware_schema_transform(schema: &mut schemars::Schema) {
2106    if let Some(schema_obj) = schema.as_object_mut() {
2107        // Only reject a non-null `schema_file` alongside `schema`; `schema_file: null`
2108        // is allowed with `schema`, matching the runtime compiler (Option is None).
2109        schema_obj.insert(
2110            "not".to_string(),
2111            serde_json::json!({
2112                "required": ["schema", "schema_file"],
2113                "properties": { "schema_file": { "type": "string" } }
2114            }),
2115        );
2116    }
2117}
2118
2119/// Configuration for the correlated in-process stream response buffer.
2120#[derive(Debug, Serialize, Deserialize, Clone, Default)]
2121#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2122#[serde(deny_unknown_fields)]
2123pub struct StreamBufferConfig {
2124    /// Shared buffer topic used by both the publisher and correlated consumers.
2125    pub topic: String,
2126    /// Consumer-only correlation id partition to read from.
2127    ///
2128    /// Leave this unset for the publisher endpoint configured in
2129    /// `HttpConfig::stream_response_to`. Set it on consumers so a reader only
2130    /// receives messages belonging to one request or response stream.
2131    #[serde(default, skip_serializing_if = "Option::is_none")]
2132    pub correlation_id: Option<String>,
2133    /// Capacity of each correlation partition. Defaults to 100.
2134    #[serde(default, skip_serializing_if = "Option::is_none")]
2135    pub capacity: Option<usize>,
2136}
2137
2138impl StreamBufferConfig {
2139    /// Creates a `stream_buffer` config for the given topic.
2140    ///
2141    /// Add `with_correlation_id` when constructing a consumer for one stream.
2142    /// Leave the correlation id unset when constructing the publisher buffer
2143    /// used by `HttpConfig::stream_response_to`.
2144    pub fn new(topic: impl Into<String>) -> Self {
2145        Self {
2146            topic: topic.into(),
2147            ..Default::default()
2148        }
2149    }
2150
2151    /// Selects the response stream partition that a consumer should read.
2152    pub fn with_correlation_id(mut self, correlation_id: impl Into<String>) -> Self {
2153        self.correlation_id = Some(correlation_id.into());
2154        self
2155    }
2156
2157    /// Sets the per-correlation partition capacity.
2158    pub fn with_capacity(mut self, capacity: usize) -> Self {
2159        self.capacity = Some(capacity);
2160        self
2161    }
2162}
2163
2164// --- AMQP Specific Configuration ---
2165
2166/// General AMQP connection configuration.
2167#[derive(Debug, Deserialize, Serialize, Clone, Default)]
2168#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2169#[serde(deny_unknown_fields)]
2170pub struct AmqpConfig {
2171    /// AMQP connection URI. The `lapin` client connects to a single host specified in the URI. If it contains userinfo, it will be treated as a secret.
2172    /// For high availability, provide the address of a load balancer or use DNS resolution
2173    /// that points to multiple brokers. Example: "amqp://localhost:5672/vhost".
2174    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2175    pub url: String,
2176    /// The AMQP queue name.
2177    pub queue: Option<String>,
2178    /// (Consumer only) If true, act as a **Subscriber** (fan-out). Defaults to false.
2179    #[serde(default)]
2180    pub subscribe_mode: bool,
2181    /// Optional username for authentication.
2182    pub username: Option<String>,
2183    /// Optional password for authentication.
2184    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2185    pub password: Option<String>,
2186    /// TLS configuration.
2187    #[serde(default)]
2188    pub tls: TlsConfig,
2189    /// The exchange to publish to or bind the queue to.
2190    pub exchange: Option<String>,
2191    /// (Consumer only) Number of messages to prefetch. Defaults to 100.
2192    pub prefetch_count: Option<u16>,
2193    /// If true, declare queues as non-durable (transient). Defaults to false. Affects both Consumer (queue durability) and Publisher (message persistence).
2194    #[serde(default)]
2195    pub no_persistence: bool,
2196    /// (Publisher only) If true, do not attempt to declare the queue. Assumes the queue already exists. Defaults to false.
2197    #[serde(default)]
2198    pub no_declare_queue: bool,
2199    /// (Publisher only) If true, do not wait for an acknowledgement when sending to broker. Defaults to false.
2200    #[serde(default)]
2201    pub delayed_ack: bool,
2202}
2203
2204impl AmqpConfig {
2205    /// Creates a new AMQP configuration with the specified connection URL.
2206    pub fn new(url: impl Into<String>) -> Self {
2207        Self {
2208            url: url.into(),
2209            ..Default::default()
2210        }
2211    }
2212
2213    pub fn with_queue(mut self, queue: impl Into<String>) -> Self {
2214        self.queue = Some(queue.into());
2215        self
2216    }
2217
2218    pub fn with_exchange(mut self, exchange: impl Into<String>) -> Self {
2219        self.exchange = Some(exchange.into());
2220        self
2221    }
2222
2223    pub fn with_credentials(
2224        mut self,
2225        username: impl Into<String>,
2226        password: impl Into<String>,
2227    ) -> Self {
2228        self.username = Some(username.into());
2229        self.password = Some(password.into());
2230        self
2231    }
2232}
2233
2234/// MongoDB message storage format.
2235///
2236/// Determines how messages are stored and retrieved from MongoDB collections.
2237#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq, Eq)]
2238#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2239#[serde(rename_all = "lowercase")]
2240pub enum MongoDbFormat {
2241    #[default]
2242    Normal,
2243    Json,
2244    Text,
2245    Raw,
2246}
2247
2248/// How a MongoDB endpoint consumes a collection. One intent-named selector — the bridge picks the
2249/// underlying mechanism (change stream vs. polling) automatically. Defaults to `consumer`.
2250#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default, PartialEq, Eq)]
2251#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2252#[serde(rename_all = "snake_case")]
2253pub enum MongoConsume {
2254    /// **Queue** — competing consumers: claim, process, delete, so each document goes to exactly one
2255    /// reader. Default. Destructive and ~5x slower than `capture_all`; for jobs, not bulk reads.
2256    #[default]
2257    Consumer,
2258    /// **Queue, ephemeral** — receive only new messages, no durable position (fan-out subscriber).
2259    Subscriber,
2260    /// **Watch existing collection** — capture changes from now on (insert/update/delete), resuming
2261    /// under `cursor_id`. Reads an existing collection non-destructively; never ends on drain.
2262    CaptureNew,
2263    /// **Watch existing collection** — read the existing documents first, then capture changes.
2264    /// Non-destructive and the fastest read mode; use this for bulk reads and ETL.
2265    CaptureAll,
2266}
2267
2268// --- MongoDB Specific Configuration ---
2269
2270/// General MongoDB connection configuration.
2271#[derive(Debug, Deserialize, Serialize, Clone, Default)]
2272#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2273#[serde(deny_unknown_fields)]
2274pub struct MongoDbConfig {
2275    /// MongoDB connection string URI. Can contain a comma-separated list of hosts for a replica set. If it contains userinfo, it will be treated as a secret.
2276    /// Credentials provided via the separate `username` and `password` fields take precedence over any credentials embedded in the URL.
2277    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2278    pub url: String,
2279    /// The MongoDB collection name.
2280    pub collection: Option<String>,
2281    /// Optional username. Takes precedence over any credentials embedded in the `url`.
2282    /// Use embedded URL credentials for simple one-off connections but prefer explicit username/password fields (or environment-sourced secrets) for clarity and secret management in production.
2283    pub username: Option<String>,
2284    /// Optional password. Takes precedence over any credentials embedded in the `url`.
2285    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2286    /// Use embedded URL credentials for simple one-off connections but prefer explicit username/password fields (or environment-sourced secrets) for clarity and secret management in production.
2287    pub password: Option<String>,
2288    /// TLS configuration.
2289    #[serde(default)]
2290    pub tls: TlsConfig,
2291    /// The database name.
2292    pub database: String,
2293    /// (Consumer only) Polling interval in milliseconds for the consumer (when not using Change Streams). Defaults to 100ms.
2294    pub polling_interval_ms: Option<u64>,
2295    /// (Publisher only) Polling interval in milliseconds for the publisher when waiting for a reply. Defaults to 50ms.
2296    pub reply_polling_ms: Option<u64>,
2297    /// (Publisher only) If true, the publisher will wait for a response in a dedicated collection. Defaults to false.
2298    #[serde(default)]
2299    pub request_reply: bool,
2300    /// (Consumer only) How to consume the collection: `consumer` (default, competing-consumers work
2301    /// queue — destructive and ~5x slower), `subscriber` (ephemeral queue), `capture_new` (watch an
2302    /// existing collection for changes), or `capture_all` (read existing documents first, then watch
2303    /// for changes — use this for single-reader bulk reads and ETL). The bridge selects the
2304    /// underlying mechanism automatically. If unset, the deprecated `change_stream` boolean is
2305    /// honored for backward compatibility.
2306    pub consume: Option<MongoConsume>,
2307    /// (Consumer only) Optional custom MongoDB query to filter messages. Provided as a JSON string (e.g., '{"type": "notification"}').
2308    pub receive_query: Option<String>,
2309    /// (Consumer only) **Deprecated** — use `consume: subscriber`. Kept for compatibility.
2310    #[serde(default)]
2311    pub change_stream: bool,
2312    /// (Consumer only) Where to persist the resume cursor in `capture_new`/`capture_all` mode. A URL
2313    /// selects the backend; a bare name (or `/name`) reuses the **source** database with that name:
2314    /// - absent → source database, collection `mqb_cursors_<source_collection>` (auto-unique)
2315    /// - `/my_cursors` → source database, collection `my_cursors`
2316    /// - `file:///var/lib/mqb/cursors.json` → local JSON file (read-only / write-restricted sources)
2317    /// - `mongodb://host/db/collection` → external MongoDB collection (collection optional)
2318    /// - `postgres://user@host/db/table` or `mysql://host/db/table` → external SQL table (table optional)
2319    /// - `s3://bucket/prefix` (also `gs://`, `az://`, `abfs://`) → cloud object store; creds via env
2320    ///
2321    /// When no collection/table is named, it defaults to `mqb_cursors_<source_collection>`.
2322    /// May embed connection credentials, so it is treated as a secret.
2323    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2324    pub checkpoint_store: Option<String>,
2325    /// (Publisher only) Timeout for request-reply operations in milliseconds. Defaults to 30000ms.
2326    pub request_timeout_ms: Option<u64>,
2327    /// (Publisher only) TTL in seconds for documents created by the publisher. If set, a TTL index is created.
2328    pub ttl_seconds: Option<u64>,
2329    /// (Publisher only) If set, creates a capped collection with this size in bytes.
2330    pub capped_size_bytes: Option<i64>,
2331    /// Format for storing messages. Defaults to Normal.
2332    #[serde(default)]
2333    pub format: MongoDbFormat,
2334    /// (Publisher only) Top-level payload field whose value becomes the document `_id`, for
2335    /// idempotent inserts via the unique `_id` index. Sink collections only.
2336    pub id_field: Option<String>,
2337    /// (Publisher only) Return the message with metadata `mongodb.outcome` = `inserted`/`existed`
2338    /// (dup-key) so a `request`+`switch` can branch. Sink collections only; pair with `id_field`.
2339    #[serde(default)]
2340    pub report_outcome: bool,
2341    /// The ID used for the cursor in sequenced mode. If not provided, consumption starts from the current sequence (ephemeral).
2342    pub cursor_id: Option<String>,
2343    /// (Optional) Collection to store sequence counters and cursor positions. Defaults to the message collection if not set.
2344    pub meta_collection: Option<String>,
2345    /// Share one MongoDB client per connection (default: true); false forces a dedicated client.
2346    #[serde(default)]
2347    #[cfg_attr(feature = "schema", schemars(default = "default_shared_schema"))]
2348    pub shared: Option<bool>,
2349}
2350
2351impl MongoDbConfig {
2352    /// Creates a new MongoDB configuration with the specified URL and database name.
2353    pub fn new(url: impl Into<String>, database: impl Into<String>) -> Self {
2354        Self {
2355            url: url.into(),
2356            database: database.into(),
2357            ..Default::default()
2358        }
2359    }
2360
2361    pub fn with_collection(mut self, collection: impl Into<String>) -> Self {
2362        self.collection = Some(collection.into());
2363        self
2364    }
2365
2366    pub fn with_credentials(
2367        mut self,
2368        username: impl Into<String>,
2369        password: impl Into<String>,
2370    ) -> Self {
2371        self.username = Some(username.into());
2372        self.password = Some(password.into());
2373        self
2374    }
2375
2376    pub fn with_change_stream(mut self, change_stream: bool) -> Self {
2377        self.change_stream = change_stream;
2378        self
2379    }
2380
2381    /// The effective consume mode: the explicit `consume` field if set, otherwise derived from the
2382    /// deprecated `change_stream` boolean.
2383    pub fn resolved_consume(&self) -> MongoConsume {
2384        if let Some(mode) = self.consume {
2385            return mode;
2386        }
2387        if self.change_stream {
2388            MongoConsume::Subscriber
2389        } else {
2390            MongoConsume::Consumer
2391        }
2392    }
2393}
2394
2395// --- MQTT Specific Configuration ---
2396
2397/// General MQTT connection configuration.
2398#[derive(Debug, Deserialize, Serialize, Clone, Default)]
2399#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2400#[serde(deny_unknown_fields)]
2401pub struct MqttConfig {
2402    /// MQTT broker URL (e.g., "tcp://localhost:1883"). Does not support multiple hosts. If it contains userinfo, it will be treated as a secret.
2403    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2404    pub url: String,
2405    /// The MQTT topic.
2406    pub topic: Option<String>,
2407    /// Optional username for authentication.
2408    pub username: Option<String>,
2409    /// Optional password for authentication.
2410    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2411    pub password: Option<String>,
2412    /// TLS configuration.
2413    #[serde(default)]
2414    pub tls: TlsConfig,
2415    /// Optional client ID. If not provided, one is generated or derived from route name.
2416    pub client_id: Option<String>,
2417    /// Capacity of the internal channel for incoming messages. Defaults to 100.
2418    pub queue_capacity: Option<usize>,
2419    /// Maximum number of inflight messages.
2420    pub max_inflight: Option<u16>,
2421    /// Quality of Service level (0, 1, or 2). Defaults to 1.
2422    pub qos: Option<u8>,
2423    /// (Consumer only) If true, start with a clean session. Defaults to false (persistent session). Setting this to true effectively enables **Subscriber mode** (ephemeral).
2424    #[serde(default = "default_clean_session")]
2425    pub clean_session: bool,
2426    /// Keep-alive interval in seconds. Defaults to 20.
2427    pub keep_alive_seconds: Option<u64>,
2428    /// MQTT protocol version (V3 or V5). Defaults to V5.
2429    #[serde(default)]
2430    pub protocol: MqttProtocol,
2431    /// Session expiry interval in seconds (MQTT v5 only).
2432    pub session_expiry_interval: Option<u32>,
2433    /// (Consumer only) If true, messages are acknowledged immediately upon receipt (auto-ack).
2434    /// If false (default), messages are acknowledged after processing (manual-ack).
2435    /// Note: For QoS 1/2 the publisher always waits for end-to-end broker
2436    /// confirmation (PUBACK/PUBCOMP) before reporting success, independent of
2437    /// this setting; QoS 0 remains fire-and-forget.
2438    #[serde(default)]
2439    pub delayed_ack: bool,
2440}
2441
2442impl MqttConfig {
2443    /// Creates a new MQTT configuration with the specified broker URL.
2444    pub fn new(url: impl Into<String>) -> Self {
2445        Self {
2446            url: url.into(),
2447            ..Default::default()
2448        }
2449    }
2450
2451    pub fn with_topic(mut self, topic: impl Into<String>) -> Self {
2452        self.topic = Some(topic.into());
2453        self
2454    }
2455
2456    pub fn with_client_id(mut self, client_id: impl Into<String>) -> Self {
2457        self.client_id = Some(client_id.into());
2458        self
2459    }
2460
2461    pub fn with_credentials(
2462        mut self,
2463        username: impl Into<String>,
2464        password: impl Into<String>,
2465    ) -> Self {
2466        self.username = Some(username.into());
2467        self.password = Some(password.into());
2468        self
2469    }
2470}
2471
2472/// MQTT protocol version.
2473///
2474/// Specifies which version of the MQTT protocol to use for connections.
2475#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
2476#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2477#[serde(rename_all = "lowercase")]
2478pub enum MqttProtocol {
2479    #[default]
2480    V5,
2481    V3,
2482}
2483
2484// --- ZeroMQ Specific Configuration ---
2485
2486#[derive(Debug, Deserialize, Serialize, Clone, Default)]
2487#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2488#[serde(deny_unknown_fields)]
2489pub struct ZeroMqConfig {
2490    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2491    /// The ZeroMQ URL (e.g., "tcp://127.0.0.1:5555").
2492    pub url: String,
2493    /// The socket type (PUSH, PULL, PUB, SUB, REQ, REP).
2494    #[serde(default)]
2495    pub socket_type: Option<ZeroMqSocketType>,
2496    /// (Consumer only) The ZeroMQ topic (for SUB sockets).
2497    pub topic: Option<String>,
2498    /// If true, bind to the address. If false, connect.
2499    #[serde(default)]
2500    pub bind: bool,
2501    /// Internal buffer size for the channel. Defaults to 128.
2502    #[serde(default)]
2503    pub internal_buffer_size: Option<usize>,
2504    /// Wire format: `json` wraps the CanonicalMessage; `raw` sends payload bytes per frame; `raw_framed` adds a JSON metadata frame. Default `json`.
2505    #[serde(default)]
2506    pub format: ZeroMqFormat,
2507    /// Backend: `zmq` (default, the `zeromq` crate) or `omq` (the `omq-tokio` PoC — PUSH/PULL + PUB/SUB only). `omq` needs the `zeromq-omq` build feature.
2508    #[serde(default)]
2509    pub backend: ZeroMqBackend,
2510    /// (REQ publisher only) Timeout in ms for one request/reply exchange before it is reported as failed. Defaults to 30000.
2511    #[serde(default)]
2512    pub request_timeout_ms: Option<u64>,
2513}
2514
2515impl ZeroMqConfig {
2516    /// Creates a new ZeroMQ configuration with the specified URL.
2517    pub fn new(url: impl Into<String>) -> Self {
2518        Self {
2519            url: url.into(),
2520            ..Default::default()
2521        }
2522    }
2523
2524    pub fn with_socket_type(mut self, socket_type: ZeroMqSocketType) -> Self {
2525        self.socket_type = Some(socket_type);
2526        self
2527    }
2528
2529    pub fn with_bind(mut self, bind: bool) -> Self {
2530        self.bind = bind;
2531        self
2532    }
2533}
2534
2535/// ZeroMQ wire format.
2536///
2537/// `json` wraps each message as a JSON CanonicalMessage (batched into one frame);
2538/// `raw` sends/receives the payload bytes directly, one frame per message (metadata
2539/// is not transmitted); `raw_framed` sends a two-frame message — a JSON metadata frame
2540/// followed by the raw payload frame — keeping the payload binary-safe while still
2541/// carrying headers. Use `raw`/`raw_framed` for binary feeds such as JPEG, Avro or Protobuf.
2542#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq, Eq)]
2543#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2544#[serde(rename_all = "snake_case")]
2545pub enum ZeroMqFormat {
2546    #[default]
2547    Json,
2548    Raw,
2549    RawFramed,
2550}
2551
2552/// ZeroMQ socket type.
2553///
2554/// Defines the messaging pattern for ZeroMQ connections.
2555/// Different patterns support different communication paradigms (request-reply, publish-subscribe, etc.).
2556#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
2557#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2558#[serde(rename_all = "lowercase")]
2559pub enum ZeroMqSocketType {
2560    Push,
2561    Pull,
2562    Pub,
2563    Sub,
2564    Req,
2565    Rep,
2566}
2567
2568/// ZeroMQ backend implementation.
2569///
2570/// `zmq` (default) uses the `zeromq` crate (pure-Rust zmq.rs). `omq` uses
2571/// `omq-tokio` (omq.rs) — much faster on the per-message `raw`/`raw_framed`
2572/// path and adds CURVE/PLAIN security, but currently covers PUSH/PULL + PUB/SUB
2573/// only and requires the `zeromq-omq` build feature (MSRV 1.93).
2574#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq, Eq)]
2575#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2576#[serde(rename_all = "lowercase")]
2577pub enum ZeroMqBackend {
2578    #[default]
2579    Zmq,
2580    Omq,
2581}
2582
2583// --- Redis Streams Specific Configuration ---
2584
2585/// Configuration for a Redis Streams endpoint.
2586///
2587/// Publishers `XADD` to the stream; consumers read via a consumer group
2588/// (`XREADGROUP` + `XACK`) by default, or ephemerally via `XREAD` from new
2589/// messages when `subscriber_mode` is set.
2590#[derive(Debug, Deserialize, Serialize, Clone, Default)]
2591#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2592#[serde(deny_unknown_fields)]
2593pub struct RedisStreamsConfig {
2594    /// Redis URL, `redis://` or `rediss://` for TLS. Userinfo is treated as a secret.
2595    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2596    pub url: String,
2597    /// The stream key to publish to or read from. Defaults to the route name.
2598    pub stream: Option<String>,
2599    /// (Consumer) Group name. Defaults to `{APP_NAME}-{stream}`; ignored in `subscriber_mode`.
2600    pub group: Option<String>,
2601    /// (Consumer) Consumer name within the group. Defaults to a unique per-instance id.
2602    pub consumer_name: Option<String>,
2603    /// (Consumer) Read ephemerally via `XREAD` from new messages (no group/acks). Default false.
2604    #[serde(default)]
2605    pub subscriber_mode: bool,
2606    /// (Consumer) Block timeout in milliseconds for each read. Defaults to 5000ms.
2607    pub block_ms: Option<u64>,
2608    /// (Consumer) On group creation, start from the stream beginning ("0") not "$". Default false.
2609    #[serde(default)]
2610    pub read_from_start: bool,
2611    /// (Consumer) Redeliver entries pending ≥ this long via `XAUTOCLAIM`; 0 disables. Default 60000ms.
2612    pub redelivery_timeout_ms: Option<u64>,
2613    /// (Publisher) If set, cap the stream length with `XADD MAXLEN`.
2614    pub maxlen: Option<usize>,
2615    /// (Publisher) Use approximate (`~`) trimming when `maxlen` is set. Defaults to true.
2616    pub approx_trim: Option<bool>,
2617    /// Optional username for authentication (Redis ACL).
2618    pub username: Option<String>,
2619    /// Optional password for authentication.
2620    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2621    pub password: Option<String>,
2622    /// Internal buffer size for the consumer channel. Defaults to 128.
2623    pub internal_buffer_size: Option<usize>,
2624    /// (Consumer) Parallel `XREADGROUP` reader connections fanned out across the group. Default 1.
2625    /// Ignored in `subscriber_mode`.
2626    pub reader_connections: Option<usize>,
2627}
2628
2629impl RedisStreamsConfig {
2630    /// Creates a new Redis Streams configuration with the specified URL.
2631    pub fn new(url: impl Into<String>) -> Self {
2632        Self {
2633            url: url.into(),
2634            ..Default::default()
2635        }
2636    }
2637
2638    pub fn with_stream(mut self, stream: impl Into<String>) -> Self {
2639        self.stream = Some(stream.into());
2640        self
2641    }
2642
2643    pub fn with_group(mut self, group: impl Into<String>) -> Self {
2644        self.group = Some(group.into());
2645        self
2646    }
2647
2648    pub fn with_subscriber(mut self, subscriber: bool) -> Self {
2649        self.subscriber_mode = subscriber;
2650        self
2651    }
2652
2653    pub fn with_reader_connections(mut self, connections: usize) -> Self {
2654        self.reader_connections = Some(connections);
2655        self
2656    }
2657}
2658
2659// --- gRPC Specific Configuration ---
2660
2661#[derive(Debug, Deserialize, Serialize, Clone, Default)]
2662#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2663#[serde(deny_unknown_fields)]
2664pub struct GrpcConfig {
2665    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2666    /// The gRPC server URL (e.g., "http://localhost:50051" for client or "0.0.0.0:50051" for server mode).
2667    pub url: String,
2668    /// Topic / subject used for both subscribe and publish paths.
2669    pub topic: Option<String>,
2670    /// Timeout in milliseconds.
2671    /// - Client mode: used as the connection timeout and per-request deadline.
2672    /// - Server mode: applied as the per-request deadline on the embedded server.
2673    pub timeout_ms: Option<u64>,
2674    /// TLS configuration.
2675    #[serde(default)]
2676    pub tls: TlsConfig,
2677    /// If `true`, start an embedded tonic gRPC server that accepts incoming `Publish` /
2678    /// `PublishBatch` RPCs. If `false` (the default), connect to a remote server as a client.
2679    #[serde(default)]
2680    pub server_mode: bool,
2681    /// HTTP/2 stream-level initial window size in bytes. **Server-mode only.**
2682    #[serde(default)]
2683    pub initial_stream_window_size: Option<u32>,
2684    /// HTTP/2 connection-level initial window size in bytes. **Server-mode only.**
2685    #[serde(default)]
2686    pub initial_connection_window_size: Option<u32>,
2687    /// Maximum number of concurrent requests handled per connection. **Server-mode only.**
2688    #[serde(default)]
2689    pub concurrency_limit_per_connection: Option<usize>,
2690    /// HTTP/2 keepalive ping interval in milliseconds. **Server-mode only.** Default disabled
2691    #[serde(default)]
2692    pub http2_keepalive_interval_ms: Option<u64>,
2693    /// Timeout for a keepalive ping acknowledgement in milliseconds. **Server-mode only.**
2694    #[serde(default)]
2695    pub http2_keepalive_timeout_ms: Option<u64>,
2696    /// Maximum size of a decoded incoming message in bytes. **Server-mode only.** Default 4 MiB.
2697    #[serde(default)]
2698    pub max_decoding_message_size: Option<usize>,
2699    /// (Publisher only) Share one gRPC channel per connection (default: true); false forces a dedicated channel.
2700    #[serde(default)]
2701    #[cfg_attr(feature = "schema", schemars(default = "default_shared_schema"))]
2702    pub shared: Option<bool>,
2703}
2704
2705impl GrpcConfig {
2706    /// Creates a new gRPC configuration with the specified server URL.
2707    pub fn new(url: impl Into<String>) -> Self {
2708        Self {
2709            url: url.into(),
2710            ..Default::default()
2711        }
2712    }
2713
2714    pub fn with_topic(mut self, topic: impl Into<String>) -> Self {
2715        self.topic = Some(topic.into());
2716        self
2717    }
2718
2719    /// Enable or disable server mode for this gRPC endpoint.
2720    pub fn with_server_mode(mut self, server_mode: bool) -> Self {
2721        self.server_mode = server_mode;
2722        self
2723    }
2724}
2725
2726// --- HTTP Specific Configuration ---
2727
2728/// Supported inbound HTTP protocols for server listeners.
2729#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Hash, Default)]
2730#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2731#[serde(rename_all = "snake_case")]
2732pub enum HttpServerProtocol {
2733    /// Accept both HTTP/1.1 and HTTP/2, matching the current default behavior.
2734    #[default]
2735    Auto,
2736    /// Accept only HTTP/1.x connections.
2737    Http1Only,
2738    /// Accept only HTTP/2 connections.
2739    Http2Only,
2740}
2741
2742/// WebSocket route execution strategy.
2743#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default, PartialEq, Eq)]
2744#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2745#[serde(rename_all = "snake_case")]
2746pub enum WebSocketExecutionMode {
2747    /// Use direct per-connection handling for simple `websocket -> response` routes and fall back
2748    /// to the routed adapter with a warning when route semantics need the normal pipeline.
2749    #[default]
2750    Auto,
2751    /// Require direct per-connection handling. Startup fails if the route cannot run directly.
2752    DirectOnly,
2753    /// Always use the normal routed consumer/worker/disposition pipeline.
2754    Routed,
2755}
2756
2757/// General HTTP connection configuration.
2758#[derive(Debug, Deserialize, Serialize, Clone, Default)]
2759#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2760#[serde(deny_unknown_fields)]
2761pub struct HttpConfig {
2762    /// For consumers, the listen address (e.g., "0.0.0.0:8080"). For publishers, the target URL.
2763    pub url: String,
2764    /// (Consumer only) Optional request path filter. If set, only requests whose URI path matches exactly are delivered to this consumer.
2765    pub path: Option<String>,
2766    /// (Optional) HTTP method. For publishers: the method to use (defaults to POST). For consumers: restrict to this method (others return 405).
2767    pub method: Option<String>,
2768    /// TLS configuration.
2769    #[serde(default)]
2770    pub tls: TlsConfig,
2771    /// (Consumer only) Number of worker threads to use. Defaults to 0 for unlimited.
2772    pub workers: Option<usize>,
2773    /// (Consumer only) Header key to extract the message ID from. Defaults to "message-id".
2774    pub message_id_header: Option<String>,
2775    /// Timeout for HTTP requests in milliseconds. For consumers, it's the request-reply timeout. For publishers, it's the timeout for each individual request. Defaults to 30000ms.
2776    pub request_timeout_ms: Option<u64>,
2777    /// (Consumer only) Internal buffer size for the channel. Defaults to 100.
2778    pub internal_buffer_size: Option<usize>,
2779    /// (Consumer only) If true, respond immediately with 202 Accepted without waiting for downstream processing. Defaults to false.
2780    #[serde(default)]
2781    pub fire_and_forget: bool,
2782    /// (Consumer only) If true, read request bodies as a stream and emit each received stream item as a separate message.
2783    #[serde(default)]
2784    pub receive_streamable: bool,
2785    /// (Consumer only) If true, compatible `http -> response` routes may bypass the normal route consumer/worker/disposition pipeline
2786    /// and reply inline for lower latency. Defaults to true. Set to false to force the normal route path.
2787    #[serde(default, skip_serializing_if = "Option::is_none")]
2788    #[cfg_attr(
2789        feature = "schema",
2790        schemars(default = "default_inline_response_fast_path_schema")
2791    )]
2792    pub inline_response_fast_path: Option<bool>,
2793    /// (Consumer only) Restrict which HTTP protocol versions a server listener accepts.
2794    /// Defaults to `auto` (HTTP/1.1 + HTTP/2). On cleartext listeners, `http2_only`
2795    /// means HTTP/2 prior-knowledge (h2c) only.
2796    #[serde(default)]
2797    pub server_protocol: HttpServerProtocol,
2798    /// (Publisher only) Optional endpoint that receives streamed HTTP response items as correlated messages.
2799    ///
2800    /// Use a `stream_buffer` endpoint here when callers need to read streamed
2801    /// response items later through a normal mq-bridge consumer. Each streamed
2802    /// item is published with `correlation_id`, `http_stream_id`,
2803    /// `http_stream_index`, `http_stream_format`, and `http_stream_end`
2804    /// metadata. If the request message has no `correlation_id`, the HTTP
2805    /// publisher uses `format!("{:032x}", request.message_id)` so callers can
2806    /// derive the consumer correlation id before calling `send`.
2807    #[serde(default, skip_serializing_if = "Option::is_none")]
2808    pub stream_response_to: Option<Box<Endpoint>>,
2809    /// (Publisher only) The number of concurrent HTTP requests to send in a batch. Defaults to 20.
2810    #[serde(default, skip_serializing_if = "Option::is_none")]
2811    pub batch_concurrency: Option<usize>,
2812    /// (Publisher only) TCP keepalive timeout for the underlying connection pool in milliseconds. Defaults to 60000ms.
2813    #[serde(default, skip_serializing_if = "Option::is_none")]
2814    pub tcp_keepalive_ms: Option<u64>,
2815    /// (Publisher only) Timeout for idle connections in the connection pool in milliseconds. Defaults to 90000ms.
2816    #[serde(default, skip_serializing_if = "Option::is_none")]
2817    pub pool_idle_timeout_ms: Option<u64>,
2818    /// (Publisher only) Codec for the request body (`none`, `gzip`, `lz4`, `zstd`); overrides
2819    /// `compression_enabled`. `lz4` is non-standard (mq-bridge peers only). Ignored on a consumer —
2820    /// enable response compression with `compression_enabled`. Defaults to `none`.
2821    #[serde(default)]
2822    pub compression: Compression,
2823    /// Turns compression on. Publisher: compress the request body with gzip (unless `compression`
2824    /// sets another codec). Consumer: compress responses, negotiating the best codec the client's
2825    /// `Accept-Encoding` accepts. Defaults to off.
2826    #[serde(default, skip_serializing_if = "Option::is_none")]
2827    pub compression_enabled: Option<bool>,
2828    /// Minimum message size in bytes to compress. Messages smaller than this are sent uncompressed. Defaults to 1024 bytes.
2829    #[serde(default)]
2830    pub compression_threshold_bytes: Option<usize>,
2831    /// (Consumer only) Maximum number of concurrent requests to handle. Defaults to 100.
2832    pub concurrency_limit: Option<usize>,
2833    /// HTTP Basic Authentication credentials (username, password). For consumers: validates incoming requests. For publishers: adds Authorization header.
2834    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2835    #[serde(
2836        default,
2837        skip_serializing_if = "Option::is_none",
2838        deserialize_with = "deserialize_basic_auth"
2839    )]
2840    pub basic_auth: Option<(String, String)>,
2841    /// Custom headers as key-value pairs (e.g., {"X-API-Key": "token123"}). Added to outgoing HTTP headers for both consumers and publishers.
2842    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2843    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2844    pub custom_headers: HashMap<String, String>,
2845    /// (Publisher only) Share one HTTP client per connection (default: true); false forces a dedicated client.
2846    #[serde(default)]
2847    #[cfg_attr(feature = "schema", schemars(default = "default_shared_schema"))]
2848    pub shared: Option<bool>,
2849}
2850
2851/// WebSocket connection configuration.
2852#[derive(Debug, Deserialize, Serialize, Clone, Default)]
2853#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2854#[serde(deny_unknown_fields)]
2855pub struct WebSocketConfig {
2856    /// For consumers, the listen address (e.g. "0.0.0.0:9000"). For publishers, the target URL.
2857    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2858    pub url: String,
2859    /// (Consumer only) Optional request path filter. If set, only upgrade requests whose URI path matches exactly are delivered to this consumer.
2860    pub path: Option<String>,
2861    /// (Consumer only) Header key to extract the message ID from the WebSocket handshake. Defaults to "message-id".
2862    pub message_id_header: Option<String>,
2863    /// (Consumer only) Queue capacity for the routed adapter. Direct response routes do not use this queue. Defaults to 100.
2864    pub routed_queue_capacity: Option<usize>,
2865    /// (Consumer only) TCP listen backlog (pending-connection queue depth) for the accept socket.
2866    /// Raise this if high-concurrency handshake bursts are being dropped/reset before `accept()`
2867    /// can keep up. Defaults to 4096, which is higher than the OS/tokio default of 1024.
2868    pub backlog: Option<u32>,
2869    /// (Consumer only) Selects whether WebSocket routes run directly or through the routed pipeline.
2870    #[serde(default)]
2871    pub execution_mode: WebSocketExecutionMode,
2872}
2873
2874fn deserialize_basic_auth<'de, D>(deserializer: D) -> Result<Option<(String, String)>, D::Error>
2875where
2876    D: Deserializer<'de>,
2877{
2878    let val = serde_json::Value::deserialize(deserializer)?;
2879    match val {
2880        serde_json::Value::Null => Ok(None),
2881        serde_json::Value::Array(arr) => {
2882            if arr.len() != 2 {
2883                return Err(serde::de::Error::custom("basic_auth must have 2 elements"));
2884            }
2885            let u = arr[0]
2886                .as_str()
2887                .ok_or_else(|| serde::de::Error::custom("basic_auth[0] must be string"))?
2888                .to_string();
2889            let p = arr[1]
2890                .as_str()
2891                .ok_or_else(|| serde::de::Error::custom("basic_auth[1] must be string"))?
2892                .to_string();
2893            Ok(Some((u, p)))
2894        }
2895        serde_json::Value::Object(map) => {
2896            let u = map
2897                .get("0")
2898                .and_then(|v| v.as_str())
2899                .ok_or_else(|| serde::de::Error::custom("basic_auth map missing '0'"))?
2900                .to_string();
2901            let p = map
2902                .get("1")
2903                .and_then(|v| v.as_str())
2904                .ok_or_else(|| serde::de::Error::custom("basic_auth map missing '1'"))?
2905                .to_string();
2906            Ok(Some((u, p)))
2907        }
2908        _ => Err(serde::de::Error::custom("invalid type for basic_auth")),
2909    }
2910}
2911
2912impl HttpConfig {
2913    /// Creates a new HTTP configuration with the specified URL.
2914    pub fn new(url: impl Into<String>) -> Self {
2915        Self {
2916            url: url.into(),
2917            ..Default::default()
2918        }
2919    }
2920
2921    pub fn with_workers(mut self, workers: usize) -> Self {
2922        self.workers = Some(workers);
2923        self
2924    }
2925
2926    pub fn with_method(mut self, method: impl Into<String>) -> Self {
2927        self.method = Some(method.into());
2928        self
2929    }
2930
2931    pub fn with_path(mut self, path: impl Into<String>) -> Self {
2932        self.path = Some(path.into());
2933        self
2934    }
2935
2936    pub fn with_receive_streamable(mut self, receive_streamable: bool) -> Self {
2937        self.receive_streamable = receive_streamable;
2938        self
2939    }
2940
2941    pub fn with_inline_response_fast_path(mut self, inline_response_fast_path: bool) -> Self {
2942        self.inline_response_fast_path = Some(inline_response_fast_path);
2943        self
2944    }
2945
2946    pub fn with_server_protocol(mut self, server_protocol: HttpServerProtocol) -> Self {
2947        self.server_protocol = server_protocol;
2948        self
2949    }
2950
2951    pub fn inline_response_fast_path_enabled(&self) -> bool {
2952        self.inline_response_fast_path.unwrap_or(true)
2953    }
2954
2955    /// Request-body codec for a publisher: explicit `compression`, else gzip when
2956    /// `compression_enabled`, else none.
2957    pub fn publisher_compression(&self) -> Compression {
2958        match self.compression {
2959            Compression::None if self.compression_enabled == Some(true) => Compression::Gzip,
2960            other => other,
2961        }
2962    }
2963
2964    /// Whether a consumer compresses responses (then it negotiates the best codec the client
2965    /// accepts). Driven by `compression_enabled`; the publisher-only `compression` codec is ignored.
2966    pub fn consumer_compression_enabled(&self) -> bool {
2967        self.compression_enabled == Some(true)
2968    }
2969
2970    pub fn with_stream_response_to(mut self, endpoint: Endpoint) -> Self {
2971        self.stream_response_to = Some(Box::new(endpoint));
2972        self
2973    }
2974}
2975
2976impl WebSocketConfig {
2977    /// Creates a new WebSocket configuration with the specified URL.
2978    pub fn new(url: impl Into<String>) -> Self {
2979        Self {
2980            url: url.into(),
2981            ..Default::default()
2982        }
2983    }
2984
2985    pub fn with_path(mut self, path: impl Into<String>) -> Self {
2986        self.path = Some(path.into());
2987        self
2988    }
2989
2990    pub fn with_backlog(mut self, backlog: u32) -> Self {
2991        self.backlog = Some(backlog);
2992        self
2993    }
2994
2995    pub fn with_execution_mode(mut self, execution_mode: WebSocketExecutionMode) -> Self {
2996        self.execution_mode = execution_mode;
2997        self
2998    }
2999}
3000
3001// --- IBM MQ Specific Configuration ---
3002
3003/// TLS configuration for the IBM MQ native client.
3004///
3005/// The IBM MQ client doesn't consume PEM files, so this uses MQ-native field
3006/// names rather than the generic [`TlsConfig`] used by the other endpoints.
3007#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq, Eq, Hash)]
3008#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3009#[cfg_attr(feature = "schema", schemars(transform = ibm_tls_config_schema_transform))]
3010#[serde(deny_unknown_fields)]
3011pub struct IbmTlsConfig {
3012    /// If true, enable TLS/SSL.
3013    #[serde(default, deserialize_with = "deserialize_null_as_false")]
3014    pub required: bool,
3015    /// TLS CipherSpec (e.g., `ANY_TLS12`). Required for encrypted connections. IBM MQ-specific.
3016    pub cipher_spec: Option<String>,
3017    /// For IBM MQ this is the CMS key repository stem (e.g. `/path/to/tls` for `tls.kdb`/`tls.sth`),
3018    /// not a PEM file. Exposed as `cert_file` for config parity with the generic `TlsConfig`;
3019    /// the MQ-native name `key_repository` is still accepted.
3020    #[serde(rename = "cert_file", alias = "key_repository")]
3021    pub key_repository: Option<String>,
3022    /// Password unlocking the key repository. Requires an IBM MQ client/server at 9.3.0.0+.
3023    /// Exposed as `cert_password` for parity with `TlsConfig`; alias `key_repository_password`.
3024    #[serde(rename = "cert_password", alias = "key_repository_password")]
3025    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
3026    pub key_repository_password: Option<String>,
3027    /// If true, disable server certificate verification (insecure).
3028    #[serde(default)]
3029    pub accept_invalid_certs: bool,
3030}
3031
3032// schemars ignores serde `alias`, so the MQ-native names accepted at runtime
3033// (`key_repository`, `key_repository_password`) must be added to the schema by
3034// hand, otherwise `additionalProperties: false` rejects otherwise-valid configs.
3035#[cfg(feature = "schema")]
3036fn ibm_tls_config_schema_transform(schema: &mut schemars::Schema) {
3037    let Some(properties) = schema
3038        .as_object_mut()
3039        .and_then(|schema_obj| schema_obj.get_mut("properties"))
3040        .and_then(serde_json::Value::as_object_mut)
3041    else {
3042        return;
3043    };
3044
3045    properties.insert(
3046        "key_repository".to_string(),
3047        serde_json::json!({
3048            "description": "MQ-native alias for `cert_file`: the CMS key repository stem \
3049                (e.g. `/path/to/tls` for `tls.kdb`/`tls.sth`).",
3050            "type": ["string", "null"]
3051        }),
3052    );
3053
3054    properties.insert(
3055        "key_repository_password".to_string(),
3056        serde_json::json!({
3057            "description": "MQ-native alias for `cert_password`: password unlocking the key \
3058                repository. Requires an IBM MQ client/server at 9.3.0.0+.",
3059            "type": ["string", "null"],
3060            "format": "password"
3061        }),
3062    );
3063}
3064
3065/// Connection settings for the IBM MQ Queue Manager.
3066// Default is implemented manually (not derived): the numeric fields must match
3067// the serde defaults, otherwise `IbmMqConfig::new()` / `..Default::default()`
3068// would yield max_message_size=0 (zero-length receive buffer) and wait_timeout=0.
3069#[derive(Debug, Deserialize, Serialize, Clone)]
3070#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3071#[serde(deny_unknown_fields)]
3072pub struct IbmMqConfig {
3073    /// Required. Connection URL in `host(port)` format. Supports comma-separated list for failover (e.g., `host1(1414),host2(1414)`). If it contains userinfo, it will be treated as a secret.
3074    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
3075    pub url: String,
3076    /// Target Queue name for point-to-point messaging. Optional if `topic` is set; defaults to route name if omitted.
3077    pub queue: Option<String>,
3078    /// Target Topic string for Publish/Subscribe. If set, enables **Subscriber mode** (Consumer) or publishes to a topic (Publisher). Optional if `queue` is set.
3079    pub topic: Option<String>,
3080    /// Required. Name of the Queue Manager to connect to (e.g., `QM1`).
3081    pub queue_manager: String,
3082    /// Required. Server Connection (SVRCONN) Channel name defined on the QM.
3083    pub channel: String,
3084    /// Username for authentication. Optional; required if the channel enforces authentication
3085    pub username: Option<String>,
3086    /// Password for authentication. Optional; required if the channel enforces authentication.
3087    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
3088    pub password: Option<String>,
3089    /// TLS configuration settings (e.g., keystore paths). Optional.
3090    #[serde(default)]
3091    pub tls: IbmTlsConfig,
3092    /// Maximum message size in bytes (default: 4MB). Optional.
3093    #[serde(default = "default_max_message_size")]
3094    pub max_message_size: usize,
3095    /// (Consumer only) Polling timeout in milliseconds (default: 1000ms). Optional.
3096    #[serde(default = "default_wait_timeout_ms")]
3097    pub wait_timeout_ms: i32,
3098    /// Internal buffer size for the channel. Defaults to 100.
3099    #[serde(default)]
3100    pub internal_buffer_size: Option<usize>,
3101    /// If false, attempt to open the queue with INQUIRE permissions to fetch queue depth for status checks. Defaults to false.
3102    #[serde(default)]
3103    pub disable_status_inq: bool,
3104}
3105
3106impl IbmMqConfig {
3107    /// Creates a new IBM MQ configuration with the specified connection URL, queue manager, and channel.
3108    pub fn new(
3109        url: impl Into<String>,
3110        queue_manager: impl Into<String>,
3111        channel: impl Into<String>,
3112    ) -> Self {
3113        Self {
3114            url: url.into(),
3115            queue_manager: queue_manager.into(),
3116            channel: channel.into(),
3117            disable_status_inq: false,
3118            ..Default::default()
3119        }
3120    }
3121
3122    pub fn with_queue(mut self, queue: impl Into<String>) -> Self {
3123        self.queue = Some(queue.into());
3124        self
3125    }
3126
3127    pub fn with_topic(mut self, topic: impl Into<String>) -> Self {
3128        self.topic = Some(topic.into());
3129        self
3130    }
3131
3132    pub fn with_credentials(
3133        mut self,
3134        username: impl Into<String>,
3135        password: impl Into<String>,
3136    ) -> Self {
3137        self.username = Some(username.into());
3138        self.password = Some(password.into());
3139        self
3140    }
3141}
3142
3143impl Default for IbmMqConfig {
3144    fn default() -> Self {
3145        Self {
3146            url: String::new(),
3147            queue: None,
3148            topic: None,
3149            queue_manager: String::new(),
3150            channel: String::new(),
3151            username: None,
3152            password: None,
3153            tls: IbmTlsConfig::default(),
3154            max_message_size: default_max_message_size(),
3155            wait_timeout_ms: default_wait_timeout_ms(),
3156            internal_buffer_size: None,
3157            disable_status_inq: false,
3158        }
3159    }
3160}
3161
3162fn default_max_message_size() -> usize {
3163    4 * 1024 * 1024 // 4MB default
3164}
3165
3166fn default_wait_timeout_ms() -> i32 {
3167    1000 // 1 second default
3168}
3169
3170// --- Switch/Router Configuration ---
3171
3172#[derive(Debug, Deserialize, Serialize, Clone)]
3173#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3174#[serde(deny_unknown_fields)]
3175pub struct SwitchConfig {
3176    /// The metadata key to inspect for routing decisions.
3177    pub metadata_key: String,
3178    /// A map of values to endpoints.
3179    pub cases: HashMap<String, Endpoint>,
3180    /// The default endpoint if no case matches.
3181    pub default: Option<Box<Endpoint>>,
3182}
3183
3184// --- Response Endpoint Configuration ---
3185#[derive(Debug, Deserialize, Serialize, Clone, Default)]
3186#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3187#[serde(deny_unknown_fields)]
3188pub struct ResponseConfig {
3189    // This struct is a marker and currently has no fields.
3190}
3191
3192// --- Request/Forward Endpoint Configuration ---
3193
3194/// Sends each message to a request-capable endpoint and forwards its response elsewhere.
3195///
3196/// Turns a request/reply exchange (HTTP, or a request_reply NATS/Mongo/Memory endpoint) into
3197/// a one-way flow whose response lands on `forward_to` — e.g. IBM MQ → HTTP → IBM MQ. On
3198/// request error/timeout the original message is forwarded instead (unchanged). Successful
3199/// responses carry the transport-native status (e.g. `http_status_code`), so a `switch` on
3200/// `forward_to` can route them by status; a failed request forwards the original message with
3201/// no status key, so catch failures on the switch's default branch.
3202#[derive(Debug, Deserialize, Serialize, Clone)]
3203#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3204#[serde(deny_unknown_fields)]
3205pub struct RequestForwardConfig {
3206    /// The request-capable endpoint to send each message to (e.g. an `http` client).
3207    pub to: Box<Endpoint>,
3208    /// Where the response (or, on error, the original message) is forwarded.
3209    pub forward_to: Box<Endpoint>,
3210}
3211
3212// --- Postgres CDC (logical replication) Configuration ---
3213
3214/// Postgres logical-replication CDC source (pgoutput). Source-only.
3215#[derive(Debug, Deserialize, Serialize, Clone, Default)]
3216#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3217#[serde(deny_unknown_fields)]
3218pub struct PostgresCdcConfig {
3219    /// Connection URL, e.g. `postgres://user:pass@host:5432/dbname`.
3220    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
3221    pub url: String,
3222    /// Publication name (must already exist; defines which tables are captured).
3223    pub publication: String,
3224    /// Replication slot name; created if missing when `create_slot` is true.
3225    #[serde(default = "default_pg_cdc_slot")]
3226    pub slot_name: String,
3227    /// Create the replication slot if it does not exist.
3228    #[serde(default = "default_true")]
3229    pub create_slot: bool,
3230    /// Create the `publication` if missing (default false; leave off if it pre-exists).
3231    /// Needs table ownership for `publication_tables`, or superuser when none are set (`FOR ALL TABLES`).
3232    #[serde(default)]
3233    pub create_publication: bool,
3234    /// Tables to include when managing the publication (`create_publication`); may be `schema.table`.
3235    /// Missing ones are added to an existing publication (never removed). Empty = `FOR ALL TABLES` (needs superuser).
3236    #[serde(default)]
3237    pub publication_tables: Vec<String>,
3238    /// Ephemeral run: drop the slot when the route stops. Not restart-safe; a hard crash leaks it.
3239    #[serde(default)]
3240    pub temporary_slot: bool,
3241    /// Checkpoint key for persisting the confirmed LSN across restarts (optional; the slot is authoritative).
3242    pub cursor_id: Option<String>,
3243    /// Checkpoint store spec (e.g. `file:///path`, `s3://bucket/prefix`); defaults to the source database.
3244    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
3245    pub checkpoint_store: Option<String>,
3246    /// Standby-status-update interval in ms; must be shorter than the server's `wal_sender_timeout`.
3247    #[serde(default = "default_pg_cdc_status_interval_ms")]
3248    pub status_interval_ms: u64,
3249    /// TLS configuration for the replication connection.
3250    #[serde(default)]
3251    pub tls: TlsConfig,
3252}
3253
3254fn default_pg_cdc_slot() -> String {
3255    "mq_bridge_slot".to_string()
3256}
3257
3258fn default_pg_cdc_status_interval_ms() -> u64 {
3259    10_000
3260}
3261
3262// --- SQLx Specific Configuration ---
3263
3264/// General SQLx connection configuration.
3265#[derive(Debug, Deserialize, Serialize, Clone, Default)]
3266#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3267#[serde(deny_unknown_fields)]
3268pub struct SqlxConfig {
3269    /// Database connection URL. If it contains userinfo, it will be treated as a secret.
3270    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
3271    pub url: String,
3272    /// Optional username. Takes precedence over any credentials embedded in the `url`.
3273    #[serde(default)]
3274    pub username: Option<String>,
3275    /// Optional password. Takes precedence over any credentials embedded in the `url`.
3276    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
3277    #[serde(default)]
3278    pub password: Option<String>,
3279    /// The table to interact with.
3280    pub table: String,
3281    /// (Publisher only) Optional. A custom SQL INSERT query. Use `?` as a placeholder for the payload.
3282    /// If not provided, a default `INSERT INTO {table} (payload) VALUES (?)` is used.
3283    ///
3284    /// For multi-column inserts, embed explicit source tokens directly in the query:
3285    /// `${metadata:<key>}` binds `message.metadata["<key>"]`, and `${payload:<field>}`
3286    /// binds the top-level JSON field `<field>` of the payload (types preserved:
3287    /// numbers/bools stay numeric/bool). There is no fallback between the two: an
3288    /// absent metadata key, non-JSON payload, or missing/non-scalar field binds SQL NULL.
3289    /// Example: `INSERT INTO orders (customer_id, sku, qty) VALUES (${metadata:customer_id}, ${payload:sku}, ${payload:qty})`.
3290    /// A query with no `${...}` tokens behaves exactly as before (whole payload bound once).
3291    /// `auto_create_table` is not supported together with a token-based query.
3292    ///
3293    /// Tokens bind as text/number/bool; Postgres won't implicitly cast text into a
3294    /// `numeric`/`timestamptz` column (these arrive as JSON strings from a sql source).
3295    /// Add an explicit cast next to the token — it is preserved verbatim in the SQL:
3296    /// `VALUES (${payload:amount}::numeric, ${payload:created_at}::timestamptz)`.
3297    pub insert_query: Option<String>,
3298    /// (Consumer only) Optional. A custom SQL SELECT query to fetch messages. This is only supported for PostgreSQL and Microsoft SQL Server.
3299    /// The query must include a placeholder for the batch size (`$1` for PostgreSQL, `@p1` for SQL Server).
3300    /// The bridge will bind the route's `batch_size` to this placeholder.
3301    pub select_query: Option<String>,
3302    /// (Consumer only) If true, delete messages after processing.
3303    #[serde(default)]
3304    pub delete_after_read: bool,
3305    /// (Consumer only) Read an existing table **non-destructively** and resumably, paging by this
3306    /// monotonic column (`SELECT * FROM {table} WHERE {cursor_column} > $last ORDER BY {cursor_column} ASC LIMIT n`)
3307    /// and persisting the last read value under `cursor_id`. Does not delete/lock source rows.
3308    /// Mutually exclusive with `delete_after_read`.
3309    pub cursor_column: Option<String>,
3310    /// (Consumer only) Cursor id used to key the persisted resume position. Recommended when
3311    /// `cursor_column` is set: without it, progress is not persisted and every restart re-copies
3312    /// from the beginning.
3313    pub cursor_id: Option<String>,
3314    /// (Consumer only) Where to persist the resume cursor in `cursor_column` mode. A URL selects the
3315    /// backend; a bare name (or `/name`) reuses the **source** datastore with that table name:
3316    /// - absent → source datastore, table `mqb_cursors_<source_table>` (auto-unique)
3317    /// - `/my_cursors` → source datastore, table `my_cursors`
3318    /// - `file:///var/lib/mqb/cursors.json` → local JSON file (read-only / write-restricted sources)
3319    /// - `postgres://user@host/db/table` or `mysql://host/db/table` → external SQL table (table optional)
3320    /// - `mongodb://host/db/collection` → external MongoDB collection (collection optional)
3321    /// - `s3://bucket/prefix` (also `gs://`, `az://`, `abfs://`) → cloud object store; creds via env
3322    ///
3323    /// When no table/collection is named, it defaults to `mqb_cursors_<source_table>`.
3324    /// May embed connection credentials, so it is treated as a secret.
3325    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
3326    pub checkpoint_store: Option<String>,
3327    /// (Publisher only) If true, automatically create the table and indexes if they don't exist. Defaults to false.
3328    #[serde(default)]
3329    pub auto_create_table: bool,
3330    /// (Publisher only) PostgreSQL only. Bulk-load batches via `COPY FROM STDIN` (much faster than multi-row INSERT). Requires a token-based `insert_query`; no `ON CONFLICT`/`RETURNING`.
3331    #[serde(default)]
3332    pub bulk_copy: bool,
3333    /// (Consumer only) Polling interval in milliseconds. Defaults to 100ms.
3334    pub polling_interval_ms: Option<u64>,
3335    /// (Consumer only) If set, the poll interval backs off exponentially from `polling_interval_ms`
3336    /// up to this value while drained, resetting on new rows. Unset = constant interval.
3337    pub max_polling_interval_ms: Option<u64>,
3338    /// (Consumer only, PostgreSQL) If set, consume via logical-replication CDC instead of cursor
3339    /// polling: streams inserts/updates/deletes from this publication. Requires the `postgres-cdc`
3340    /// feature and a Postgres URL. For full control use the dedicated `postgres_cdc` endpoint.
3341    pub publication: Option<String>,
3342    /// (Consumer only, CDC) Replication slot name; created if missing. Defaults to `mq_bridge_slot`.
3343    pub slot_name: Option<String>,
3344    /// (Consumer only, CDC) When `publication` is set, create it if missing (default false).
3345    /// Needs table-owner privilege: it is auto-published `FOR TABLE {table}`.
3346    #[serde(default)]
3347    pub create_publication: bool,
3348    /// TLS configuration for the database connection.
3349    #[serde(default)]
3350    pub tls: TlsConfig,
3351    /// Maximum number of connections in the pool. Defaults to 10.
3352    pub max_connections: Option<u32>,
3353    /// Minimum number of connections to keep in the pool. Defaults to 0.
3354    pub min_connections: Option<u32>,
3355    /// Timeout for acquiring a connection from the pool in milliseconds. Defaults to 30000ms.
3356    pub acquire_timeout_ms: Option<u64>,
3357    /// Maximum idle time for a connection in milliseconds. Defaults to 600000ms (10 minutes).
3358    pub idle_timeout_ms: Option<u64>,
3359    /// Maximum lifetime of a connection in milliseconds. Defaults to 1800000ms (30 minutes).
3360    pub max_lifetime_ms: Option<u64>,
3361    /// Share one connection pool per connection (default: true); false forces a dedicated pool.
3362    #[serde(default)]
3363    #[cfg_attr(feature = "schema", schemars(default = "default_shared_schema"))]
3364    pub shared: Option<bool>,
3365}
3366
3367// --- ClickHouse Specific Configuration ---
3368
3369/// ClickHouse endpoint configuration (talks the ClickHouse HTTP interface).
3370///
3371/// As a **publisher** it batch-inserts messages using `FORMAT JSONEachRow` — by default the whole
3372/// message payload (which must be a JSON object) becomes one row; set `columns` to build each row
3373/// from explicit `${payload:<field>}` / `${metadata:<key>}` tokens instead. As a **consumer** it
3374/// reads an existing table **non-destructively** by paging over a monotonic `cursor_column`
3375/// (ClickHouse has no native queue/pub-sub), serializing each row to a JSON payload.
3376#[derive(Debug, Deserialize, Serialize, Clone, Default)]
3377#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3378#[serde(deny_unknown_fields)]
3379pub struct ClickHouseConfig {
3380    /// ClickHouse HTTP endpoint URL, e.g. `http://localhost:8123` (or `https://…`). If it contains
3381    /// userinfo, it will be treated as a secret.
3382    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
3383    pub url: String,
3384    /// Optional username. Takes precedence over any credentials embedded in the `url`. Defaults to `default`.
3385    #[serde(default)]
3386    pub username: Option<String>,
3387    /// Optional password. Takes precedence over any credentials embedded in the `url`.
3388    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
3389    #[serde(default)]
3390    pub password: Option<String>,
3391    /// Database name. Defaults to `default`.
3392    pub database: Option<String>,
3393    /// The table to read from / write to. May be schema-qualified (`db.table`).
3394    pub table: String,
3395    /// (Publisher only) Optional per-column mapping. Each entry maps a target column name to a value
3396    /// token: `${payload:<field>}` takes the top-level JSON field `<field>` of the payload (JSON type
3397    /// preserved), `${metadata:<key>}` takes `message.metadata["<key>"]` (as a string), and any other
3398    /// value is inserted literally. When omitted, the whole payload JSON object is inserted as one row.
3399    pub columns: Option<std::collections::BTreeMap<String, String>>,
3400    /// (Publisher only) If true, set the ClickHouse `async_insert=1` server setting so inserts are
3401    /// buffered server-side. Defaults to false.
3402    #[serde(default)]
3403    pub async_insert: bool,
3404    /// (Publisher only) With `async_insert`, wait for the server to flush before acking. Defaults to
3405    /// true (durable). False = fire-and-forget: faster, but a crash before flush can drop the batch.
3406    #[serde(default)]
3407    pub wait_for_async_insert: Option<bool>,
3408    /// (Consumer only) Read an existing table **non-destructively** and resumably, paging by this
3409    /// monotonic column (`SELECT … WHERE {cursor_column} > {last} ORDER BY {cursor_column} ASC LIMIT n`)
3410    /// and persisting the last read value under `cursor_id`.
3411    pub cursor_column: Option<String>,
3412    /// (Consumer only) Cursor id used to key the persisted resume position. Without it, progress is not
3413    /// persisted and every restart re-copies from the beginning.
3414    pub cursor_id: Option<String>,
3415    /// (Consumer only) Where to persist the resume cursor. Because ClickHouse is unsuited to per-row
3416    /// cursor upserts, a durable checkpoint requires an **external** store URL:
3417    /// - `file:///var/lib/mqb/cursors.json` → local JSON file
3418    /// - `postgres://user@host/db/table` / `mysql://host/db/table` → external SQL table (table optional)
3419    /// - `mongodb://host/db/collection` → external MongoDB collection (collection optional)
3420    /// - `s3://bucket/prefix` (also `gs://`, `az://`, `abfs://`) → cloud object store; creds via env
3421    ///
3422    /// May embed connection credentials, so it is treated as a secret.
3423    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
3424    pub checkpoint_store: Option<String>,
3425    /// (Consumer only) Columns to select in `cursor_column` mode. Defaults to `*`.
3426    pub select_columns: Option<String>,
3427    /// (Consumer only) Polling interval in milliseconds when the table is drained. Defaults to 100ms.
3428    pub polling_interval_ms: Option<u64>,
3429    /// (Consumer only) If set, the poll interval backs off exponentially from `polling_interval_ms`
3430    /// up to this value while drained, resetting on new rows. Unset = constant interval.
3431    pub max_polling_interval_ms: Option<u64>,
3432    /// Request timeout in milliseconds for ClickHouse HTTP calls (inserts, cursor reads, status).
3433    /// Unset = no timeout (wait indefinitely), which suits very large batch inserts.
3434    pub request_timeout_ms: Option<u64>,
3435    /// Connection (TCP + TLS handshake) timeout in milliseconds. Defaults to 10000ms.
3436    pub connect_timeout_ms: Option<u64>,
3437    /// TLS configuration for `https://` connections.
3438    #[serde(default)]
3439    pub tls: TlsConfig,
3440    /// HTTP body compression for inserts and cursor reads (`none`, `gzip`, `lz4`, `zstd`). Applied
3441    /// as `Content-Encoding` on the request body and negotiated on the response via `Accept-Encoding`.
3442    /// `lz4`/`zstd` are faster than `gzip`; all are understood natively by ClickHouse. Defaults to `gzip`.
3443    #[serde(default = "default_gzip_compression")]
3444    pub compression: Compression,
3445}
3446
3447fn default_gzip_compression() -> Compression {
3448    Compression::Gzip
3449}
3450
3451// --- Common Configuration ---
3452
3453/// TLS configuration for secure connections.
3454///
3455/// Configures Transport Layer Security (TLS/SSL) for encrypted communication.
3456/// Supports both client certificate (mutual TLS) and server certificate validation.
3457///
3458/// # Examples
3459///
3460/// ```
3461/// use mq_bridge::models::TlsConfig;
3462///
3463/// let tls = TlsConfig {
3464///     required: true,
3465///     ca_file: Some("/path/to/ca.pem".to_string()),
3466///     cert_file: Some("/path/to/cert.pem".to_string()),
3467///     key_file: Some("/path/to/key.pem".to_string()),
3468///     ..Default::default()
3469/// };
3470/// ```
3471#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq, Eq, Hash)]
3472#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3473#[serde(deny_unknown_fields)]
3474pub struct TlsConfig {
3475    /// If true, enable TLS/SSL.
3476    #[serde(default, deserialize_with = "deserialize_null_as_false")]
3477    pub required: bool,
3478    /// Path to the CA certificate file.
3479    pub ca_file: Option<String>,
3480    /// Path to the client certificate file (PEM).
3481    pub cert_file: Option<String>,
3482    /// Path to the client private key file (PEM).
3483    pub key_file: Option<String>,
3484    /// Password for the private key (if encrypted).
3485    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
3486    pub cert_password: Option<String>,
3487    /// If true, disable server certificate verification (insecure).
3488    #[serde(default)]
3489    pub accept_invalid_certs: bool,
3490}
3491
3492impl TlsConfig {
3493    /// Creates a new TLS configuration with default settings (TLS not required).
3494    pub fn new() -> Self {
3495        Self::default()
3496    }
3497
3498    pub fn with_ca_file(mut self, ca_file: impl Into<String>) -> Self {
3499        self.ca_file = Some(ca_file.into());
3500        self.required = true;
3501        self
3502    }
3503
3504    pub fn with_client_cert(
3505        mut self,
3506        cert_file: impl Into<String>,
3507        key_file: impl Into<String>,
3508    ) -> Self {
3509        self.cert_file = Some(cert_file.into());
3510        self.key_file = Some(key_file.into());
3511        self.required = true;
3512        self
3513    }
3514
3515    pub fn with_insecure(mut self, accept_invalid_certs: bool) -> Self {
3516        self.accept_invalid_certs = accept_invalid_certs;
3517        self
3518    }
3519
3520    /// Checks if mutual TLS (mTLS) client authentication is configured.
3521    pub fn is_mtls_client_configured(&self) -> bool {
3522        self.required && self.cert_file.is_some() && self.key_file.is_some()
3523    }
3524
3525    /// Checks if TLS server certificate authentication is configured.
3526    pub fn is_tls_server_configured(&self) -> bool {
3527        self.required && self.cert_file.is_some() && self.key_file.is_some()
3528    }
3529
3530    /// Checks if the TLS configuration is sufficient to make a TLS client connection.
3531    pub fn is_tls_client_configured(&self) -> bool {
3532        self.required
3533            || self.ca_file.is_some()
3534            || (self.cert_file.is_some() && self.key_file.is_some())
3535    }
3536
3537    /// Helper to normalize a URL by adding the appropriate scheme prefix (http:// or https://) if missing.
3538    pub fn normalize_url(&self, url: &str) -> String {
3539        if url
3540            .get(..7)
3541            .is_some_and(|prefix| prefix.eq_ignore_ascii_case("http://"))
3542            || url
3543                .get(..8)
3544                .is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://"))
3545        {
3546            url.to_string()
3547        } else {
3548            let is_tls = self.required;
3549            let scheme = if is_tls { "https" } else { "http" };
3550            format!("{}://{}", scheme, url)
3551        }
3552    }
3553}
3554
3555/// Trait for extracting secrets from configuration structures.
3556pub trait SecretExtractor {
3557    /// Extracts secrets into the provided map using the given prefix, and clears them from self.
3558    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>);
3559}
3560
3561fn extract_sensitive_string_map_entries(
3562    values: &mut HashMap<String, String>,
3563    prefix: &str,
3564    field_name: &str,
3565    secrets: &mut HashMap<String, String>,
3566) {
3567    let secret_keys = values
3568        .keys()
3569        .filter(|key| {
3570            let key = key.to_ascii_lowercase();
3571            key.contains("key") || key.contains("token") || key.contains("auth")
3572        })
3573        .cloned()
3574        .collect::<Vec<_>>();
3575
3576    for key in secret_keys {
3577        if let Some(value) = values.remove(&key) {
3578            secrets.insert(
3579                sanitize_secret_key(&format!("{}__{}__{}", prefix, field_name, key)),
3580                value,
3581            );
3582        }
3583    }
3584}
3585
3586fn url_has_userinfo(url: &str) -> bool {
3587    let Some(authority_start) = url.find("://").map(|idx| idx + 3) else {
3588        return false;
3589    };
3590    let authority_end = url[authority_start..]
3591        .find(['/', '?', '#'])
3592        .map(|idx| authority_start + idx)
3593        .unwrap_or(url.len());
3594    url[authority_start..authority_end].contains('@')
3595}
3596
3597fn sanitize_secret_key(key: &str) -> String {
3598    key.chars()
3599        .map(|ch| {
3600            let ch = ch.to_ascii_uppercase();
3601            if ch.is_ascii_alphanumeric() || ch == '_' {
3602                ch
3603            } else {
3604                '_'
3605            }
3606        })
3607        .collect()
3608}
3609
3610fn extract_sensitive_url(
3611    url: &mut String,
3612    prefix: &str,
3613    field_name: &str,
3614    secrets: &mut HashMap<String, String>,
3615) {
3616    if !url.is_empty() && url_has_userinfo(url) {
3617        secrets.insert(
3618            sanitize_secret_key(&format!("{}__{}", prefix, field_name)),
3619            std::mem::take(url),
3620        );
3621    }
3622}
3623
3624fn extract_sensitive_optional_url(
3625    url: &mut Option<String>,
3626    prefix: &str,
3627    field_name: &str,
3628    secrets: &mut HashMap<String, String>,
3629) {
3630    if url.as_ref().is_some_and(|url| url_has_userinfo(url)) {
3631        if let Some(url) = url.take() {
3632            secrets.insert(
3633                sanitize_secret_key(&format!("{}__{}", prefix, field_name)),
3634                url,
3635            );
3636        }
3637    }
3638}
3639
3640impl SecretExtractor for Route {
3641    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3642        self.input
3643            .extract_secrets(&format!("{}__{}", prefix, "INPUT"), secrets);
3644        self.output
3645            .extract_secrets(&format!("{}__{}", prefix, "OUTPUT"), secrets);
3646    }
3647}
3648
3649impl SecretExtractor for Endpoint {
3650    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3651        for (i, middleware) in self.middlewares.iter_mut().enumerate() {
3652            middleware.extract_secrets(&format!("{}__{}__{}", prefix, "MIDDLEWARES", i), secrets);
3653        }
3654        self.endpoint_type.extract_secrets(prefix, secrets);
3655    }
3656}
3657
3658impl SecretExtractor for EndpointType {
3659    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3660        match self {
3661            EndpointType::Aws(cfg) => {
3662                cfg.extract_secrets(&format!("{}__{}", prefix, "AWS"), secrets)
3663            }
3664            EndpointType::Kafka(cfg) => {
3665                cfg.extract_secrets(&format!("{}__{}", prefix, "KAFKA"), secrets)
3666            }
3667            EndpointType::Nats(cfg) => {
3668                cfg.extract_secrets(&format!("{}__{}", prefix, "NATS"), secrets)
3669            }
3670            EndpointType::Amqp(cfg) => {
3671                cfg.extract_secrets(&format!("{}__{}", prefix, "AMQP"), secrets)
3672            }
3673            EndpointType::MongoDb(cfg) => {
3674                cfg.extract_secrets(&format!("{}__{}", prefix, "MONGODB"), secrets)
3675            }
3676            EndpointType::Mqtt(cfg) => {
3677                cfg.extract_secrets(&format!("{}__{}", prefix, "MQTT"), secrets)
3678            }
3679            EndpointType::Http(cfg) => {
3680                cfg.extract_secrets(&format!("{}__{}", prefix, "HTTP"), secrets)
3681            }
3682            EndpointType::WebSocket(cfg) => {
3683                cfg.extract_secrets(&format!("{}__{}", prefix, "WEBSOCKET"), secrets)
3684            }
3685            EndpointType::IbmMq(cfg) => {
3686                cfg.extract_secrets(&format!("{}__{}", prefix, "IBMMQ"), secrets)
3687            }
3688            EndpointType::ZeroMq(cfg) => {
3689                cfg.extract_secrets(&format!("{}__{}", prefix, "ZEROMQ"), secrets)
3690            }
3691            EndpointType::RedisStreams(cfg) => {
3692                cfg.extract_secrets(&format!("{}__{}", prefix, "REDIS_STREAMS"), secrets)
3693            }
3694            EndpointType::Sqlx(cfg) => {
3695                cfg.extract_secrets(&format!("{}__{}", prefix, "SQLX"), secrets)
3696            }
3697            EndpointType::ClickHouse(cfg) => {
3698                cfg.extract_secrets(&format!("{}__{}", prefix, "CLICKHOUSE"), secrets)
3699            }
3700            EndpointType::PostgresCdc(cfg) => {
3701                cfg.extract_secrets(&format!("{}__{}", prefix, "POSTGRES_CDC"), secrets)
3702            }
3703            EndpointType::Grpc(cfg) => {
3704                cfg.extract_secrets(&format!("{}__{}", prefix, "GRPC"), secrets)
3705            }
3706            EndpointType::Fanout(endpoints) => {
3707                for (i, ep) in endpoints.iter_mut().enumerate() {
3708                    ep.extract_secrets(&format!("{}__{}__{}", prefix, "FANOUT", i), secrets);
3709                }
3710            }
3711            EndpointType::Switch(cfg) => {
3712                for (key, ep) in cfg.cases.iter_mut() {
3713                    ep.extract_secrets(
3714                        &format!(
3715                            "{}__{}__{}",
3716                            prefix,
3717                            "SWITCH__CASES",
3718                            sanitize_secret_key(key)
3719                        ),
3720                        secrets,
3721                    );
3722                }
3723                if let Some(default) = &mut cfg.default {
3724                    default.extract_secrets(&format!("{}__{}", prefix, "SWITCH__DEFAULT"), secrets);
3725                }
3726            }
3727            EndpointType::Reader(ep) => {
3728                ep.extract_secrets(&format!("{}__{}", prefix, "READER"), secrets)
3729            }
3730            EndpointType::Request(cfg) => {
3731                cfg.to
3732                    .extract_secrets(&format!("{}__{}", prefix, "REQUEST__TO"), secrets);
3733                cfg.forward_to
3734                    .extract_secrets(&format!("{}__{}", prefix, "REQUEST__FORWARD_TO"), secrets);
3735            }
3736            EndpointType::File(cfg) => {
3737                if let Some(enc) = &mut cfg.encryption {
3738                    enc.extract_secrets(&format!("{}__{}", prefix, "FILE__ENCRYPTION"), secrets);
3739                }
3740            }
3741            EndpointType::ObjectStore(cfg) => {
3742                if let Some(enc) = &mut cfg.encryption {
3743                    enc.extract_secrets(
3744                        &format!("{}__{}", prefix, "OBJECT_STORE__ENCRYPTION"),
3745                        secrets,
3746                    );
3747                }
3748            }
3749            _ => {}
3750        }
3751    }
3752}
3753
3754impl SecretExtractor for Middleware {
3755    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3756        match self {
3757            Middleware::Dlq(cfg) => {
3758                cfg.endpoint
3759                    .extract_secrets(&format!("{}__{}__{}", prefix, "DLQ", "ENDPOINT"), secrets);
3760            }
3761            Middleware::Encryption(cfg) => {
3762                cfg.extract_secrets(&format!("{}__{}", prefix, "ENCRYPTION"), secrets);
3763            }
3764            _ => {}
3765        }
3766    }
3767}
3768
3769impl SecretExtractor for EncryptionConfig {
3770    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3771        if !self.key.is_empty() {
3772            secrets.insert(
3773                sanitize_secret_key(&format!("{}__{}", prefix, "KEY")),
3774                std::mem::take(&mut self.key),
3775            );
3776        }
3777        for (id, k) in std::mem::take(&mut self.decrypt_keys) {
3778            secrets.insert(
3779                sanitize_secret_key(&format!("{}__{}__{}", prefix, "DECRYPT_KEYS", id)),
3780                k,
3781            );
3782        }
3783    }
3784}
3785
3786impl SecretExtractor for AwsConfig {
3787    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3788        if let Some(val) = self.access_key.take() {
3789            secrets.insert(format!("{}__{}", prefix, "ACCESS_KEY"), val);
3790        }
3791        if let Some(val) = self.secret_key.take() {
3792            secrets.insert(format!("{}__{}", prefix, "SECRET_KEY"), val);
3793        }
3794        if let Some(val) = self.session_token.take() {
3795            secrets.insert(format!("{}__{}", prefix, "SESSION_TOKEN"), val);
3796        }
3797        extract_sensitive_optional_url(&mut self.queue_url, prefix, "QUEUE_URL", secrets);
3798        extract_sensitive_optional_url(&mut self.endpoint_url, prefix, "ENDPOINT_URL", secrets);
3799    }
3800}
3801
3802impl SecretExtractor for KafkaConfig {
3803    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3804        extract_sensitive_url(&mut self.url, prefix, "URL", secrets);
3805        if let Some(val) = self.username.take() {
3806            secrets.insert(format!("{}__{}", prefix, "USERNAME"), val);
3807        }
3808        if let Some(val) = self.password.take() {
3809            secrets.insert(format!("{}__{}", prefix, "PASSWORD"), val);
3810        }
3811        self.tls
3812            .extract_secrets(&format!("{}__{}", prefix, "TLS"), secrets);
3813    }
3814}
3815
3816impl SecretExtractor for NatsConfig {
3817    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3818        extract_sensitive_url(&mut self.url, prefix, "URL", secrets);
3819        if let Some(val) = self.username.take() {
3820            secrets.insert(format!("{}__{}", prefix, "USERNAME"), val);
3821        }
3822        if let Some(val) = self.password.take() {
3823            secrets.insert(format!("{}__{}", prefix, "PASSWORD"), val);
3824        }
3825        if let Some(val) = self.token.take() {
3826            secrets.insert(format!("{}__{}", prefix, "TOKEN"), val);
3827        }
3828        self.tls
3829            .extract_secrets(&format!("{}__{}", prefix, "TLS"), secrets);
3830    }
3831}
3832
3833impl SecretExtractor for AmqpConfig {
3834    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3835        extract_sensitive_url(&mut self.url, prefix, "URL", secrets);
3836        if let Some(val) = self.username.take() {
3837            secrets.insert(format!("{}__{}", prefix, "USERNAME"), val);
3838        }
3839        if let Some(val) = self.password.take() {
3840            secrets.insert(format!("{}__{}", prefix, "PASSWORD"), val);
3841        }
3842        self.tls
3843            .extract_secrets(&format!("{}__{}", prefix, "TLS"), secrets);
3844    }
3845}
3846
3847impl SecretExtractor for MongoDbConfig {
3848    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3849        extract_sensitive_url(&mut self.url, prefix, "URL", secrets);
3850        if let Some(val) = self.username.take() {
3851            secrets.insert(format!("{}__{}", prefix, "USERNAME"), val);
3852        }
3853        if let Some(val) = self.password.take() {
3854            secrets.insert(format!("{}__{}", prefix, "PASSWORD"), val);
3855        }
3856        // The checkpoint store URL may embed connection credentials.
3857        extract_sensitive_optional_url(
3858            &mut self.checkpoint_store,
3859            prefix,
3860            "CHECKPOINT_STORE",
3861            secrets,
3862        );
3863        self.tls
3864            .extract_secrets(&format!("{}__{}", prefix, "TLS"), secrets);
3865    }
3866}
3867
3868impl SecretExtractor for MqttConfig {
3869    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3870        extract_sensitive_url(&mut self.url, prefix, "URL", secrets);
3871        if let Some(val) = self.username.take() {
3872            secrets.insert(format!("{}__{}", prefix, "USERNAME"), val);
3873        }
3874        if let Some(val) = self.password.take() {
3875            secrets.insert(format!("{}__{}", prefix, "PASSWORD"), val);
3876        }
3877        self.tls
3878            .extract_secrets(&format!("{}__{}", prefix, "TLS"), secrets);
3879    }
3880}
3881
3882impl SecretExtractor for HttpConfig {
3883    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3884        extract_sensitive_url(&mut self.url, prefix, "URL", secrets);
3885        if let Some((u, p)) = self.basic_auth.take() {
3886            secrets.insert(format!("{}__{}__{}", prefix, "BASIC_AUTH", 0), u);
3887            secrets.insert(format!("{}__{}__{}", prefix, "BASIC_AUTH", 1), p);
3888        }
3889        extract_sensitive_string_map_entries(
3890            &mut self.custom_headers,
3891            prefix,
3892            "CUSTOM_HEADERS",
3893            secrets,
3894        );
3895        self.tls
3896            .extract_secrets(&format!("{}__{}", prefix, "TLS"), secrets);
3897        if let Some(endpoint) = &mut self.stream_response_to {
3898            endpoint.extract_secrets(&format!("{}__{}", prefix, "STREAM_RESPONSE_TO"), secrets);
3899        }
3900    }
3901}
3902
3903impl SecretExtractor for WebSocketConfig {
3904    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3905        extract_sensitive_url(&mut self.url, prefix, "URL", secrets);
3906    }
3907}
3908
3909impl SecretExtractor for IbmMqConfig {
3910    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3911        extract_sensitive_url(&mut self.url, prefix, "URL", secrets);
3912        if let Some(val) = self.username.take() {
3913            secrets.insert(format!("{}__{}", prefix, "USERNAME"), val);
3914        }
3915        if let Some(val) = self.password.take() {
3916            secrets.insert(format!("{}__{}", prefix, "PASSWORD"), val);
3917        }
3918        self.tls
3919            .extract_secrets(&format!("{}__{}", prefix, "TLS"), secrets);
3920    }
3921}
3922
3923impl SecretExtractor for ZeroMqConfig {
3924    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3925        extract_sensitive_url(&mut self.url, prefix, "URL", secrets);
3926    }
3927}
3928
3929impl SecretExtractor for RedisStreamsConfig {
3930    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3931        extract_sensitive_url(&mut self.url, prefix, "URL", secrets);
3932        if let Some(val) = self.username.take() {
3933            secrets.insert(format!("{}__{}", prefix, "USERNAME"), val);
3934        }
3935        if let Some(val) = self.password.take() {
3936            secrets.insert(format!("{}__{}", prefix, "PASSWORD"), val);
3937        }
3938    }
3939}
3940
3941impl SecretExtractor for SqlxConfig {
3942    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3943        extract_sensitive_url(&mut self.url, prefix, "URL", secrets);
3944        if let Some(val) = self.username.take() {
3945            secrets.insert(format!("{}__{}", prefix, "USERNAME"), val);
3946        }
3947        if let Some(val) = self.password.take() {
3948            secrets.insert(format!("{}__{}", prefix, "PASSWORD"), val);
3949        }
3950        self.tls
3951            .extract_secrets(&format!("{}__{}", prefix, "TLS"), secrets);
3952    }
3953}
3954
3955impl SecretExtractor for ClickHouseConfig {
3956    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3957        extract_sensitive_url(&mut self.url, prefix, "URL", secrets);
3958        if let Some(val) = self.username.take() {
3959            secrets.insert(format!("{}__{}", prefix, "USERNAME"), val);
3960        }
3961        if let Some(val) = self.password.take() {
3962            secrets.insert(format!("{}__{}", prefix, "PASSWORD"), val);
3963        }
3964        if let Some(val) = self.checkpoint_store.take() {
3965            secrets.insert(format!("{}__{}", prefix, "CHECKPOINT_STORE"), val);
3966        }
3967        self.tls
3968            .extract_secrets(&format!("{}__{}", prefix, "TLS"), secrets);
3969    }
3970}
3971
3972impl SecretExtractor for PostgresCdcConfig {
3973    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3974        extract_sensitive_url(&mut self.url, prefix, "URL", secrets);
3975        if let Some(val) = self.checkpoint_store.take() {
3976            secrets.insert(format!("{}__{}", prefix, "CHECKPOINT_STORE"), val);
3977        }
3978        self.tls
3979            .extract_secrets(&format!("{}__{}", prefix, "TLS"), secrets);
3980    }
3981}
3982
3983impl SecretExtractor for GrpcConfig {
3984    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3985        extract_sensitive_url(&mut self.url, prefix, "URL", secrets);
3986        self.tls
3987            .extract_secrets(&format!("{}__{}", prefix, "TLS"), secrets);
3988    }
3989}
3990
3991impl SecretExtractor for TlsConfig {
3992    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3993        if let Some(val) = self.cert_password.take() {
3994            secrets.insert(format!("{}__{}", prefix, "CERT_PASSWORD"), val);
3995        }
3996    }
3997}
3998
3999impl SecretExtractor for IbmTlsConfig {
4000    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
4001        if let Some(val) = self.key_repository_password.take() {
4002            // Wire/env name matches the serde rename (`cert_password`), so the config
4003            // crate's env override resolves back to this field.
4004            secrets.insert(format!("{}__{}", prefix, "CERT_PASSWORD"), val);
4005        }
4006    }
4007}
4008
4009/// Extracts sensitive values (passwords, keys, tokens) from the configuration
4010/// and returns them as a map of environment variables (key-value pairs).
4011/// The extracted fields in the configuration are set to `None`.
4012///
4013/// The keys in the returned map follow the `MQB__{ROUTE}__{ENDPOINT}__{FIELD}` pattern
4014/// compatible with the `config` crate's environment variable override mechanism.
4015pub fn extract_config_secrets(config: &mut Config) -> HashMap<String, String> {
4016    let mut secrets = HashMap::new();
4017    for (route_name, route) in config.iter_mut() {
4018        let prefix = sanitize_secret_key(&format!("MQB__{}", route_name));
4019        route.extract_secrets(&prefix, &mut secrets);
4020    }
4021    secrets
4022}
4023
4024#[cfg(test)]
4025mod null_endpoint_tests {
4026    use super::*;
4027
4028    #[test]
4029    fn null_endpoint_json_round_trip() {
4030        let value = serde_json::to_value(Endpoint::null()).expect("serialize");
4031        let back: Endpoint = serde_json::from_value(value).expect("deserialize");
4032        assert!(matches!(back.endpoint_type, EndpointType::Null));
4033    }
4034
4035    /// The schema advertises unit variants as bare strings, so `"null"` must parse.
4036    #[test]
4037    fn null_endpoint_accepts_string_and_unit_forms() {
4038        for input in ["\"null\"", "null", "{}"] {
4039            let endpoint: Endpoint = serde_json::from_str(input).unwrap_or_else(|e| {
4040                panic!("failed to parse {input}: {e}");
4041            });
4042            assert!(matches!(endpoint.endpoint_type, EndpointType::Null));
4043        }
4044    }
4045
4046    #[test]
4047    fn unknown_endpoint_string_is_rejected() {
4048        let err = serde_json::from_str::<Endpoint>("\"kafka\"").expect_err("should fail");
4049        assert!(
4050            err.to_string().contains("unknown variant"),
4051            "unexpected error: {err}"
4052        );
4053    }
4054
4055    #[test]
4056    fn nested_null_endpoint_json_round_trip() {
4057        let config =
4058            HttpConfig::new("http://localhost:8080").with_stream_response_to(Endpoint::null());
4059        let value = serde_json::to_value(&config).expect("serialize");
4060        let back: HttpConfig = serde_json::from_value(value).expect("deserialize");
4061        let nested = back.stream_response_to.expect("stream_response_to present");
4062        assert!(matches!(nested.endpoint_type, EndpointType::Null));
4063    }
4064
4065    #[test]
4066    fn nested_null_endpoint_yaml_forms() {
4067        for yaml in [
4068            "url: http://localhost:8080\nstream_response_to: \"null\"\n",
4069            "url: http://localhost:8080\nstream_response_to: {}\n",
4070        ] {
4071            let config: HttpConfig = serde_yaml_ng::from_str(yaml)
4072                .unwrap_or_else(|e| panic!("failed to parse {yaml:?}: {e}"));
4073            let nested = config
4074                .stream_response_to
4075                .expect("stream_response_to present");
4076            assert!(matches!(nested.endpoint_type, EndpointType::Null));
4077        }
4078    }
4079
4080    /// In an `Option<Box<Endpoint>>` field, a bare `null` is consumed by serde's `Option`
4081    /// layer as `None` and never reaches the endpoint visitor.
4082    #[test]
4083    fn nested_bare_null_yaml_is_none() {
4084        let config: HttpConfig =
4085            serde_yaml_ng::from_str("url: http://localhost:8080\nstream_response_to: null\n")
4086                .expect("deserialize");
4087        assert!(config.stream_response_to.is_none());
4088    }
4089
4090    #[test]
4091    fn null_endpoint_yaml_round_trip() {
4092        let yaml = serde_yaml_ng::to_string(&Endpoint::null()).expect("serialize");
4093        let back: Endpoint = serde_yaml_ng::from_str(&yaml).expect("deserialize");
4094        assert!(matches!(back.endpoint_type, EndpointType::Null));
4095    }
4096}
4097
4098#[cfg(test)]
4099mod tests {
4100    use super::*;
4101    use config::{Config as ConfigBuilder, Environment};
4102
4103    const TEST_YAML: &str = r#"
4104kafka_to_nats:
4105  concurrency: 10
4106  input:
4107    middlewares:
4108      - deduplication:
4109          sled_path: "/tmp/mq-bridge/dedup_db"
4110          ttl_seconds: 3600
4111      - metrics: {}
4112      - retry:
4113          max_attempts: 5
4114          initial_interval_ms: 200
4115      - random_panic:
4116          mode: nack
4117      - dlq:
4118          endpoint:
4119            nats:
4120              subject: "dlq-subject"
4121              url: "nats://localhost:4222"
4122    kafka:
4123      topic: "input-topic"
4124      url: "localhost:9092"
4125      group_id: "my-consumer-group"
4126      tls:
4127        required: true
4128        ca_file: "/path_to_ca"
4129        cert_file: "/path_to_cert"
4130        key_file: "/path_to_key"
4131        cert_password: "password"
4132        accept_invalid_certs: true
4133  output:
4134    middlewares:
4135      - metrics: {}
4136      - dlq:
4137          endpoint:
4138            file:
4139              path: "error.out"
4140    nats:
4141      subject: "output-subject"
4142      url: "nats://localhost:4222"
4143"#;
4144
4145    fn assert_config_values(config: &Config) {
4146        assert_eq!(config.len(), 1);
4147        let route = config.get("kafka_to_nats").expect("Route should exist");
4148
4149        assert_eq!(route.options.concurrency, 10);
4150
4151        // --- Assert Input ---
4152        let input = &route.input;
4153        assert_eq!(input.middlewares.len(), 5);
4154
4155        let mut has_dedup = false;
4156        let mut has_metrics = false;
4157        let mut has_dlq = false;
4158        let mut has_retry = false;
4159        let mut has_random_panic = false;
4160        for middleware in &input.middlewares {
4161            match middleware {
4162                Middleware::Deduplication(dedup) => {
4163                    assert_eq!(dedup.sled_path.as_deref(), Some("/tmp/mq-bridge/dedup_db"));
4164                    assert_eq!(dedup.ttl_seconds, 3600);
4165                    has_dedup = true;
4166                }
4167                Middleware::Metrics(_) => {
4168                    has_metrics = true;
4169                }
4170                Middleware::Custom { .. } => {}
4171                Middleware::Dlq(dlq) => {
4172                    assert!(dlq.endpoint.middlewares.is_empty());
4173                    if let EndpointType::Nats(nats_cfg) = &dlq.endpoint.endpoint_type {
4174                        assert_eq!(nats_cfg.subject, Some("dlq-subject".to_string()));
4175                        assert_eq!(nats_cfg.url, "nats://localhost:4222");
4176                    }
4177                    has_dlq = true;
4178                }
4179                Middleware::Retry(retry) => {
4180                    assert_eq!(retry.max_attempts, 5);
4181                    assert_eq!(retry.initial_interval_ms, 200);
4182                    has_retry = true;
4183                }
4184                Middleware::RandomPanic(rp) => {
4185                    assert!(rp.mode == FaultMode::Nack);
4186                    has_random_panic = true;
4187                }
4188                Middleware::Delay(_) => {}
4189                Middleware::WeakJoin(_) => {}
4190                Middleware::Limiter(_) => {}
4191                Middleware::Buffer(_) => {}
4192                Middleware::CookieJar(_) => {}
4193                Middleware::Transform(_) => {}
4194                Middleware::Encryption(_) => {}
4195                Middleware::Compression(_) => {}
4196            }
4197        }
4198
4199        if let EndpointType::Kafka(kafka) = &input.endpoint_type {
4200            assert_eq!(kafka.topic, Some("input-topic".to_string()));
4201            assert_eq!(kafka.url, "localhost:9092");
4202            assert_eq!(kafka.group_id, Some("my-consumer-group".to_string()));
4203            let tls = &kafka.tls;
4204            assert!(tls.required);
4205            assert_eq!(tls.ca_file.as_deref(), Some("/path_to_ca"));
4206            assert!(tls.accept_invalid_certs);
4207        } else {
4208            panic!("Input endpoint should be Kafka");
4209        }
4210        assert!(has_dedup);
4211        assert!(has_metrics);
4212        assert!(has_dlq);
4213        assert!(has_retry);
4214        assert!(has_random_panic);
4215
4216        // --- Assert Output ---
4217        let output = &route.output;
4218        assert_eq!(output.middlewares.len(), 2);
4219        assert!(matches!(output.middlewares[0], Middleware::Metrics(_)));
4220
4221        if let EndpointType::Nats(nats) = &output.endpoint_type {
4222            assert_eq!(nats.subject, Some("output-subject".to_string()));
4223            assert_eq!(nats.url, "nats://localhost:4222");
4224        } else {
4225            panic!("Output endpoint should be NATS");
4226        }
4227    }
4228
4229    #[test]
4230    fn test_deserialize_from_yaml() {
4231        // We use serde_yaml directly here because the `config` crate's processing
4232        // can interfere with complex deserialization logic.
4233        let result: Result<Config, _> = serde_yaml_ng::from_str(TEST_YAML);
4234        println!("Deserialized from YAML: {:#?}", result);
4235        let config = result.expect("Failed to deserialize TEST_YAML");
4236        assert_config_values(&config);
4237    }
4238
4239    #[test]
4240    fn test_deserialize_from_env() {
4241        // Set environment variables based on README
4242        unsafe {
4243            std::env::set_var("MQB__KAFKA_TO_NATS__CONCURRENCY", "10");
4244            std::env::set_var("MQB__KAFKA_TO_NATS__INPUT__KAFKA__TOPIC", "input-topic");
4245            std::env::set_var("MQB__KAFKA_TO_NATS__INPUT__KAFKA__URL", "localhost:9092");
4246            std::env::set_var(
4247                "MQB__KAFKA_TO_NATS__INPUT__KAFKA__GROUP_ID",
4248                "my-consumer-group",
4249            );
4250            std::env::set_var("MQB__KAFKA_TO_NATS__INPUT__KAFKA__TLS__REQUIRED", "true");
4251            std::env::set_var(
4252                "MQB__KAFKA_TO_NATS__INPUT__KAFKA__TLS__CA_FILE",
4253                "/path_to_ca",
4254            );
4255            std::env::set_var(
4256                "MQB__KAFKA_TO_NATS__INPUT__KAFKA__TLS__ACCEPT_INVALID_CERTS",
4257                "true",
4258            );
4259            std::env::set_var(
4260                "MQB__KAFKA_TO_NATS__OUTPUT__NATS__SUBJECT",
4261                "output-subject",
4262            );
4263            std::env::set_var(
4264                "MQB__KAFKA_TO_NATS__OUTPUT__NATS__URL",
4265                "nats://localhost:4222",
4266            );
4267            std::env::set_var(
4268                "MQB__KAFKA_TO_NATS__INPUT__MIDDLEWARES__0__DLQ__ENDPOINT__NATS__SUBJECT",
4269                "dlq-subject",
4270            );
4271            std::env::set_var(
4272                "MQB__KAFKA_TO_NATS__INPUT__MIDDLEWARES__0__DLQ__ENDPOINT__NATS__URL",
4273                "nats://localhost:4222",
4274            );
4275        }
4276
4277        let builder = ConfigBuilder::builder()
4278            // Enable automatic type parsing for values from environment variables.
4279            .add_source(
4280                Environment::with_prefix("MQB")
4281                    .separator("__")
4282                    .try_parsing(true),
4283            );
4284
4285        let config: Config = builder
4286            .build()
4287            .expect("Failed to build config")
4288            .try_deserialize()
4289            .expect("Failed to deserialize config");
4290
4291        // We can't test all values from env, but we can check the ones we set.
4292        assert_eq!(config.get("kafka_to_nats").unwrap().options.concurrency, 10);
4293        if let EndpointType::Kafka(k) = &config.get("kafka_to_nats").unwrap().input.endpoint_type {
4294            assert_eq!(k.topic, Some("input-topic".to_string()));
4295            assert!(k.tls.required);
4296        } else {
4297            panic!("Expected Kafka endpoint");
4298        }
4299
4300        let input = &config.get("kafka_to_nats").unwrap().input;
4301        assert_eq!(input.middlewares.len(), 1);
4302        if let Middleware::Dlq(_) = &input.middlewares[0] {
4303            // Correctly parsed
4304        } else {
4305            panic!("Expected DLQ middleware");
4306        }
4307    }
4308
4309    #[test]
4310    fn test_extract_secrets() {
4311        let mut config = Config::new();
4312        let mut route = Route::default();
4313
4314        // Setup Kafka with secrets
4315        let mut kafka_config = KafkaConfig::new("kafka://user:pass@localhost:9092");
4316        kafka_config.username = Some("user".to_string());
4317        kafka_config.password = Some("pass".to_string());
4318        kafka_config.tls.cert_password = Some("certpass".to_string());
4319
4320        route.input = Endpoint {
4321            endpoint_type: EndpointType::Kafka(kafka_config),
4322            middlewares: vec![],
4323            handler: None,
4324        };
4325
4326        // Setup HTTP with basic auth
4327        let mut http_config = HttpConfig::new("http://httpuser:httppass@localhost");
4328        http_config.basic_auth = Some(("httpuser".to_string(), "httppass".to_string()));
4329        http_config
4330            .custom_headers
4331            .insert("X-API-Key".to_string(), "http-api-key".to_string());
4332        http_config.custom_headers.insert(
4333            "X-Access-Token".to_string(),
4334            "http-access-token".to_string(),
4335        );
4336        http_config.custom_headers.insert(
4337            "X-Authentication".to_string(),
4338            "http-authentication".to_string(),
4339        );
4340        http_config.custom_headers.insert(
4341            "Authorization".to_string(),
4342            "Bearer secret-token".to_string(),
4343        );
4344        http_config
4345            .custom_headers
4346            .insert("X-Trace-Id".to_string(), "trace-value".to_string());
4347
4348        route.output = Endpoint {
4349            endpoint_type: EndpointType::Http(http_config),
4350            middlewares: vec![],
4351            handler: None,
4352        };
4353
4354        config.insert("test_route".to_string(), route);
4355
4356        let secrets = extract_config_secrets(&mut config);
4357
4358        // Verify secrets extracted
4359        assert_eq!(
4360            secrets
4361                .get("MQB__TEST_ROUTE__INPUT__KAFKA__URL")
4362                .map(|s| s.as_str()),
4363            Some("kafka://user:pass@localhost:9092")
4364        );
4365        assert_eq!(
4366            secrets
4367                .get("MQB__TEST_ROUTE__INPUT__KAFKA__USERNAME")
4368                .map(|s| s.as_str()),
4369            Some("user")
4370        );
4371        assert_eq!(
4372            secrets
4373                .get("MQB__TEST_ROUTE__INPUT__KAFKA__PASSWORD")
4374                .map(|s| s.as_str()),
4375            Some("pass")
4376        );
4377        assert_eq!(
4378            secrets
4379                .get("MQB__TEST_ROUTE__INPUT__KAFKA__TLS__CERT_PASSWORD")
4380                .map(|s| s.as_str()),
4381            Some("certpass")
4382        );
4383        assert_eq!(
4384            secrets
4385                .get("MQB__TEST_ROUTE__OUTPUT__HTTP__URL")
4386                .map(|s| s.as_str()),
4387            Some("http://httpuser:httppass@localhost")
4388        );
4389        assert_eq!(
4390            secrets
4391                .get("MQB__TEST_ROUTE__OUTPUT__HTTP__BASIC_AUTH__0")
4392                .map(|s| s.as_str()),
4393            Some("httpuser")
4394        );
4395        assert_eq!(
4396            secrets
4397                .get("MQB__TEST_ROUTE__OUTPUT__HTTP__BASIC_AUTH__1")
4398                .map(|s| s.as_str()),
4399            Some("httppass")
4400        );
4401        assert_eq!(
4402            secrets
4403                .get("MQB__TEST_ROUTE__OUTPUT__HTTP__CUSTOM_HEADERS__X_API_KEY")
4404                .map(|s| s.as_str()),
4405            Some("http-api-key")
4406        );
4407        assert_eq!(
4408            secrets
4409                .get("MQB__TEST_ROUTE__OUTPUT__HTTP__CUSTOM_HEADERS__X_ACCESS_TOKEN")
4410                .map(|s| s.as_str()),
4411            Some("http-access-token")
4412        );
4413        assert_eq!(
4414            secrets
4415                .get("MQB__TEST_ROUTE__OUTPUT__HTTP__CUSTOM_HEADERS__X_AUTHENTICATION")
4416                .map(|s| s.as_str()),
4417            Some("http-authentication")
4418        );
4419        assert_eq!(
4420            secrets
4421                .get("MQB__TEST_ROUTE__OUTPUT__HTTP__CUSTOM_HEADERS__AUTHORIZATION")
4422                .map(|s| s.as_str()),
4423            Some("Bearer secret-token")
4424        );
4425
4426        // Verify config cleared
4427        let route = config.get("test_route").unwrap();
4428        if let EndpointType::Kafka(k) = &route.input.endpoint_type {
4429            assert!(k.url.is_empty());
4430            assert!(k.username.is_none());
4431            assert!(k.password.is_none());
4432            assert!(k.tls.cert_password.is_none());
4433        }
4434        if let EndpointType::Http(h) = &route.output.endpoint_type {
4435            assert!(h.url.is_empty());
4436            assert!(h.basic_auth.is_none());
4437            assert!(!h.custom_headers.contains_key("X-API-Key"));
4438            assert!(!h.custom_headers.contains_key("X-Access-Token"));
4439            assert!(!h.custom_headers.contains_key("X-Authentication"));
4440            assert!(!h.custom_headers.contains_key("Authorization"));
4441            assert_eq!(
4442                h.custom_headers.get("X-Trace-Id").map(|s| s.as_str()),
4443                Some("trace-value")
4444            );
4445        }
4446    }
4447
4448    #[test]
4449    fn test_extract_sensitive_url_only_strips_authority_credentials() {
4450        let mut config = Config::new();
4451        let path_at_route = Route {
4452            output: Endpoint {
4453                endpoint_type: EndpointType::Http(HttpConfig::new(
4454                    "https://example.com/path/user@example.com?email=a@b.test",
4455                )),
4456                middlewares: vec![],
4457                handler: None,
4458            },
4459            ..Default::default()
4460        };
4461        config.insert("path_at_route".to_string(), path_at_route);
4462
4463        let credential_route = Route {
4464            output: Endpoint {
4465                endpoint_type: EndpointType::Http(HttpConfig::new(
4466                    "https://user:pass@example.com/path",
4467                )),
4468                middlewares: vec![],
4469                handler: None,
4470            },
4471            ..Default::default()
4472        };
4473        config.insert("credential_route".to_string(), credential_route);
4474
4475        let query_at_route = Route {
4476            output: Endpoint {
4477                endpoint_type: EndpointType::Http(HttpConfig::new(
4478                    "https://example.com?next=a@b.test",
4479                )),
4480                middlewares: vec![],
4481                handler: None,
4482            },
4483            ..Default::default()
4484        };
4485        config.insert("query_at_route".to_string(), query_at_route);
4486
4487        let fragment_at_route = Route {
4488            output: Endpoint {
4489                endpoint_type: EndpointType::Http(HttpConfig::new(
4490                    "https://example.com#user@example.com",
4491                )),
4492                middlewares: vec![],
4493                handler: None,
4494            },
4495            ..Default::default()
4496        };
4497        config.insert("fragment_at_route".to_string(), fragment_at_route);
4498
4499        let secrets = extract_config_secrets(&mut config);
4500
4501        if let EndpointType::Http(http) = &config.get("path_at_route").unwrap().output.endpoint_type
4502        {
4503            assert_eq!(
4504                http.url,
4505                "https://example.com/path/user@example.com?email=a@b.test"
4506            );
4507        }
4508        if let EndpointType::Http(http) =
4509            &config.get("query_at_route").unwrap().output.endpoint_type
4510        {
4511            assert_eq!(http.url, "https://example.com?next=a@b.test");
4512        }
4513        if let EndpointType::Http(http) = &config
4514            .get("fragment_at_route")
4515            .unwrap()
4516            .output
4517            .endpoint_type
4518        {
4519            assert_eq!(http.url, "https://example.com#user@example.com");
4520        }
4521        if let EndpointType::Http(http) =
4522            &config.get("credential_route").unwrap().output.endpoint_type
4523        {
4524            assert!(http.url.is_empty());
4525        }
4526        assert_eq!(
4527            secrets
4528                .get("MQB__CREDENTIAL_ROUTE__OUTPUT__HTTP__URL")
4529                .map(String::as_str),
4530            Some("https://user:pass@example.com/path")
4531        );
4532        assert!(!secrets.contains_key("MQB__PATH_AT_ROUTE__OUTPUT__HTTP__URL"));
4533        assert!(!secrets.contains_key("MQB__QUERY_AT_ROUTE__OUTPUT__HTTP__URL"));
4534        assert!(!secrets.contains_key("MQB__FRAGMENT_AT_ROUTE__OUTPUT__HTTP__URL"));
4535    }
4536
4537    #[test]
4538    fn test_memory_config_requires_topic_or_url() {
4539        let err = serde_yaml_ng::from_str::<MemoryConfig>("{}").unwrap_err();
4540        assert!(err
4541            .to_string()
4542            .contains("MemoryConfig: 'topic' (or 'url' alias) is required."));
4543    }
4544
4545    #[test]
4546    fn test_file_config_inference() {
4547        let yaml = r#"
4548mode: group_subscribe
4549path: "/tmp/test"
4550group_id: "my_group"
4551"#;
4552        let config: FileConfig = serde_yaml_ng::from_str(yaml).unwrap();
4553        match config.mode {
4554            Some(FileConsumerMode::GroupSubscribe { group_id, .. }) => {
4555                assert_eq!(group_id, "my_group")
4556            }
4557            _ => panic!("Expected GroupSubscribe"),
4558        }
4559
4560        let yaml_queue = r#"
4561mode: consume
4562path: "/tmp/test"
4563"#;
4564        let config_queue: FileConfig = serde_yaml_ng::from_str(yaml_queue).unwrap();
4565        match config_queue.mode {
4566            Some(FileConsumerMode::Consume { delete }) => assert!(!delete),
4567            _ => panic!("Expected Consume"),
4568        }
4569    }
4570}
4571
4572#[cfg(all(test, feature = "schema"))]
4573mod schema_tests {
4574    use super::*;
4575
4576    #[test]
4577    fn generate_json_schema() {
4578        let schema = schemars::schema_for!(Config);
4579        let schema_json = serde_json::to_string_pretty(&schema).unwrap();
4580
4581        let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
4582        path.push("mq-bridge.schema.json");
4583        std::fs::write(path, schema_json).expect("Failed to write schema file");
4584    }
4585}