ruststream_rumqttc/publisher.rs
1//! [`MqttPublisher`], its [`MqttPublish`] policy, and the per-message settings.
2
3// Without the `testing` feature a link has one variant, so a `match` on it has a single arm; the
4// match stays so that the in-process arm has its place when the feature is on.
5#![cfg_attr(
6 not(feature = "testing"),
7 allow(clippy::infallible_destructuring_match)
8)]
9
10use std::fmt;
11use std::future::{Future, ready};
12
13use bytes::Bytes;
14use rumqttc::v5::mqttbytes::v5::PublishProperties;
15use rumqttc::v5::mqttbytes::valid_topic;
16#[cfg(feature = "asyncapi")]
17use ruststream::asyncapi::Bindings;
18use ruststream::runtime::{PublishBuilder, PublishSink};
19use ruststream::{BytesMut, OutgoingMessage, PairError, PublishPolicy, Publisher, Take};
20
21#[cfg(feature = "asyncapi")]
22use crate::asyncapi;
23use crate::broker::{ConnectedMqttBroker, CoreCell, Link};
24use crate::error::MqttError;
25use crate::filter::Qos;
26use crate::message::{is_mqtt_text, to_wire_properties};
27
28/// Refuses a topic no server takes, before any I/O.
29///
30/// The client's send-path error cannot say why a request failed, so the topic is validated here; a
31/// remaining failure unambiguously means the connection is gone. The client passes an empty topic
32/// through, and a server answers that packet by closing the session, which would take every other
33/// message in flight with it.
34pub(crate) fn check_topic(topic: &str) -> Result<(), MqttError> {
35 let reason = if topic.is_empty() {
36 "an MQTT topic must not be empty"
37 } else if !valid_topic(topic) {
38 "not a valid MQTT topic (wildcards are subscribe-only)"
39 } else if !is_mqtt_text(topic) {
40 "not a valid MQTT topic (it carries a control character or a Unicode non-character)"
41 } else {
42 return Ok(());
43 };
44 Err(MqttError::Publish {
45 topic: topic.to_owned(),
46 reason: reason.to_owned(),
47 })
48}
49
50/// The single send path: every publishing form resolves to a `QoS` and a retain flag, and the
51/// wire work happens here once.
52async fn send(
53 cell: &CoreCell,
54 qos: Qos,
55 retain: bool,
56 msg: OutgoingMessage<'_, BytesMut>,
57) -> Result<(), MqttError> {
58 let core = cell.get().ok_or(MqttError::NotConnected)?;
59 core.shared.ensure_open()?;
60 check_topic(msg.name())?;
61 let topic = msg.name();
62 let (payload, properties) = into_packet(msg)?;
63 let client = match &core.link {
64 Link::Wire(client) => client,
65 #[cfg(feature = "testing")]
66 Link::InProcess(bus) => {
67 bus.publish(&core.shared, topic, qos, retain, payload, properties);
68 return Ok(());
69 }
70 };
71 let outcome = match properties {
72 Some(properties) => {
73 client
74 .publish_bytes_with_properties(topic, qos.to_client(), retain, payload, properties)
75 .await
76 }
77 None => {
78 client
79 .publish_bytes(topic, qos.to_client(), retain, payload)
80 .await
81 }
82 };
83 outcome.map_err(|_| MqttError::Publish {
84 topic: topic.to_owned(),
85 reason: "the mqtt connection task has shut down".to_owned(),
86 })
87}
88
89/// What a PUBLISH packet carries, taken out of the message in one move: the payload the session
90/// keeps and the MQTT 5 properties the headers map onto.
91fn into_packet(
92 msg: OutgoingMessage<'_, BytesMut>,
93) -> Result<(Bytes, Option<PublishProperties>), MqttError> {
94 let properties = to_wire_properties(&msg)?;
95 Ok((msg.into_payload().freeze(), properties))
96}
97
98/// Publishes messages to MQTT topics through the shared connection.
99///
100/// The publish is queued into the client session, so `Ok` means "owned by the session", not
101/// "broker confirmed". For `QoS` 1/2 the client keeps the message until the broker acknowledges
102/// it, across reconnects: a resumed session retransmits it, and a new session sends it again,
103/// where it may arrive twice. Buildable before `connect` and usable until `shutdown`; afterwards
104/// every publish reports [`MqttError::NotConnected`].
105///
106/// Headers are sent as MQTT 5 user properties, so non-Rust peers see plain MQTT messages. The
107/// `content-type`, `reply-to` and `correlation-id` headers take the matching first-class
108/// properties (content type, response topic, correlation data), in both directions, and a
109/// message with no headers carries no properties at all. A textual content type
110/// (`application/json`, any `text/` subtype, any `+json` vendor type) sets the payload format
111/// indicator to 1, and every other one to 0. A header name or value that is not text an MQTT
112/// string carries refuses the publish with [`MqttError::Publish`]; the correlation data is binary
113/// on the wire and takes any bytes.
114///
115/// # Examples
116///
117/// Nothing writes the media type for a service: a reply, a deferred retry copy and a plain
118/// publish carry the headers something put on them. A service whose peers read the content type
119/// property sets it once at the mount site, through a publish transform over the position:
120///
121/// ```
122/// # mod demo {
123/// use ruststream::runtime::{Outgoing, Reads};
124/// use ruststream_rumqttc::prelude::*;
125/// use serde::{Deserialize, Serialize};
126///
127/// /// States the media type of every message this position sends, which is what fills the content
128/// /// type property and decides the payload format indicator. It reads nothing about the delivery,
129/// /// so it is generic over the position and mounts on any of them.
130/// struct Json;
131///
132/// impl<K: ContextKind, Options> PublishTransform<K, Options> for Json {
133/// type Destination = Reads;
134///
135/// fn apply(&self, out: &mut Outgoing<'_>, _options: &mut Option<Options>, _cx: &K::View<'_>) {
136/// out.headers_mut().insert("content-type", "application/json");
137/// }
138/// }
139///
140/// #[derive(Deserialize)]
141/// struct Telemetry {
142/// device: String,
143/// temperature: f64,
144/// }
145///
146/// #[derive(Serialize, Outgoing)]
147/// #[outgoing(name = "devices/state")]
148/// struct DeviceState {
149/// device: String,
150/// hot: bool,
151/// }
152///
153/// #[subscriber(MqttFilter::new("devices/+/telemetry"), reply)]
154/// async fn track(telemetry: &Telemetry) -> DeviceState {
155/// DeviceState { device: telemetry.device.clone(), hot: telemetry.temperature > 30.0 }
156/// }
157///
158/// #[ruststream::app]
159/// fn app() -> impl App {
160/// RustStream::new(AppInfo::new("states", "0.1.0")).with_broker(
161/// MqttBroker::new("mqtt://localhost:1883", "states-svc"),
162/// |b| {
163/// // Every reply now carries the content type property and the payload format
164/// // indicator 1, so a peer reads the body as UTF-8 JSON.
165/// b.include(track)
166/// .out_reply(Publish::default())
167/// .transform(Json)
168/// .out_retry(Publish::default())
169/// .to("devices/retry/telemetry");
170/// },
171/// )
172/// }
173/// # }
174/// # fn main() {}
175/// ```
176#[derive(Clone)]
177pub struct MqttPublisher {
178 cell: CoreCell,
179 qos: Qos,
180 retain: bool,
181}
182
183impl fmt::Debug for MqttPublisher {
184 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
185 f.debug_struct("MqttPublisher")
186 .field("qos", &self.qos)
187 .field("retain", &self.retain)
188 .finish_non_exhaustive()
189 }
190}
191
192impl MqttPublisher {
193 pub(crate) fn new(cell: CoreCell, qos: Qos, retain: bool) -> Self {
194 Self { cell, qos, retain }
195 }
196}
197
198impl Publisher for MqttPublisher {
199 /// The client keeps the payload: a PUBLISH packet is queued into the session, which holds
200 /// the `Bytes` until the broker acknowledges it.
201 type Payload = Take;
202
203 type Error = MqttError;
204 type Options = MqttPublishOptions;
205
206 async fn publish(
207 &self,
208 msg: OutgoingMessage<'_, BytesMut>,
209 options: Option<&Self::Options>,
210 ) -> Result<(), Self::Error> {
211 let (qos, retain) = MqttPublishOptions::resolve(options, self.qos, self.retain);
212 send(&self.cell, qos, retain, msg).await
213 }
214}
215
216/// The two arguments MQTT carries on every PUBLISH packet, as one message asked for them.
217///
218/// This is [`Publisher::Options`] for every publisher this crate hands out. An argument left
219/// unset keeps what the [`MqttPublish`] policy fixed at the mount site, so a call carries only
220/// what it changed, and a path with no call site at all - a reply, the runtime's deferred
221/// redelivery - carries nothing and publishes entirely under the policy.
222///
223/// A service writes [the steps](MqttPublishSteps) rather than this type; it is named in a test
224/// asserting what a publish carried (`with_options`) and in the
225/// `Out<impl Publisher<Options = MqttPublishOptions>, Marker>` bound of a handler body that takes
226/// one.
227///
228/// # Examples
229///
230/// ```
231/// # #[cfg(feature = "testing")]
232/// # mod demo {
233/// use std::error::Error;
234///
235/// use ruststream::testing::TestApp;
236/// use ruststream_rumqttc::prelude::*;
237/// use serde::{Deserialize, Serialize};
238///
239/// #[derive(Deserialize, Serialize, Outgoing)]
240/// struct Telemetry {
241/// temperature: f64,
242/// }
243///
244/// #[derive(Outgoing, Serialized)]
245/// #[outgoing(name = "devices/dev42/state")]
246/// struct DeviceState(Vec<u8>);
247///
248/// #[derive(OutSlot)]
249/// #[publishes(DeviceState)]
250/// struct States;
251///
252/// #[subscriber("devices/dev42/telemetry")]
253/// async fn announce(
254/// telemetry: &Telemetry,
255/// Out(states): Out<impl Publisher<Options = MqttPublishOptions>, States>,
256/// ) -> HandlerOutcome {
257/// let state: &[u8] = if telemetry.temperature > 30.0 { b"hot" } else { b"ok" };
258/// if states.message(&DeviceState(state.to_vec())).retain(true).publish().await.is_err() {
259/// return HandlerOutcome::retry();
260/// }
261/// HandlerOutcome::ack()
262/// }
263///
264/// pub fn app() -> impl App {
265/// RustStream::new(AppInfo::new("states", "0.1.0")).with_broker(
266/// MqttBroker::new("mqtt://localhost:1883", "states-svc"),
267/// |b| {
268/// b.include(announce).out(States, Publish::default()).build();
269/// },
270/// )
271/// }
272///
273/// pub async fn the_state_is_published_retained() -> Result<(), Box<dyn Error>> {
274/// let tb = TestApp::start(app()).await?;
275///
276/// tb.broker::<MqttBroker>()
277/// .message(&Telemetry { temperature: 31.5 })
278/// .to("devices/dev42/telemetry")
279/// .publish()
280/// .await?;
281///
282/// tb.out::<States>()
283/// .assert_called_once()
284/// .with_raw(b"hot")
285/// .with_options(&MqttPublishOptions::default().retain(true));
286/// tb.shutdown().await?;
287/// Ok(())
288/// }
289/// # }
290/// # #[cfg(feature = "testing")]
291/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
292/// # tokio::runtime::Builder::new_multi_thread()
293/// # .enable_all()
294/// # .build()?
295/// # .block_on(demo::the_state_is_published_retained())
296/// # }
297/// # #[cfg(not(feature = "testing"))]
298/// # fn main() {}
299/// ```
300#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
301#[must_use]
302pub struct MqttPublishOptions {
303 qos: Option<Qos>,
304 retain: Option<bool>,
305}
306
307impl MqttPublishOptions {
308 /// Sends this one message at `qos` instead of the policy's.
309 pub const fn qos(mut self, qos: Qos) -> Self {
310 self.qos = Some(qos);
311 self
312 }
313
314 /// Sends this one message retained (or explicitly not retained), whatever the policy declares.
315 ///
316 /// A retained message is the last one the broker keeps per topic and hands to each new
317 /// subscriber on a matching filter; publishing an empty payload retained clears it.
318 pub const fn retain(mut self, retain: bool) -> Self {
319 self.retain = Some(retain);
320 self
321 }
322
323 /// Resolves one call's arguments over the defaults its publisher was paired with.
324 pub(crate) fn resolve(options: Option<&Self>, qos: Qos, retain: bool) -> (Qos, bool) {
325 let options = options.copied().unwrap_or_default();
326 (options.qos.unwrap_or(qos), options.retain.unwrap_or(retain))
327 }
328}
329
330/// The per-message steps of an MQTT publish, on the publish builder itself.
331///
332/// Both arguments MQTT carries on a PUBLISH packet are reopened at the call site:
333/// `out.message(&state).retain(true).publish()`. The steps sit on the builder rather than
334/// wrapping the publisher, so the publish still leaves through the mount site's own entry - with
335/// the codec that entry named, and attributed to the slot it belongs to.
336///
337/// The bound is on the sink's [options type](Publisher::Options), so these steps appear on a
338/// builder over an MQTT publisher and on no other broker's. A handler body that takes one imports
339/// this crate's prelude and bounds its slot with
340/// `Out<impl Publisher<Options = MqttPublishOptions>, Marker>`; everywhere else - a startup hook,
341/// a test - the steps are already there on the builder the publisher hands out.
342///
343/// # Examples
344///
345/// ```
346/// # mod demo {
347/// use ruststream::runtime::PublishError;
348/// use ruststream_rumqttc::MqttError;
349/// use ruststream_rumqttc::prelude::*;
350/// use serde::Deserialize;
351///
352/// #[derive(Deserialize)]
353/// struct Telemetry {
354/// device: String,
355/// temperature: f64,
356/// }
357///
358/// /// An MQTT state is bytes on the wire rather than an encoded model, so the type carries its own
359/// /// bytes and no codec runs on them; the `{device}` segment becomes a setter the call fills in.
360/// #[derive(Outgoing, Serialized)]
361/// #[outgoing(name = "devices/{device}/state")]
362/// struct DeviceState(Vec<u8>);
363///
364/// #[derive(OutSlot)]
365/// #[publishes(DeviceState)]
366/// struct States;
367///
368/// /// The reading is one of many; the state it puts the device in is what a service joining later
369/// /// must see. So the state is published retained, and only the state.
370/// #[subscriber(MqttFilter::new("devices/+/telemetry").qos(Qos::AtLeastOnce))]
371/// async fn announce_state(
372/// telemetry: &Telemetry,
373/// Out(states): Out<impl Publisher<Options = MqttPublishOptions>, States>,
374/// ) -> HandlerOutcome {
375/// let state = if telemetry.temperature > 30.0 { "hot" } else { "ok" };
376/// if states
377/// .message(&DeviceState(state.as_bytes().to_vec()))
378/// .retain(true)
379/// .to()
380/// .device(&telemetry.device)
381/// .publish()
382/// .await
383/// .is_err()
384/// {
385/// return HandlerOutcome::retry();
386/// }
387/// HandlerOutcome::ack()
388/// }
389///
390/// #[ruststream::app]
391/// fn app() -> impl App {
392/// RustStream::new(AppInfo::new("retained", "0.1.0")).with_broker(
393/// MqttBroker::new("mqtt://localhost:1883", "retained-example"),
394/// |b| {
395/// // The policy publishes plain; this one announcement is retained and exactly once,
396/// // so a subscriber that joins later still sees the device online. The hook runs once
397/// // the broker is connected, so the announcement cannot race it.
398/// b.after_startup(
399/// Publish::default(),
400/// async move |publisher| -> Result<(), PublishError<MqttError>> {
401/// publisher
402/// .message(&DeviceState(b"online".to_vec()))
403/// .retain(true)
404/// .qos(Qos::ExactlyOnce)
405/// .to()
406/// .device("dev42")
407/// .publish()
408/// .await
409/// },
410/// );
411/// // The mount site fixes what every publish through the slot takes; the handler's
412/// // step is what one message changes.
413/// b.include(announce_state)
414/// .out(States, Publish::default().qos(Qos::AtLeastOnce))
415/// .out_retry(Publish::default())
416/// .to("devices/retry/telemetry")
417/// .build();
418/// },
419/// )
420/// }
421/// # }
422/// # fn main() {}
423/// ```
424pub trait MqttPublishSteps {
425 /// Sends this one message at `qos` instead of the publisher's own.
426 #[must_use]
427 fn qos(self, qos: Qos) -> Self;
428
429 /// Sends this one message retained (or explicitly not retained), whatever the publisher's
430 /// policy declares.
431 #[must_use]
432 fn retain(self, retain: bool) -> Self;
433}
434
435impl<Sink, Body, Enc, Hdrs, Dest> MqttPublishSteps for PublishBuilder<Sink, Body, Enc, Hdrs, Dest>
436where
437 Sink: PublishSink<Options = MqttPublishOptions>,
438{
439 fn qos(mut self, qos: Qos) -> Self {
440 self.options_mut()
441 .get_or_insert_with(MqttPublishOptions::default)
442 .qos = Some(qos);
443 self
444 }
445
446 fn retain(mut self, retain: bool) -> Self {
447 self.options_mut()
448 .get_or_insert_with(MqttPublishOptions::default)
449 .retain = Some(retain);
450 self
451 }
452}
453
454/// The publish policy for [`MqttPublisher`]: quality of service and the retain flag as pure
455/// declaration, paired with the connected broker by the runtime after `connect`.
456///
457/// # Examples
458///
459/// ```
460/// # mod demo {
461/// use ruststream_rumqttc::{MqttBroker, MqttFilter, MqttPublish, Qos};
462/// use ruststream::prelude::*;
463/// use serde::{Deserialize, Serialize};
464///
465/// #[derive(Deserialize)]
466/// struct Telemetry {
467/// device: String,
468/// temperature: f64,
469/// }
470///
471/// #[derive(Serialize, Outgoing)]
472/// #[outgoing(name = "devices/state")]
473/// struct DeviceState {
474/// device: String,
475/// hot: bool,
476/// }
477///
478/// #[subscriber(MqttFilter::new("devices/+/telemetry"), reply)]
479/// async fn track(telemetry: &Telemetry) -> DeviceState {
480/// DeviceState { device: telemetry.device.clone(), hot: telemetry.temperature > 30.0 }
481/// }
482///
483/// #[ruststream::app]
484/// fn app() -> impl App {
485/// RustStream::new(AppInfo::new("states", "0.1.0")).with_broker(
486/// MqttBroker::new("mqtt://localhost:1883", "states-svc"),
487/// |b| {
488/// // Every reply is retained, so a dashboard that starts later reads the last state.
489/// b.include(track)
490/// .out_reply(MqttPublish::default().qos(Qos::ExactlyOnce).retain(true))
491/// .out_retry(MqttPublish::default())
492/// .to("devices/retry/telemetry");
493/// },
494/// )
495/// }
496/// # }
497/// # fn main() {}
498/// ```
499#[derive(Debug, Clone, Copy, Default)]
500#[must_use]
501pub struct MqttPublish {
502 qos: Qos,
503 retain: bool,
504}
505
506impl MqttPublish {
507 /// Sets the delivery quality of service. Defaults to [`Qos::AtLeastOnce`].
508 pub fn qos(mut self, qos: Qos) -> Self {
509 self.qos = qos;
510 self
511 }
512
513 /// Publishes messages as retained: the broker keeps the last one per topic and hands it
514 /// to new (non-shared) subscribers.
515 pub fn retain(mut self, retain: bool) -> Self {
516 self.retain = retain;
517 self
518 }
519}
520
521impl MqttPublish {
522 pub(crate) fn into_publisher(self, cell: CoreCell) -> MqttPublisher {
523 MqttPublisher::new(cell, self.qos, self.retain)
524 }
525}
526
527impl PublishPolicy<ConnectedMqttBroker> for MqttPublish {
528 type Live = MqttPublisher;
529
530 fn pair(
531 self,
532 connected: &ConnectedMqttBroker,
533 ) -> impl Future<Output = Result<Self::Live, PairError>> {
534 ready(Ok(connected.publisher_with(self)))
535 }
536
537 /// What every packet this policy sends carries: the quality of service and the retain flag,
538 /// which the `mqtt` binding puts on the operation.
539 #[cfg(feature = "asyncapi")]
540 fn operation_bindings(&self, _channel: &str) -> Bindings {
541 asyncapi::send_operation(self.qos, self.retain)
542 }
543
544 /// The MQTT 5 properties every message this policy sends is mapped through.
545 ///
546 /// The destination the mount site resolved is not read here, nor on the operation: the `mqtt`
547 /// binding has no field that names one. The Response Topic property is the requester's to
548 /// set, so a message a service sends describes none, and where a reply goes is the
549 /// operation's `reply` object.
550 #[cfg(feature = "asyncapi")]
551 fn message_bindings(&self, _channel: &str) -> Bindings {
552 asyncapi::publish_message()
553 }
554
555 /// The crate answers a request through the Response Topic property, which arrives as the
556 /// `reply-to` header, so that is where a client reads the address of its answer.
557 #[cfg(feature = "asyncapi")]
558 fn reply_address_location(&self) -> Option<&'static str> {
559 Some(asyncapi::REPLY_ADDRESS_LOCATION)
560 }
561}
562
563#[cfg(test)]
564mod tests {
565 use ruststream::runtime::PublishExt;
566 use ruststream::{HeaderMap, Outgoing, Serialized};
567
568 use super::*;
569 use crate::broker::MqttBroker;
570
571 /// A topic a server would close the connection on is refused before the packet is queued.
572 #[test]
573 fn a_topic_with_a_control_character_is_refused() {
574 assert!(check_topic("devices/a\nb").is_err());
575 assert!(check_topic("devices/\u{fdd0}").is_err());
576 assert!(check_topic("devices/zürich").is_ok());
577 }
578
579 /// The packet is made of the buffer the publish wrote, not of a copy of it: the session keeps
580 /// the payload, so this crate hands it over.
581 #[test]
582 fn the_packet_carries_the_buffer_that_was_handed_in() {
583 let body = BytesMut::from(&br#"{"id":7}"#[..]);
584 let written_at = body.as_ptr();
585 let mut headers = HeaderMap::new();
586 headers.insert("content-type", "application/json");
587
588 let (payload, properties) =
589 into_packet(OutgoingMessage::produced("orders", body).with_headers(headers))
590 .expect("the headers are MQTT text");
591
592 assert!(properties.is_some(), "the headers still map to properties");
593 assert_eq!(
594 payload.as_ptr(),
595 written_at,
596 "the packet must carry the buffer the publish wrote, not a copy of it",
597 );
598 }
599
600 fn publisher() -> MqttPublisher {
601 MqttBroker::new("mqtt://localhost:1883", "arguments").publisher()
602 }
603
604 /// A payload that carries its own bytes, so a builder can be assembled without a codec in
605 /// the picture: what these tests read is the options the steps wrote, not an encoding.
606 #[derive(Outgoing, Serialized)]
607 #[outgoing(name = "devices/dev42/state")]
608 struct DeviceState(Vec<u8>);
609
610 fn state() -> DeviceState {
611 DeviceState(b"online".to_vec())
612 }
613
614 #[test]
615 fn a_call_that_names_nothing_publishes_entirely_under_the_policy() {
616 assert_eq!(
617 MqttPublishOptions::resolve(None, Qos::AtLeastOnce, false),
618 (Qos::AtLeastOnce, false),
619 "a reply and a deferred redelivery arrive here with no call site at all"
620 );
621 }
622
623 #[test]
624 fn a_named_argument_wins_and_an_unnamed_one_keeps_the_policy_value() {
625 let options = MqttPublishOptions::default().retain(true);
626 assert_eq!(
627 MqttPublishOptions::resolve(Some(&options), Qos::ExactlyOnce, false),
628 (Qos::ExactlyOnce, true),
629 "the quality of service the call left alone is still the policy's"
630 );
631 }
632
633 #[test]
634 fn the_steps_compose_in_either_order() {
635 let publisher = publisher();
636 let state = state();
637 let expected = MqttPublishOptions::default()
638 .qos(Qos::ExactlyOnce)
639 .retain(true);
640
641 let mut forward = publisher.message(&state).qos(Qos::ExactlyOnce).retain(true);
642 assert_eq!(*forward.options_mut(), Some(expected));
643
644 let mut reversed = publisher.message(&state).retain(true).qos(Qos::ExactlyOnce);
645 assert_eq!(*reversed.options_mut(), Some(expected));
646 }
647
648 #[test]
649 fn a_builder_no_step_touched_carries_no_options() {
650 let publisher = publisher();
651 let state = state();
652 let mut plain = publisher.message(&state);
653 assert_eq!(
654 *plain.options_mut(),
655 None,
656 "nothing to resolve means the policy is the whole answer"
657 );
658 }
659
660 #[tokio::test]
661 async fn a_publish_before_connect_reports_the_missing_connection() {
662 let publisher = publisher();
663 let msg = OutgoingMessage::new("devices/dev42/state", b"online".as_slice());
664 let error = publisher
665 .publish(msg, Some(&MqttPublishOptions::default().retain(true)))
666 .await
667 .expect_err("nothing is connected yet");
668 assert!(matches!(error, MqttError::NotConnected));
669 }
670}