Skip to main content

mq_bridge/endpoints/
static_endpoint.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
5use crate::models::StaticConfig;
6use crate::traits::{
7    BoxFuture, ConsumerError, MessageConsumer, MessageDisposition, MessagePublisher,
8    PublisherError, Received, ReceivedBatch, Sent, SentBatch,
9};
10use crate::CanonicalMessage;
11use anyhow::Context;
12use async_trait::async_trait;
13use bytes::Bytes;
14use serde_json::Value;
15use std::any::Any;
16use tracing::trace;
17
18/// A sink that responds with a static, pre-configured message.
19#[derive(Clone)]
20pub struct StaticEndpointPublisher {
21    payload: Vec<u8>,
22    content_raw: String,
23    metadata: std::collections::HashMap<String, String>,
24}
25
26impl StaticEndpointPublisher {
27    pub fn new(config: &StaticConfig) -> anyhow::Result<Self> {
28        // In `raw` mode the body is sent verbatim; otherwise it is JSON-encoded
29        // as a string (the historical behaviour, kept for backward compatibility).
30        let payload = if config.raw {
31            config.body.clone().into_bytes()
32        } else {
33            serde_json::to_vec(&Value::String(config.body.clone()))
34                .context("Failed to serialize static response to JSON")?
35        };
36        Ok(Self {
37            payload,
38            content_raw: config.body.clone(),
39            metadata: config.metadata.clone(),
40        })
41    }
42}
43
44#[async_trait]
45impl MessagePublisher for StaticEndpointPublisher {
46    async fn send(&self, _message: CanonicalMessage) -> Result<Sent, PublisherError> {
47        let mut response_msg = CanonicalMessage::new(self.payload.clone(), None);
48        // Attach configured metadata to the response. When this feeds an HTTP
49        // response these become headers (e.g. `content-type`), so the server
50        // emits them instead of defaulting to `application/octet-stream`.
51        for (key, value) in &self.metadata {
52            response_msg.metadata.insert(key.clone(), value.clone());
53        }
54        trace!(
55            message_id = %format!("{:032x}", response_msg.message_id),
56            response = %self.content_raw, "Sending static response"
57        );
58        Ok(Sent::Response(response_msg))
59    }
60
61    async fn send_batch(
62        &self,
63        messages: Vec<CanonicalMessage>,
64    ) -> Result<SentBatch, PublisherError> {
65        crate::traits::send_batch_helper(self, messages, |publisher, message| {
66            Box::pin(publisher.send(message))
67        })
68        .await
69    }
70
71    async fn flush(&self) -> anyhow::Result<()> {
72        Ok(()) // Nothing to flush for a static response
73    }
74
75    fn as_any(&self) -> &dyn Any {
76        self
77    }
78}
79
80/// A source that always produces the same static message.
81#[derive(Clone)]
82pub struct StaticRequestConsumer {
83    payload: Bytes,
84    #[allow(dead_code)]
85    content: String, // Kept for metadata/tracing if needed
86    metadata: std::collections::HashMap<String, String>,
87}
88
89impl StaticRequestConsumer {
90    pub fn new(config: &StaticConfig) -> anyhow::Result<Self> {
91        Ok(Self {
92            payload: Bytes::copy_from_slice(config.body.as_bytes()),
93            content: config.body.clone(),
94            metadata: config.metadata.clone(),
95        })
96    }
97}
98
99#[async_trait]
100impl MessageConsumer for StaticRequestConsumer {
101    async fn receive(&mut self) -> Result<Received, ConsumerError> {
102        let mut message = CanonicalMessage::new_bytes(self.payload.clone(), None);
103        message.metadata = self.metadata.clone();
104        trace!(message_id = %format!("{:032x}", message.message_id), "Producing static message");
105        let commit = Box::new(|_disposition: MessageDisposition| {
106            Box::pin(async { Ok(()) }) as BoxFuture<'static, anyhow::Result<()>>
107        });
108        Ok(Received { message, commit })
109    }
110
111    async fn receive_batch(
112        &mut self,
113        _max_messages: usize,
114    ) -> Result<ReceivedBatch, ConsumerError> {
115        // To properly utilize batching, we generate `_max_messages` here.
116        // Each message still involves cloning the payload and generating a new UUID.
117        let mut messages = Vec::with_capacity(_max_messages);
118        for _ in 0.._max_messages {
119            let mut message = CanonicalMessage::new_bytes(self.payload.clone(), None);
120            message.metadata = self.metadata.clone();
121            messages.push(message);
122        }
123        // For a static consumer, committing is a no-op, so we provide a simple async no-op closure.
124        let commit = Box::new(|_disposition: Vec<MessageDisposition>| {
125            Box::pin(async { Ok(()) }) as BoxFuture<'static, anyhow::Result<()>>
126        });
127        Ok(ReceivedBatch { messages, commit })
128    }
129
130    fn as_any(&self) -> &dyn Any {
131        self
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::CanonicalMessage;
139    use serde_json::Value;
140
141    fn config(body: &str) -> StaticConfig {
142        StaticConfig {
143            body: body.to_string(),
144            raw: false,
145            metadata: std::collections::HashMap::new(),
146        }
147    }
148
149    #[tokio::test]
150    async fn test_static_publisher() {
151        let content = "static_response";
152        let publisher = StaticEndpointPublisher::new(&config(content)).unwrap();
153        let msg = CanonicalMessage::new(vec![], None);
154
155        let response = publisher.send(msg).await.unwrap();
156        let response_msg = match response {
157            Sent::Response(msg) => msg,
158            _ => panic!("Expected response"),
159        };
160        let expected_payload = serde_json::to_vec(&Value::String(content.to_string())).unwrap();
161        assert_eq!(response_msg.payload, expected_payload);
162    }
163
164    #[tokio::test]
165    async fn test_static_publisher_raw_with_metadata() {
166        let mut metadata = std::collections::HashMap::new();
167        metadata.insert("content-type".to_string(), "text/plain".to_string());
168        metadata.insert("server".to_string(), "mq-bridge".to_string());
169        let publisher = StaticEndpointPublisher::new(&StaticConfig {
170            body: "Hello, World!".to_string(),
171            raw: true,
172            metadata,
173        })
174        .unwrap();
175
176        let response = publisher
177            .send(CanonicalMessage::new(vec![], None))
178            .await
179            .unwrap();
180        let response_msg = match response {
181            Sent::Response(msg) => msg,
182            _ => panic!("Expected response"),
183        };
184        // Raw payload: no JSON quoting.
185        assert_eq!(response_msg.payload.as_ref(), b"Hello, World!");
186        assert_eq!(
187            response_msg
188                .metadata
189                .get("content-type")
190                .map(String::as_str),
191            Some("text/plain")
192        );
193        assert_eq!(
194            response_msg.metadata.get("server").map(String::as_str),
195            Some("mq-bridge")
196        );
197    }
198
199    #[tokio::test]
200    async fn test_static_consumer() {
201        let content = "static_message";
202        let mut consumer = StaticRequestConsumer::new(&config(content)).unwrap();
203
204        let received = consumer.receive().await.unwrap();
205        assert_eq!(received.message.payload, content.as_bytes());
206    }
207
208    #[test]
209    fn test_static_config_yaml() {
210        use crate::models::{Config, EndpointType};
211
212        let yaml = r#"
213test_route:
214  input:
215    static: "static_input_value"
216  output:
217    static: "static_output_value"
218"#;
219        let config: Config = serde_yaml_ng::from_str(yaml).expect("Failed to parse YAML");
220        let route = config.get("test_route").expect("Route should exist");
221
222        if let EndpointType::Static(val) = &route.input.endpoint_type {
223            assert_eq!(val.body, "static_input_value");
224        } else {
225            panic!("Input was not static");
226        }
227
228        if let EndpointType::Static(val) = &route.output.endpoint_type {
229            assert_eq!(val.body, "static_output_value");
230        } else {
231            panic!("Output was not static");
232        }
233    }
234
235    #[test]
236    fn test_static_config_simple_serializes_as_bare_string() {
237        // Backward compat: a plain static config must serialize back to a bare
238        // string (not a map), so configs written by this version stay readable
239        // by older versions that only understand `static: "value"`.
240        let cfg = StaticConfig::from("hello");
241        let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
242        assert_eq!(yaml.trim(), "hello");
243
244        let cfg = StaticConfig {
245            body: "hi".to_string(),
246            raw: true,
247            metadata: std::collections::HashMap::new(),
248        };
249        let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
250        assert!(yaml.contains("body: hi"), "expected map form, got: {yaml}");
251        assert!(yaml.contains("raw: true"));
252    }
253
254    #[test]
255    fn test_static_config_map_form() {
256        use crate::models::{Config, EndpointType};
257
258        let yaml = r#"
259test_route:
260  input:
261    static: "in"
262  output:
263    static:
264      body: "Hello, World!"
265      raw: true
266      metadata:
267        content-type: "text/plain"
268"#;
269        let config: Config = serde_yaml_ng::from_str(yaml).expect("Failed to parse YAML");
270        let route = config.get("test_route").expect("Route should exist");
271
272        if let EndpointType::Static(val) = &route.output.endpoint_type {
273            assert_eq!(val.body, "Hello, World!");
274            assert_eq!(
275                val.metadata.get("content-type").map(String::as_str),
276                Some("text/plain")
277            );
278            assert!(val.raw);
279        } else {
280            panic!("Output was not static");
281        }
282    }
283}