Skip to main content

ruststream_lapin/
broker.rs

1//! The broker handle: connection lifecycle, subscriptions, and publisher constructors.
2
3use std::sync::Arc;
4
5use lapin::options::{
6    BasicConsumeOptions, BasicQosOptions, ExchangeDeclareOptions, QueueBindOptions,
7    QueueDeclareOptions,
8};
9use lapin::types::{AMQPValue, FieldTable, ShortString};
10use lapin::{Channel, Connection, ConnectionProperties};
11use ruststream::{Broker, DescribeServer, ServerSpec, Subscribe};
12use tokio::sync::OnceCell;
13
14use crate::convert;
15use crate::delay::{Delay, DelayContext, DelayTarget};
16use crate::error::AmqpError;
17use crate::publisher::LapinPublisher;
18use crate::queue::{QueueType, RabbitQueue};
19use crate::requester::LapinRequester;
20use crate::subscriber::LapinSubscriber;
21
22/// The live connection plus the shared fire-and-forget publish channel.
23#[derive(Debug)]
24pub(crate) struct ConnState {
25    connection: Connection,
26    publish_channel: Channel,
27}
28
29impl ConnState {
30    pub(crate) fn connection(&self) -> &Connection {
31        &self.connection
32    }
33
34    pub(crate) fn publish_channel(&self) -> &Channel {
35        &self.publish_channel
36    }
37}
38
39/// The connection cell shared by the broker and everything it hands out, so publishers obtained
40/// before `Broker::connect` resolve the connection on first use.
41pub(crate) type SharedConn = Arc<OnceCell<ConnState>>;
42
43/// A `RabbitMQ` broker backed by [`lapin`](https://docs.rs/lapin).
44///
45/// Follows the `RustStream` lazy startup contract: [`new`](Self::new) is synchronous and does no
46/// I/O; the network work happens in the idempotent async `Broker::connect`, which the runtime
47/// calls once at startup. Publishers handed out earlier share the connection cell and resolve it
48/// on first use.
49///
50/// By default the broker never creates infrastructure: descriptors describe the EXPECTED
51/// topology, and a missing queue is a subscribe error. Opt into declaration with
52/// [`declare_topology(true)`](Self::declare_topology).
53///
54/// # Examples
55///
56/// ```no_run
57/// use ruststream_lapin::{LapinBroker, QueueType};
58///
59/// let broker = LapinBroker::new("amqp://localhost:5672")
60///     .prefetch(64)
61///     .default_queue_type(QueueType::Quorum);
62/// # let _ = broker;
63/// ```
64#[derive(Debug, Clone)]
65pub struct LapinBroker {
66    conn: SharedConn,
67    uri: String,
68    connection_name: Option<String>,
69    prefetch: Option<u16>,
70    declare: bool,
71    default_queue_type: Option<QueueType>,
72}
73
74impl LapinBroker {
75    /// Records the connection URI; no I/O happens until `Broker::connect`.
76    ///
77    /// The URI carries credentials, virtual host, and TLS scheme:
78    /// `amqp://user:pass@host:5672/vhost` (or `amqps://` with a TLS feature enabled).
79    #[must_use]
80    pub fn new(uri: impl Into<String>) -> Self {
81        Self {
82            conn: Arc::new(OnceCell::new()),
83            uri: uri.into(),
84            connection_name: None,
85            prefetch: None,
86            declare: false,
87            default_queue_type: None,
88        }
89    }
90
91    /// Connects eagerly: [`new`](Self::new) followed by `Broker::connect`.
92    ///
93    /// # Errors
94    ///
95    /// Returns [`AmqpError::Connect`] when the connection cannot be established.
96    pub async fn connect(uri: impl Into<String>) -> Result<Self, AmqpError> {
97        let broker = Self::new(uri);
98        Broker::connect(&broker).await?;
99        Ok(broker)
100    }
101
102    /// A connection name shown in the `RabbitMQ` management UI.
103    #[must_use]
104    pub fn connection_name(mut self, name: impl Into<String>) -> Self {
105        self.connection_name = Some(name.into());
106        self
107    }
108
109    /// Caps unacknowledged deliveries in flight per subscription (`basic.qos`).
110    ///
111    /// This is the back-pressure window for subscriber streams; individual queue descriptors
112    /// can override it. Without it the server imposes no prefetch limit.
113    #[must_use]
114    pub fn prefetch(mut self, prefetch: u16) -> Self {
115        self.prefetch = Some(prefetch);
116        self
117    }
118
119    /// Whether subscribing declares the descriptor's expected topology first. Defaults to
120    /// `false`: managing infrastructure is the user's job, so creation is a deliberate opt-in.
121    ///
122    /// When enabled, subscribing declares the bound exchanges (except the built-in `amq.*`
123    /// ones and the default exchange), the queue, and the bindings.
124    #[must_use]
125    pub fn declare_topology(mut self, declare: bool) -> Self {
126        self.declare = declare;
127        self
128    }
129
130    /// The queue type declared for descriptors that do not set one.
131    ///
132    /// Only consulted when [`declare_topology`](Self::declare_topology) is enabled. Without a
133    /// broker default or a per-queue type, no `x-queue-type` argument is sent and the server
134    /// default applies.
135    #[must_use]
136    pub fn default_queue_type(mut self, queue_type: QueueType) -> Self {
137        self.default_queue_type = Some(queue_type);
138        self
139    }
140
141    fn connected(&self) -> Result<&ConnState, AmqpError> {
142        self.conn.get().ok_or(AmqpError::NotConnected)
143    }
144
145    /// Opens a subscription for `def`, declaring its topology first when the broker opted in.
146    ///
147    /// # Errors
148    ///
149    /// Returns [`AmqpError::NotConnected`] before `Broker::connect`, [`AmqpError::Declare`] when
150    /// opted-in declaration fails, [`AmqpError::InvalidOptions`] for contradictory descriptor
151    /// options, and [`AmqpError::Subscribe`] when the channel or consumer cannot be opened (for
152    /// example the queue does not exist and declaration was not opted into).
153    pub async fn subscribe(&self, def: RabbitQueue) -> Result<LapinSubscriber, AmqpError> {
154        let state = self.connected()?;
155        let channel = state
156            .connection
157            .create_channel()
158            .await
159            .map_err(AmqpError::subscribe)?;
160
161        if self.declare {
162            declare_topology(&channel, &def, self.default_queue_type).await?;
163        }
164        if let Some(prefetch) = def.prefetch_or(self.prefetch) {
165            channel
166                .basic_qos(prefetch, BasicQosOptions::default())
167                .await
168                .map_err(AmqpError::subscribe)?;
169        }
170
171        let queue = def.name().to_owned();
172        // A native delay backend re-publishes the delayed copy on the same channel the delivery is
173        // acked on, so no extra channel is created and the publish orders naturally before the
174        // ack (duplicate-not-loss).
175        let delay = def
176            .delay_config()
177            .map(|delay| DelayContext::new(channel.clone(), delay.target_for(&queue)));
178
179        let consumer = channel
180            .basic_consume(
181                convert::short(&queue, "queue name")?,
182                ShortString::default(),
183                BasicConsumeOptions::default(),
184                FieldTable::default(),
185            )
186            .await
187            .map_err(AmqpError::subscribe)?;
188
189        Ok(LapinSubscriber::new(channel, consumer, queue, delay))
190    }
191
192    /// A fire-and-forget publisher on the shared publish channel.
193    ///
194    /// Upgrade with [`confirms`](LapinPublisher::confirms) or
195    /// [`server_tx`](LapinPublisher::server_tx) for transactional publishing.
196    #[must_use]
197    pub fn publisher(&self) -> LapinPublisher {
198        LapinPublisher::new(Arc::clone(&self.conn))
199    }
200
201    /// A request/reply client over `RabbitMQ` direct reply-to.
202    #[must_use]
203    pub fn requester(&self) -> LapinRequester {
204        LapinRequester::new(Arc::clone(&self.conn))
205    }
206}
207
208impl Broker for LapinBroker {
209    type Error = AmqpError;
210
211    /// Establishes the connection and the shared publish channel; idempotent.
212    ///
213    /// # Errors
214    ///
215    /// Returns [`AmqpError::Connect`] when the URI cannot be parsed or the connection fails.
216    async fn connect(&self) -> Result<(), Self::Error> {
217        self.conn
218            .get_or_try_init(|| async {
219                let mut properties = ConnectionProperties::default();
220                if let Some(name) = &self.connection_name {
221                    properties = properties.with_connection_name(name.as_str().into());
222                }
223                let connection = Connection::connect(&self.uri, properties)
224                    .await
225                    .map_err(AmqpError::connect)?;
226                let publish_channel = connection
227                    .create_channel()
228                    .await
229                    .map_err(AmqpError::connect)?;
230                Ok(ConnState {
231                    connection,
232                    publish_channel,
233                })
234            })
235            .await?;
236        Ok(())
237    }
238
239    /// Closes the connection; further operations fail with [`AmqpError::NotConnected`] or a
240    /// channel error. Idempotent: closing an already-closed connection succeeds.
241    ///
242    /// # Errors
243    ///
244    /// Returns [`AmqpError::Connect`] when the close handshake fails.
245    async fn shutdown(&self) -> Result<(), Self::Error> {
246        if let Some(state) = self.conn.get()
247            && state.connection.status().connected()
248        {
249            state
250                .connection
251                .close(200, ShortString::from("OK"))
252                .await
253                .map_err(AmqpError::connect)?;
254        }
255        Ok(())
256    }
257}
258
259// `Self::subscribe` inside this impl would resolve to the trait method and recurse; the type
260// name is the only way to reach the inherent one.
261#[allow(clippy::use_self)]
262impl Subscribe for LapinBroker {
263    type Subscriber = LapinSubscriber;
264
265    /// Subscribes to the queue `name` with descriptor defaults (durable, shared).
266    async fn subscribe(&self, name: &str) -> Result<Self::Subscriber, Self::Error> {
267        LapinBroker::subscribe(self, RabbitQueue::new(name)).await
268    }
269}
270
271impl DescribeServer for LapinBroker {
272    fn describe_server(&self) -> ServerSpec {
273        ServerSpec::new(host_of(&self.uri), "amqp")
274    }
275}
276
277/// Extracts the `host[:port]` part of an AMQP URI for `AsyncAPI` metadata; never fails, because
278/// metadata must not block startup on a URI the connection itself will reject anyway.
279fn host_of(uri: &str) -> String {
280    let after_scheme = uri.split_once("://").map_or(uri, |(_, rest)| rest);
281    let after_auth = after_scheme
282        .rsplit_once('@')
283        .map_or(after_scheme, |(_, rest)| rest);
284    let host = after_auth.split(['/', '?']).next().unwrap_or(after_auth);
285    host.to_owned()
286}
287
288async fn declare_topology(
289    channel: &Channel,
290    def: &RabbitQueue,
291    broker_default: Option<QueueType>,
292) -> Result<(), AmqpError> {
293    for (exchange, _) in def.bindings() {
294        // The default exchange and the amq.* built-ins exist on every broker and must not be
295        // redeclared.
296        if exchange.name().is_empty() || exchange.name().starts_with("amq.") {
297            continue;
298        }
299        channel
300            .exchange_declare(
301                convert::short(exchange.name(), "exchange name")?,
302                exchange.kind().clone(),
303                ExchangeDeclareOptions {
304                    durable: exchange.is_durable(),
305                    auto_delete: exchange.is_auto_delete(),
306                    ..ExchangeDeclareOptions::default()
307                },
308                FieldTable::default(),
309            )
310            .await
311            .map_err(AmqpError::declare)?;
312    }
313
314    let queue_type = def.queue_type_or(broker_default);
315    if queue_type == Some(QueueType::Quorum) && !def.is_durable() {
316        return Err(AmqpError::InvalidOptions(format!(
317            "queue {:?} is a quorum queue and must stay durable; drop `.durable(false)` or pick \
318             `QueueType::Classic`",
319            def.name(),
320        )));
321    }
322
323    let mut arguments = def.declare_arguments().clone();
324    if let Some(queue_type) = queue_type {
325        arguments.insert(
326            ShortString::from("x-queue-type"),
327            AMQPValue::LongString(queue_type.as_str().into()),
328        );
329    }
330    channel
331        .queue_declare(
332            convert::short(def.name(), "queue name")?,
333            QueueDeclareOptions {
334                durable: def.is_durable(),
335                exclusive: def.is_exclusive(),
336                auto_delete: def.is_auto_delete(),
337                ..QueueDeclareOptions::default()
338            },
339            arguments,
340        )
341        .await
342        .map_err(AmqpError::declare)?;
343
344    for (exchange, routing_key) in def.bindings() {
345        channel
346            .queue_bind(
347                convert::short(def.name(), "queue name")?,
348                convert::short(exchange.name(), "exchange name")?,
349                convert::short(routing_key, "routing key")?,
350                QueueBindOptions::default(),
351                FieldTable::default(),
352            )
353            .await
354            .map_err(AmqpError::declare)?;
355    }
356
357    if let Some(delay) = def.delay_config() {
358        declare_delay_backend(channel, delay, def.name()).await?;
359    }
360
361    Ok(())
362}
363
364/// Declares the infrastructure the delay backend needs to route a delayed copy back to `origin`.
365async fn declare_delay_backend(
366    channel: &Channel,
367    delay: &Delay,
368    origin: &str,
369) -> Result<(), AmqpError> {
370    match delay.target_for(origin) {
371        DelayTarget::WaitingQueue { waiting_queue } => {
372            declare_delay_queue(channel, &waiting_queue, origin).await
373        }
374        #[cfg(feature = "plugin-dme")]
375        DelayTarget::DelayedExchange {
376            exchange,
377            routing_key,
378        } => declare_delayed_exchange(channel, &exchange, origin, &routing_key).await,
379    }
380}
381
382/// Declares the delay waiting queue: durable, with a per-message TTL applied by the sender and a
383/// dead-letter route back to `origin` on the default exchange (so an expired message returns to
384/// the queue it came from).
385async fn declare_delay_queue(
386    channel: &Channel,
387    waiting_queue: &str,
388    origin: &str,
389) -> Result<(), AmqpError> {
390    let mut arguments = FieldTable::default();
391    arguments.insert(
392        ShortString::from("x-dead-letter-exchange"),
393        AMQPValue::LongString(String::new().into()),
394    );
395    arguments.insert(
396        ShortString::from("x-dead-letter-routing-key"),
397        AMQPValue::LongString(origin.into()),
398    );
399    channel
400        .queue_declare(
401            convert::short(waiting_queue, "waiting queue name")?,
402            QueueDeclareOptions {
403                durable: true,
404                ..QueueDeclareOptions::default()
405            },
406            arguments,
407        )
408        .await
409        .map_err(AmqpError::declare)?;
410    Ok(())
411}
412
413/// Declares the `x-delayed-message` exchange (direct-typed) and binds `origin` to it under
414/// `routing_key`, so a delayed copy the plugin releases returns to the origin queue.
415#[cfg(feature = "plugin-dme")]
416async fn declare_delayed_exchange(
417    channel: &Channel,
418    exchange: &str,
419    origin: &str,
420    routing_key: &str,
421) -> Result<(), AmqpError> {
422    let mut arguments = FieldTable::default();
423    // The delayed exchange wraps an underlying routing type; direct routes by the exact key.
424    arguments.insert(
425        ShortString::from("x-delayed-type"),
426        AMQPValue::LongString("direct".into()),
427    );
428    channel
429        .exchange_declare(
430            convert::short(exchange, "delayed exchange name")?,
431            lapin::ExchangeKind::Custom("x-delayed-message".to_owned()),
432            ExchangeDeclareOptions {
433                durable: true,
434                ..ExchangeDeclareOptions::default()
435            },
436            arguments,
437        )
438        .await
439        .map_err(AmqpError::declare)?;
440    channel
441        .queue_bind(
442            convert::short(origin, "queue name")?,
443            convert::short(exchange, "delayed exchange name")?,
444            convert::short(routing_key, "routing key")?,
445            QueueBindOptions::default(),
446            FieldTable::default(),
447        )
448        .await
449        .map_err(AmqpError::declare)?;
450    Ok(())
451}
452
453#[cfg(test)]
454mod tests {
455    use super::host_of;
456
457    #[test]
458    fn host_extraction_handles_auth_vhost_and_bare_forms() {
459        assert_eq!(host_of("amqp://localhost:5672"), "localhost:5672");
460        assert_eq!(host_of("amqp://user:pass@rabbit:5672/prod"), "rabbit:5672");
461        assert_eq!(host_of("amqps://rabbit/vhost"), "rabbit");
462        assert_eq!(host_of("rabbit:5672"), "rabbit:5672");
463    }
464}