Skip to main content

ruststream_lapin/
broker.rs

1//! The broker ladder: [`LapinBroker`] -> [`ConnectedLapinBroker`] -> [`ClosedLapinBroker`].
2//!
3//! Construction is synchronous and I/O-free; the connection is dialled by the consuming
4//! [`Broker::connect`], and the connected form is the only value carrying a subscribe or publish
5//! surface. [`ConnectedBroker::shutdown`] consumes it in turn and returns the terminal witness.
6
7// Without the `testing` feature a connection link has one variant, so a `match` on it has a
8// single arm; the matches stay so that the in-process arm has its place when the feature is on.
9#![cfg_attr(
10    not(feature = "testing"),
11    allow(clippy::infallible_destructuring_match)
12)]
13
14#[cfg(feature = "testing")]
15use std::future::{Future, ready};
16use std::num::NonZeroU16;
17use std::sync::Arc;
18use std::sync::atomic::{AtomicBool, Ordering};
19
20use lapin::options::{BasicConsumeOptions, BasicQosOptions};
21use lapin::types::{FieldTable, ShortString};
22#[cfg(feature = "testing")]
23use lapin::uri::AMQPUri;
24use lapin::{Channel, ChannelState, Connection, ConnectionProperties, ConnectionState, ErrorKind};
25#[cfg(feature = "testing")]
26use ruststream::testing::{Coordinator, InProcess, TestableBroker};
27use ruststream::{
28    AddressedCopies, Broker, ConnectedBroker, DefaultPublish, DescribeServer, ServerSpec, Subscribe,
29};
30#[cfg(feature = "testing")]
31use ruststream::{OutgoingMessage, RawMessage};
32use tokio::runtime::Handle;
33
34use crate::channel::ChannelCell;
35use crate::convert;
36use crate::delay::DelayContext;
37use crate::error::AmqpError;
38#[cfg(feature = "testing")]
39use crate::in_process::{self, Bus};
40#[cfg(feature = "testing")]
41use crate::live_routes::LiveRoutes;
42use crate::publish_policy::{LapinPublish, LapinPublishPolicy};
43#[cfg(feature = "testing")]
44use crate::publish_step::LapinPublishOptions;
45use crate::queue::{QueueDescriptor, RabbitQueue, declared_arguments};
46use crate::requester::{LapinRequest, LapinRequester};
47use crate::subscriber::LapinSubscriber;
48use crate::topology;
49
50/// The protocol name the `AsyncAPI` document reports for this crate.
51pub(crate) const PROTOCOL: &str = "amqp";
52
53/// The wire version behind that name. `RabbitMQ` speaks AMQP 0.9.1, and AMQP 1.0 is a different
54/// protocol with a binding key of its own, so the version is what tells a reader which one a
55/// client has to speak here.
56pub(crate) const PROTOCOL_VERSION: &str = "0.9.1";
57
58/// The live connection plus the shared fire-and-forget publish channel.
59///
60/// Held behind an [`Arc`] by the connected broker and by every publisher, requester, and
61/// subscriber paired off it, so they all speak over the same connection.
62pub(crate) struct AmqpConnection {
63    connection: Connection,
64    publish_channel: ChannelCell<Channel>,
65    closed: AtomicBool,
66    /// The runtime `connect` ran on. Every task the broker starts runs here, such as a
67    /// requester's reply dispatcher, whichever thread makes the request that starts it.
68    runtime: Handle,
69    /// What the test harness needs to tell which subscription a publish through an exchange
70    /// reaches.
71    #[cfg(feature = "testing")]
72    routes: LiveRoutes,
73}
74
75impl AmqpConnection {
76    fn new(connection: Connection, publish_channel: Channel, runtime: Handle) -> Arc<Self> {
77        Arc::new(Self {
78            connection,
79            publish_channel: ChannelCell::holding(publish_channel),
80            closed: AtomicBool::new(false),
81            runtime,
82            #[cfg(feature = "testing")]
83            routes: LiveRoutes::default(),
84        })
85    }
86
87    /// What this connection saw of publishes, bindings and deliveries through exchanges.
88    #[cfg(feature = "testing")]
89    pub(crate) const fn routes(&self) -> &LiveRoutes {
90        &self.routes
91    }
92
93    /// The runtime the broker connected on.
94    pub(crate) const fn runtime(&self) -> &Handle {
95        &self.runtime
96    }
97
98    /// The connection, or [`AmqpError::Closed`] once the broker has shut down.
99    ///
100    /// Why this stays a runtime check: handles paired before the shutdown alias the connection
101    /// and may outlive it, and the typed ladder can only rule out misuse through the owner's
102    /// handle.
103    pub(crate) fn live_connection(&self, target: &str) -> Result<&Connection, AmqpError> {
104        self.ensure_live(target)?;
105        Ok(&self.connection)
106    }
107
108    /// The shared publish channel, or [`AmqpError::Closed`] once the broker has shut down.
109    ///
110    /// Opened again when the one held here is gone. A channel-level error closes the channel and
111    /// leaves the connection up - publishing to an exchange that does not exist is enough, and
112    /// the fire-and-forget publisher does not see that answer - so a channel kept for the
113    /// connection's lifetime would turn one mistyped exchange into a service that never publishes
114    /// again until it is restarted.
115    pub(crate) async fn live_publish_channel(&self, target: &str) -> Result<Channel, AmqpError> {
116        self.ensure_live(target)?;
117        self.publish_channel
118            .get(|| async {
119                self.connection
120                    .create_channel()
121                    .await
122                    .map_err(AmqpError::publish)
123            })
124            .await
125    }
126
127    /// Whether the broker has shut this connection down.
128    pub(crate) fn is_closed(&self) -> bool {
129        self.closed.load(Ordering::Acquire)
130    }
131
132    /// `Ok` while the connection is live, [`AmqpError::Closed`] once the broker has shut down.
133    pub(crate) fn ensure_live(&self, target: &str) -> Result<(), AmqpError> {
134        if self.is_closed() {
135            return Err(AmqpError::closed(target));
136        }
137        Ok(())
138    }
139}
140
141impl std::fmt::Debug for AmqpConnection {
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        f.debug_struct("AmqpConnection")
144            .field("closed", &self.closed.load(Ordering::Relaxed))
145            .finish_non_exhaustive()
146    }
147}
148
149/// What a connected broker and every handle paired off it speak over: the live connection, or,
150/// under the `testing` feature, the in-process transport the test harness connected instead.
151///
152/// Without the feature there is one variant, so the type is the connection handle itself and
153/// every `match` on it is irrefutable: a production build carries no second transport and no
154/// branch to it.
155#[derive(Debug, Clone)]
156pub(crate) enum Link {
157    Amqp(Arc<AmqpConnection>),
158    #[cfg(feature = "testing")]
159    InProcess(Arc<Bus>),
160}
161
162// The zero-cost promise of the in-process mode, held by the compiler: a build without it gives the
163// link exactly the size of the connection handle it wraps.
164#[cfg(not(feature = "testing"))]
165const _: () = assert!(size_of::<Link>() == size_of::<Arc<AmqpConnection>>());
166
167/// A `RabbitMQ` broker backed by [`lapin`](https://docs.rs/lapin): configuration captured, no I/O
168/// performed yet.
169///
170/// [`new`](Self::new) is synchronous and records only the connection settings, so a `RabbitMQ`
171/// service is assembled with the synchronous `#[ruststream::app]` builder like any other broker.
172/// The runtime calls [`Broker::connect`] once at startup, which consumes this value and yields
173/// the [`ConnectedLapinBroker`] witness: subscriptions, publishers, and requesters exist only
174/// from there, so "not connected" is not representable.
175///
176/// By default the broker never creates infrastructure: descriptors describe the EXPECTED
177/// topology, and a missing queue is a subscribe error. Opt into declaration with
178/// [`declare_topology(true)`](Self::declare_topology).
179///
180/// # Examples
181///
182/// ```
183/// use ruststream_lapin::prelude::*;
184/// use serde::Deserialize;
185///
186/// #[derive(Deserialize)]
187/// struct Order {
188///     id: u64,
189/// }
190///
191/// #[subscriber("orders")]
192/// async fn handle(order: &Order) -> HandlerOutcome {
193///     tracing::info!(order.id, "accepted");
194///     HandlerOutcome::ack()
195/// }
196///
197/// fn app() -> RustStream {
198///     let broker = LapinBroker::new("amqp://localhost:5672")
199///         .prefetch(nonzero!(64))
200///         .declare_topology(true);
201///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(broker, |b| {
202///         b.include(handle);
203///     })
204/// }
205/// ```
206///
207/// # Testing
208///
209/// With the `testing` feature, `TestApp::start` runs the service's own app with the broker in
210/// process, and a test addresses it by its production type:
211///
212/// ```
213/// # #[cfg(feature = "testing")]
214/// # mod demo {
215/// use std::error::Error;
216///
217/// use ruststream::testing::TestApp;
218/// use ruststream_lapin::prelude::*;
219/// use serde::{Deserialize, Serialize};
220///
221/// #[derive(Debug, Serialize, Deserialize, PartialEq, Outgoing)]
222/// #[outgoing(name = "payments")]
223/// struct Payment {
224///     amount: u64,
225/// }
226///
227/// #[subscriber(RabbitQueue::new("payments"))]
228/// async fn accept(payment: &Payment) -> HandlerOutcome {
229///     if payment.amount == 0 {
230///         return HandlerOutcome::drop();
231///     }
232///     HandlerOutcome::ack()
233/// }
234///
235/// /// The app `main` runs.
236/// pub fn app() -> RustStream {
237///     RustStream::new(AppInfo::new("payments", "0.1.0"))
238///         .with_broker(LapinBroker::new("amqp://rabbit:5672"), |b| {
239///             b.include(accept);
240///         })
241/// }
242///
243/// pub async fn accepts_a_payment() -> Result<(), Box<dyn Error>> {
244///     let tb = TestApp::start(app()).await?;
245///
246///     // The publish returns once the handler it woke has settled.
247///     tb.broker::<LapinBroker>()
248///         .message(&Payment { amount: 100 })
249///         .publish()
250///         .await?;
251///
252///     tb.broker::<LapinBroker>()
253///         .subscriber("payments")
254///         .assert_called_once()
255///         .with(&Payment { amount: 100 })
256///         .settled(HandlerOutcome::ack());
257///
258///     tb.shutdown().await?;
259///     Ok(())
260/// }
261/// # }
262/// # fn main() {}
263/// ```
264#[derive(Debug, Clone)]
265#[must_use]
266pub struct LapinBroker {
267    uri: String,
268    connection_name: Option<String>,
269    prefetch: Option<NonZeroU16>,
270    declare: bool,
271}
272
273impl LapinBroker {
274    /// Records the connection URI; no I/O happens until [`Broker::connect`].
275    ///
276    /// The URI carries credentials, virtual host, and TLS scheme:
277    /// `amqp://user:pass@host:5672/vhost` (or `amqps://` with a TLS feature enabled).
278    pub fn new(uri: impl Into<String>) -> Self {
279        Self {
280            uri: uri.into(),
281            connection_name: None,
282            prefetch: None,
283            declare: false,
284        }
285    }
286
287    /// A connection name shown in the `RabbitMQ` management UI.
288    pub fn connection_name(mut self, name: impl Into<String>) -> Self {
289        self.connection_name = Some(name.into());
290        self
291    }
292
293    /// Caps unacknowledged deliveries in flight per subscription (`basic.qos`).
294    ///
295    /// This is the back-pressure window for subscriber streams; individual queue descriptors
296    /// can override it. Without it the server imposes no prefetch limit.
297    ///
298    /// The count is a [`NonZeroU16`] because AMQP reads `basic.qos(0)` as "no limit", the exact
299    /// opposite of a cap: the zero sentinel is unrepresentable here, and leaving the prefetch
300    /// unset is how "unlimited" is spelled.
301    pub fn prefetch(mut self, prefetch: NonZeroU16) -> Self {
302        self.prefetch = Some(prefetch);
303        self
304    }
305
306    /// Whether subscribing declares the descriptor's expected topology first. Defaults to
307    /// `false`: managing infrastructure is the user's job, so creation is a deliberate opt-in.
308    ///
309    /// When enabled, subscribing declares the bound exchanges (except the built-in `amq.*`
310    /// ones and the default exchange), the queue, and the bindings.
311    pub fn declare_topology(mut self, declare: bool) -> Self {
312        self.declare = declare;
313        self
314    }
315}
316
317impl Broker for LapinBroker {
318    type Error = AmqpError;
319    type Connected = ConnectedLapinBroker;
320
321    /// Opens the connection and its shared publish channel, consuming the configuration.
322    ///
323    /// # Errors
324    ///
325    /// Returns [`AmqpError::Connect`] when the URI cannot be parsed or the connection fails.
326    async fn connect(self) -> Result<Self::Connected, Self::Error> {
327        let mut properties = ConnectionProperties::default();
328        if let Some(name) = &self.connection_name {
329            properties = properties.with_connection_name(name.as_str().into());
330        }
331        let connection = Connection::connect(&self.uri, properties)
332            .await
333            .map_err(AmqpError::connect)?;
334        let publish_channel = connection
335            .create_channel()
336            .await
337            .map_err(AmqpError::connect)?;
338
339        Ok(ConnectedLapinBroker {
340            link: Link::Amqp(AmqpConnection::new(
341                connection,
342                publish_channel,
343                Handle::current(),
344            )),
345            uri: self.uri,
346            prefetch: self.prefetch,
347            declare: self.declare,
348        })
349    }
350}
351
352/// The in-process mode: the connected form a test runs the production app against, carrying the
353/// in-process transport in place of the connection and every setting of this broker.
354///
355/// The URI is parsed as `connect` parses it, so a broker a service could not connect is not one a
356/// test can connect either.
357#[cfg(feature = "testing")]
358impl InProcess for LapinBroker {
359    fn connect_in_process(
360        self,
361    ) -> impl Future<Output = Result<Self::Connected, Self::Error>> + Send {
362        let connected = self
363            .uri
364            .parse::<AMQPUri>()
365            .map_err(|err| AmqpError::Connect(err.into()))
366            .map(|_| ConnectedLapinBroker {
367                link: Link::InProcess(Bus::new(Handle::current())),
368                uri: self.uri,
369                prefetch: self.prefetch,
370                declare: self.declare,
371            });
372        ready(connected)
373    }
374}
375
376#[cfg(feature = "testing")]
377ruststream::register_testable_broker!(LapinBroker);
378
379/// `DescribeServer` reports the configured AMQP address, which is what the `AsyncAPI` document
380/// records for the service.
381///
382/// The document is published and shared, so the credentials an AMQP URI carries must not reach it.
383/// `ServerSpec::from_url` drops them, along with the scheme and the vhost path.
384impl DescribeServer for LapinBroker {
385    fn describe_server(&self) -> ServerSpec {
386        ServerSpec::from_url(&self.uri, PROTOCOL).protocol_version(PROTOCOL_VERSION)
387    }
388}
389
390/// The typed witness that [`Broker::connect`] succeeded: holds the live connection.
391///
392/// Everything connection-bound hangs off this value: subscriptions ([`Subscribe`],
393/// [`subscribe`](Self::subscribe)), publishers ([`publisher`](Self::publisher)), and requesters
394/// ([`requester`](Self::requester)). [`ConnectedBroker::shutdown`] consumes it, so a publish or
395/// subscribe after shutdown is a compile error for the owner of the handle.
396#[derive(Debug)]
397pub struct ConnectedLapinBroker {
398    link: Link,
399    uri: String,
400    prefetch: Option<NonZeroU16>,
401    declare: bool,
402}
403
404impl ConnectedLapinBroker {
405    /// What every handle paired off this broker speaks over.
406    pub(crate) fn link(&self) -> &Link {
407        &self.link
408    }
409
410    /// The `AsyncAPI` server description of the connection this broker dialled.
411    #[must_use]
412    pub fn server_spec(&self) -> ServerSpec {
413        ServerSpec::from_url(&self.uri, PROTOCOL).protocol_version(PROTOCOL_VERSION)
414    }
415
416    /// Opens a subscription for `def`, declaring its topology first when the broker opted in.
417    ///
418    /// Takes either queue descriptor: [`RabbitQueue`] or
419    /// [`RabbitQuorumQueue`](crate::RabbitQuorumQueue).
420    ///
421    /// # Errors
422    ///
423    /// Returns [`AmqpError::Closed`] after shutdown, [`AmqpError::Declare`] when opted-in
424    /// declaration fails, [`AmqpError::InvalidOptions`] for contradictory descriptor options and
425    /// for a retry declaration this queue cannot carry, and [`AmqpError::Subscribe`] when the
426    /// channel or consumer cannot be opened (for example the queue, or the waiting queue or
427    /// delayed exchange its [`delay`](crate::RabbitQueue::delay) names, does not exist and
428    /// declaration was not opted into).
429    pub async fn subscribe(&self, def: impl QueueDescriptor) -> Result<LapinSubscriber, AmqpError> {
430        let conn = match &self.link {
431            Link::Amqp(conn) => conn,
432            #[cfg(feature = "testing")]
433            Link::InProcess(bus) => return in_process::subscribe(bus, &def, self.declare),
434        };
435        let spec = def.spec();
436        // Before the channel: a declaration the queue cannot carry is the registration's mistake,
437        // and the service should not reach a live consumer with it.
438        def.check_retry(self.declare)?;
439
440        let channel = conn
441            .live_connection(&spec.name)?
442            .create_channel()
443            .await
444            .map_err(AmqpError::subscribe)?;
445
446        if self.declare {
447            let arguments = declared_arguments(spec, def.declared_retry());
448            topology::declare(&channel, spec, arguments).await?;
449        }
450        if let Some(prefetch) = spec.prefetch_or(self.prefetch) {
451            channel
452                .basic_qos(prefetch.get(), BasicQosOptions::default())
453                .await
454                .map_err(AmqpError::subscribe)?;
455        }
456
457        let queue = spec.name.clone();
458        #[cfg(feature = "testing")]
459        conn.routes().bound(&queue, &spec.bindings);
460        // A native delay backend re-publishes the delayed copy on the same channel the delivery is
461        // acked on, so no extra channel is created and the publish orders naturally before the
462        // ack (duplicate-not-loss).
463        let delay = match &spec.delay {
464            Some(delay) => {
465                let target = delay.target_for(&queue);
466                if !self.declare {
467                    target.ensure_exists(&channel, &queue).await?;
468                }
469                Some(DelayContext::new(channel.clone(), target))
470            }
471            None => None,
472        };
473
474        let consumer = channel
475            .basic_consume(
476                convert::short(&queue, "queue name")?,
477                ShortString::default(),
478                BasicConsumeOptions::default(),
479                FieldTable::default(),
480            )
481            .await
482            .map_err(AmqpError::subscribe)?;
483
484        Ok(LapinSubscriber::new(
485            Arc::clone(conn),
486            channel,
487            consumer,
488            queue,
489            spec.batch_wait,
490            delay,
491        ))
492    }
493
494    /// A live publisher for `policy`.
495    ///
496    /// [`LapinPublish`] pairs into the fire-and-forget publisher,
497    /// [`ConfirmsPublish`](crate::ConfirmsPublish) into the confirm-transactional one, and
498    /// [`ServerTxPublish`](crate::ServerTxPublish) into the AMQP-server-transactional one. All
499    /// three are cheap to build and cheap to clone.
500    ///
501    /// # Examples
502    ///
503    /// ```no_run
504    /// use std::error::Error;
505    ///
506    /// use ruststream::Broker;
507    /// use ruststream_lapin::prelude::*;
508    /// use serde::Serialize;
509    ///
510    /// #[derive(Serialize, Outgoing)]
511    /// #[outgoing(name = "order.placed")]
512    /// struct OrderPlaced {
513    ///     id: u64,
514    /// }
515    ///
516    /// // A one-off backfill outside any service: connect, publish, confirm.
517    /// async fn backfill(ids: &[u64]) -> Result<(), Box<dyn Error>> {
518    ///     let connected = LapinBroker::new("amqp://localhost:5672").connect().await?;
519    ///     let events = connected.publisher(LapinPublish::default().exchange("events").confirms());
520    ///     for &id in ids {
521    ///         events.message(&OrderPlaced { id }).publish().await?;
522    ///     }
523    ///     Ok(())
524    /// }
525    /// ```
526    #[must_use]
527    pub fn publisher<P: LapinPublishPolicy>(&self, policy: P) -> P::Live {
528        policy.bind(self)
529    }
530
531    /// A live request/reply client over `RabbitMQ` direct reply-to.
532    ///
533    /// The requester half of [`LapinRequest`]; [`publisher`](Self::publisher) accepts the same
534    /// policy, this accessor only names the result.
535    #[must_use]
536    pub fn requester(&self, policy: LapinRequest) -> LapinRequester {
537        policy.bind(self)
538    }
539}
540
541impl ConnectedBroker for ConnectedLapinBroker {
542    type Error = AmqpError;
543    type Closed = ClosedLapinBroker;
544
545    /// Closes the connection, consuming the connected form.
546    ///
547    /// # Errors
548    ///
549    /// Returns [`AmqpError::Connect`] when the close handshake fails.
550    async fn shutdown(self) -> Result<Self::Closed, Self::Error> {
551        let conn = match self.link {
552            Link::Amqp(conn) => conn,
553            #[cfg(feature = "testing")]
554            Link::InProcess(bus) => {
555                bus.close();
556                return Ok(ClosedLapinBroker { handshake: true });
557            }
558        };
559        // Marked closed before the handshake: a publisher aliasing the connection must not slip
560        // a message into a connection that is already going away.
561        conn.closed.store(true, Ordering::Release);
562        let handshake = conn.connection.status().connected();
563        if handshake {
564            match conn.connection.close(200, ShortString::from("OK")).await {
565                Ok(()) => {}
566                // A subscriber dropped just before the shutdown is still cancelling its consumer,
567                // and lapin refuses to close a channel it is already closing. The connection is
568                // going away either way, so reporting that as a teardown failure would make an
569                // orderly shutdown look broken - and would do so only sometimes, which is worse.
570                Err(err) if already_closing(&err) => {}
571                Err(err) => return Err(AmqpError::connect(err)),
572            }
573        }
574        Ok(ClosedLapinBroker { handshake })
575    }
576}
577
578// By-name subscription capability: the runtime's default `Name` source resolves through this for
579// the bare-string `#[subscriber("queue")]` form.
580//
581// `Self::subscribe` inside this impl would resolve to the trait method and recurse; the type
582// name is the only way to reach the inherent one.
583#[allow(clippy::use_self)]
584impl Subscribe for ConnectedLapinBroker {
585    type Subscriber = LapinSubscriber;
586
587    /// A queue name is an address as well as a subscription: on the default exchange a routing key
588    /// addresses the queue that carries it, so the service publishes a delayed redelivery back
589    /// under the name it subscribed to.
590    ///
591    /// The copy reaches the queue as long as the retry publisher sends on the default exchange,
592    /// which is what [`LapinPublish`] does unless [`exchange`](LapinPublish::exchange) says
593    /// otherwise; pointing that publisher at a topic exchange with no binding under the queue name
594    /// would send the copy nowhere, so bind it there or leave the retry publisher on the default
595    /// exchange.
596    type Copies = AddressedCopies;
597
598    /// Subscribes to the queue `name` with descriptor defaults (durable, shared).
599    async fn subscribe(&self, name: &str) -> Result<Self::Subscriber, Self::Error> {
600        ConnectedLapinBroker::subscribe(self, RabbitQueue::new(name)).await
601    }
602
603    // `declare_retry` keeps the default, which accepts the declaration and leaves the runtime to
604    // apply it. A cap RabbitMQ enforces itself is `x-delivery-limit` with a dead-letter route, and
605    // a queue takes both at declaration time: a name arriving here is a queue that already exists,
606    // so there is nothing to declare them on. `RabbitQuorumQueue` is where they become topology.
607}
608
609impl DefaultPublish for ConnectedLapinBroker {
610    type Policy = LapinPublish;
611}
612
613/// The harness's view of the in-process transport: what it injects, what it reads back, and the
614/// coordinator it counts the in-flight deliveries with.
615///
616/// # Panics
617///
618/// `inject` and `published` panic on a broker connected with `connect`: the harness drives only
619/// the connection `connect_in_process` produced, and a live connection has no log to read and no
620/// synchronous way to take a message.
621#[cfg(feature = "testing")]
622impl TestableBroker for ConnectedLapinBroker {
623    fn install_coordinator(&self, coordinator: Coordinator) {
624        if let Link::InProcess(bus) = &self.link {
625            bus.install(coordinator);
626        }
627    }
628
629    fn inject(&self, message: OutgoingMessage<'_>) {
630        let bus = self.bus("inject");
631        // An external producer publishes on the default exchange, the name addressing the queue,
632        // with nothing but its headers to frame.
633        if let Err(err) = bus.publish(
634            "",
635            message.name(),
636            message.payload(),
637            message.headers(),
638            &LapinPublishOptions::default(),
639        ) {
640            panic!(
641                "the injected message to {:?} is not one the server takes: {err}",
642                message.name()
643            );
644        }
645    }
646
647    fn published(&self, name: &str) -> Vec<RawMessage> {
648        self.bus("published").published(name)
649    }
650
651    /// A queue hands each message to one of its consumers, so a publish to a queue with two
652    /// subscriptions is owed by one of them, never by both.
653    ///
654    /// The broker's default publisher sends on the default exchange, which routes by queue name,
655    /// so the candidates are the subscriptions of `destination`'s name, in the order they opened.
656    /// In process the answer is the consumer the transport's rotation serves next. Against a
657    /// server the pick is the server's and not observable from here, so a live test opens one
658    /// subscription per queue.
659    ///
660    /// Against a server a publish through a named exchange is owed by the subscriptions whose
661    /// queues the service bound to it: by the binding key on a direct exchange, by the pattern on
662    /// a topic exchange, every one on a fanout exchange. A consistent-hash exchange hands a key to
663    /// the one queue its hash picks, so every bound queue owes the publish until the first
664    /// delivery of that key shows which one took it.
665    ///
666    /// # Panics
667    ///
668    /// Panics against a server when `destination` has more than one subscription: the harness
669    /// would wait on a consumer the server may not have picked, and time out without saying why.
670    fn routes(&self, destination: &str, subscriptions: &[&str]) -> Vec<usize> {
671        let candidates: Vec<usize> = subscriptions
672            .iter()
673            .enumerate()
674            .filter(|(_, name)| **name == destination)
675            .map(|(position, _)| position)
676            .collect();
677        let turn = match &self.link {
678            Link::InProcess(bus) => bus.next_consumer_of(destination),
679            Link::Amqp(conn) => {
680                assert!(
681                    candidates.len() <= 1,
682                    "queue {destination:?} has {} subscriptions, and RabbitMQ picks which consumer \
683                     takes a message where the harness cannot see it: a live test opens one \
684                     subscription per queue",
685                    candidates.len()
686                );
687                let mut owed: Vec<usize> = candidates.first().copied().into_iter().collect();
688                for position in conn.routes().through_exchanges(destination, subscriptions) {
689                    if !owed.contains(&position) {
690                        owed.push(position);
691                    }
692                }
693                return owed;
694            }
695        };
696        turn.and_then(|turn| candidates.get(turn).copied())
697            .into_iter()
698            .collect()
699    }
700}
701
702#[cfg(feature = "testing")]
703impl ConnectedLapinBroker {
704    /// The in-process transport, which is all the harness drives.
705    fn bus(&self, what: &str) -> &Arc<Bus> {
706        match &self.link {
707            Link::InProcess(bus) => bus,
708            Link::Amqp(_) => panic!(
709                "TestableBroker::{what} reached a broker connected with `connect`; the harness \
710                 drives the connection `connect_in_process` produces"
711            ),
712        }
713    }
714}
715
716/// Whether this error says the close is already under way rather than that it failed.
717fn already_closing(err: &lapin::Error) -> bool {
718    matches!(
719        err.kind(),
720        ErrorKind::InvalidChannelState(ChannelState::Closing | ChannelState::Closed, _)
721            | ErrorKind::InvalidConnectionState(ConnectionState::Closing | ConnectionState::Closed)
722    )
723}
724
725/// The terminal witness returned by shutting down a [`ConnectedLapinBroker`].
726///
727/// It has no publish or subscribe surface; it carries whether the close handshake actually ran,
728/// which distinguishes an orderly teardown from closing a connection the server had already
729/// dropped.
730#[derive(Debug, Clone, Copy, PartialEq, Eq)]
731pub struct ClosedLapinBroker {
732    handshake: bool,
733}
734
735impl ClosedLapinBroker {
736    /// Whether the AMQP close handshake ran, as opposed to the connection already being down.
737    #[must_use]
738    pub const fn handshake(&self) -> bool {
739        self.handshake
740    }
741}
742
743#[cfg(test)]
744mod tests {
745    use super::{DescribeServer, LapinBroker};
746
747    fn described_host(uri: &str) -> String {
748        LapinBroker::new(uri)
749            .describe_server()
750            .host
751            .expect("an AMQP URI always names a host")
752    }
753
754    // The published document must carry the coordinate and nothing else, whatever an operator put
755    // in the connection URI: no password, no vhost.
756    #[test]
757    fn the_description_is_the_host_alone_whatever_the_uri_carries() {
758        assert_eq!(described_host("amqp://localhost:5672"), "localhost:5672");
759        assert_eq!(
760            described_host("amqp://user:pass@rabbit:5672/prod"),
761            "rabbit:5672"
762        );
763        assert_eq!(described_host("amqps://rabbit/vhost"), "rabbit");
764        assert_eq!(described_host("rabbit:5672"), "rabbit:5672");
765        // A vhost may contain an `@`, so the path is cut before the userinfo is.
766        assert_eq!(described_host("amqp://rabbit:5672/my@vhost"), "rabbit:5672");
767        // Both an `@` in the userinfo and one in the vhost: each is cut at its own step.
768        assert_eq!(
769            described_host("amqp://user:p@ss@rabbit:5672/my@vhost"),
770            "rabbit:5672"
771        );
772        assert_eq!(
773            described_host("amqp://rabbit:5672/prod?heartbeat=30"),
774            "rabbit:5672"
775        );
776    }
777
778    // `new` records the settings without connecting: no server is needed to build the broker or
779    // to describe it, which is what lets it slot into the synchronous app builder.
780    #[test]
781    fn new_performs_no_io_and_describes_the_configured_address() {
782        let spec = LapinBroker::new("amqp://127.0.0.1:5672").describe_server();
783        assert_eq!(spec.protocol, "amqp");
784        assert_eq!(spec.host.as_deref(), Some("127.0.0.1:5672"));
785    }
786}