Skip to main content

ruststream_amqp/
message.rs

1//! [`AmqpMessage`] and the mapping between `RustStream` headers and `AMQP` 1.0 message sections.
2//!
3//! Well-known `RustStream` headers ride the `AMQP` `properties` section (`content-type`,
4//! `correlation-id`, `reply-to`, `message-id`, and the partition key as `group-id`); every other
5//! header rides `application-properties`, so no envelope format is invented and non-RustStream
6//! peers see plain `AMQP` messages.
7
8use std::fmt;
9
10use bytes::Bytes;
11use fe2o3_amqp::link::delivery::DeliveryInfo;
12use fe2o3_amqp_types::messaging::{
13    ApplicationProperties, Body, Data, Message, MessageId, Properties,
14};
15use fe2o3_amqp_types::primitives::{Binary, SimpleValue, Symbol, Value};
16use ruststream::{AckError, HeaderMap, IncomingMessage, OutgoingFor, Partitioned, Str, Take};
17use tokio::sync::{mpsc, oneshot};
18
19use crate::error::AmqpError;
20#[cfg(feature = "testing")]
21use crate::in_process::{Delivery, Settlement};
22
23/// Header carrying the partition key, mapped onto the `AMQP` `group-id` property.
24///
25/// Mirrors the in-memory broker's convention, so services can switch brokers without changing
26/// their headers.
27pub const PARTITION_KEY_HEADER: &str = "partition-key";
28
29/// How a delivered message asks its pump task to settle it.
30#[derive(Debug, Clone, Copy)]
31pub(crate) enum SettleKind {
32    /// Accept the delivery (ack).
33    Accept,
34    /// Hand the delivery back for redelivery and count the attempt as failed (nack with requeue).
35    ///
36    /// The disposition is `modified` with `delivery-failed`, not `released`. `released` is the
37    /// protocol's way of saying the delivery was not acted upon at all, and the peer leaves
38    /// `delivery-count` where it was; a handler that answered `retry()` did act on it and failed,
39    /// so the attempt has to be counted. Without that the registration's `max_attempts(..)` cap
40    /// reads the same count on every redelivery and a poison message circulates forever.
41    Modify,
42    /// Reject the delivery as undeliverable (nack without requeue); the broker's dead-letter
43    /// policy decides what happens next.
44    Reject,
45}
46
47/// A settlement request shipped from a message handle to the subscription's pump task.
48#[derive(Debug)]
49pub(crate) struct SettleCmd {
50    pub(crate) info: DeliveryInfo,
51    pub(crate) kind: SettleKind,
52    pub(crate) done: oneshot::Sender<Result<(), AckError>>,
53}
54
55pub(crate) type SettleSender = mpsc::UnboundedSender<SettleCmd>;
56
57/// A message delivered by an [`AmqpSubscriber`](crate::AmqpSubscriber).
58///
59/// `ack` maps to the `accept` disposition, `nack(requeue = true)` to `modified` with
60/// `delivery-failed` set (so the broker counts the attempt and redelivers), and
61/// `nack(requeue = false)` to `reject` (terminal; the broker's dead-letter policy applies).
62/// Deliveries received on an at-most-once subscription are already settled, so `ack`/`nack`
63/// report [`AckError::Unsupported`] instead of pretending.
64pub struct AmqpMessage {
65    payload: Bytes,
66    headers: HeaderMap,
67    /// The `delivery-count` of the delivery's `header` section, or `None` where it carries no
68    /// header section at all. See [`AmqpMessage::redelivery_count`](IncomingMessage::redelivery_count).
69    delivery_count: Option<u32>,
70    /// `None` when the delivery is already settled (at-most-once, request/reply replies).
71    settle: Option<SettleHandle>,
72    /// How a delivery of the in-process transport settles, in place of the settle handle a live
73    /// one carries. The field is there only with the `testing` feature.
74    #[cfg(feature = "testing")]
75    in_process: Option<Settlement>,
76}
77
78struct SettleHandle {
79    tx: SettleSender,
80    info: DeliveryInfo,
81}
82
83impl fmt::Debug for AmqpMessage {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        f.debug_struct("AmqpMessage")
86            .field("payload_len", &self.payload.len())
87            .field("delivery_count", &self.delivery_count)
88            .field("settled", &self.settle.is_none())
89            .finish_non_exhaustive()
90    }
91}
92
93impl AmqpMessage {
94    pub(crate) fn unsettled(
95        payload: Bytes,
96        headers: HeaderMap,
97        delivery_count: Option<u32>,
98        tx: SettleSender,
99        info: DeliveryInfo,
100    ) -> Self {
101        Self {
102            payload,
103            headers,
104            delivery_count,
105            settle: Some(SettleHandle { tx, info }),
106            #[cfg(feature = "testing")]
107            in_process: None,
108        }
109    }
110
111    /// A delivery that arrived already settled: an at-most-once subscription, which still carries
112    /// whatever the broker counted, and a request/reply answer, which counts nothing.
113    pub(crate) fn settled(payload: Bytes, headers: HeaderMap, delivery_count: Option<u32>) -> Self {
114        Self {
115            payload,
116            headers,
117            delivery_count,
118            settle: None,
119            #[cfg(feature = "testing")]
120            in_process: None,
121        }
122    }
123
124    /// A delivery of the in-process transport, reporting what a live one reports.
125    #[cfg(feature = "testing")]
126    pub(crate) fn in_process(delivery: Delivery, settlement: Settlement) -> Self {
127        Self::settled(delivery.payload, delivery.headers, delivery.count).settling(settlement)
128    }
129
130    /// Hands the settlement of this delivery to the in-process transport.
131    #[cfg(feature = "testing")]
132    pub(crate) fn settling(mut self, settlement: Settlement) -> Self {
133        self.in_process = Some(settlement);
134        self
135    }
136
137    async fn settle(self, kind: SettleKind) -> Result<(), AckError> {
138        #[cfg(feature = "testing")]
139        if let Some(settlement) = self.in_process {
140            return settlement.settle(kind, self.payload, self.headers, self.delivery_count);
141        }
142        let Some(SettleHandle { tx, info }) = self.settle else {
143            return Err(AckError::Unsupported);
144        };
145        let (done, wait) = oneshot::channel();
146        tx.send(SettleCmd { info, kind, done }).map_err(|_| {
147            AckError::Broker(Box::from("the subscription's pump task has shut down"))
148        })?;
149        wait.await.map_err(|_| {
150            AckError::Broker(Box::from("the subscription's pump task has shut down"))
151        })?
152    }
153}
154
155impl Partitioned for AmqpMessage {
156    fn partition_key(&self) -> Option<&[u8]> {
157        self.headers.get(PARTITION_KEY_HEADER)
158    }
159}
160
161impl IncomingMessage for AmqpMessage {
162    fn payload(&self) -> &[u8] {
163        &self.payload
164    }
165
166    fn headers(&self) -> &HeaderMap {
167        &self.headers
168    }
169
170    async fn ack(self) -> Result<(), AckError> {
171        self.settle(SettleKind::Accept).await
172    }
173
174    async fn nack(self, requeue: bool) -> Result<(), AckError> {
175        let kind = if requeue {
176            SettleKind::Modify
177        } else {
178            SettleKind::Reject
179        };
180        self.settle(kind).await
181    }
182
183    /// The broker's own count of deliveries of this message, from the `delivery-count` field of
184    /// the `AMQP` `header` section, counting this delivery.
185    ///
186    /// A delivery with no `header` section answers `None`, which is what a message this crate
187    /// published looks like: nothing has counted an attempt for it, and the framework's
188    /// retry-count header carries the attempt instead. That is the answer a deferred `retry_after`
189    /// copy needs, because the copy is a new message to the broker and its `delivery-count` starts
190    /// over while the framework's header does not.
191    fn redelivery_count(&self) -> Option<u64> {
192        self.delivery_count.map(|count| u64::from(count) + 1)
193    }
194
195    fn partition_key(&self) -> Option<&[u8]> {
196        Partitioned::partition_key(self)
197    }
198}
199
200/// Builds the `AMQP` message for an outgoing publish.
201pub(crate) fn to_amqp_message(msg: OutgoingFor<'_, Take>) -> Message<Data> {
202    let (_, payload, headers) = msg.into_parts();
203    build_message(&headers, Vec::from(payload))
204}
205
206/// Builds the `AMQP` message carrying `body` with `headers` spread over its sections: the
207/// well-known ones on `properties`, every other one on `application-properties`.
208pub(crate) fn build_message(headers: &HeaderMap, body: Vec<u8>) -> Message<Data> {
209    let mut properties = Properties::default();
210    let mut has_properties = false;
211    let mut application: Option<ApplicationProperties> = None;
212
213    for (name, value) in headers.iter() {
214        let text = || String::from_utf8_lossy(value).into_owned();
215        match name {
216            "content-type" => {
217                properties.content_type = Some(Symbol::from(text()));
218                has_properties = true;
219            }
220            "correlation-id" => {
221                properties.correlation_id = Some(MessageId::String(text()));
222                has_properties = true;
223            }
224            "reply-to" => {
225                properties.reply_to = Some(text());
226                has_properties = true;
227            }
228            "message-id" => {
229                properties.message_id = Some(MessageId::String(text()));
230                has_properties = true;
231            }
232            PARTITION_KEY_HEADER => {
233                properties.group_id = Some(text());
234                has_properties = true;
235            }
236            other => {
237                let simple = std::str::from_utf8(value).map_or_else(
238                    |_| SimpleValue::Binary(Binary::from(value.to_vec())),
239                    |s| SimpleValue::String(s.to_owned()),
240                );
241                application
242                    .get_or_insert_with(ApplicationProperties::default)
243                    .insert(other.to_owned(), simple);
244            }
245        }
246    }
247
248    let mut builder = Message::builder();
249    if has_properties {
250        builder = builder.properties(properties);
251    }
252    if let Some(application) = application {
253        builder = builder.application_properties(application);
254    }
255    builder.data(Binary::from(body)).build()
256}
257
258/// Extracts `RustStream` headers from a delivered `AMQP` message.
259pub(crate) fn headers_from_amqp<B>(message: &Message<B>) -> HeaderMap {
260    let mut headers = HeaderMap::new();
261    if let Some(properties) = &message.properties {
262        // Every well-known key is a lowercase literal, so the map takes it as a shared static and
263        // this path copies no key text per delivery.
264        if let Some(content_type) = &properties.content_type {
265            headers.insert(Str::from_static("content-type"), content_type.to_string());
266        }
267        if let Some(correlation_id) = &properties.correlation_id {
268            headers.insert(
269                Str::from_static("correlation-id"),
270                message_id_text(correlation_id),
271            );
272        }
273        if let Some(reply_to) = &properties.reply_to {
274            headers.insert(Str::from_static("reply-to"), reply_to.clone());
275        }
276        if let Some(message_id) = &properties.message_id {
277            headers.insert(Str::from_static("message-id"), message_id_text(message_id));
278        }
279        if let Some(group_id) = &properties.group_id {
280            headers.insert(Str::from_static(PARTITION_KEY_HEADER), group_id.clone());
281        }
282    }
283    if let Some(application) = &message.application_properties {
284        for (name, value) in application.iter() {
285            headers.insert(name.clone(), simple_value_bytes(value));
286        }
287    }
288    headers
289}
290
291/// Renders any `AMQP` message-id form as text, so it survives the byte-valued header map.
292fn message_id_text(id: &MessageId) -> String {
293    match id {
294        MessageId::String(s) => s.clone(),
295        MessageId::Uuid(u) => format!("{u:x}"),
296        MessageId::Ulong(n) => n.to_string(),
297        MessageId::Binary(b) => String::from_utf8_lossy(b).into_owned(),
298    }
299}
300
301fn simple_value_bytes(value: &SimpleValue) -> Bytes {
302    match value {
303        SimpleValue::String(s) => Bytes::copy_from_slice(s.as_bytes()),
304        SimpleValue::Binary(b) => Bytes::copy_from_slice(b),
305        SimpleValue::Symbol(s) => Bytes::copy_from_slice(s.as_str().as_bytes()),
306        other => Bytes::from(format_simple_value(other)),
307    }
308}
309
310/// Scalar fallback: peers may put numbers or booleans into application-properties; text is the
311/// only lossless byte form the header map can carry for them.
312fn format_simple_value(value: &SimpleValue) -> String {
313    match value {
314        SimpleValue::Bool(v) => v.to_string(),
315        SimpleValue::Ubyte(v) => v.to_string(),
316        SimpleValue::Ushort(v) => v.to_string(),
317        SimpleValue::Uint(v) => v.to_string(),
318        SimpleValue::Ulong(v) => v.to_string(),
319        SimpleValue::Byte(v) => v.to_string(),
320        SimpleValue::Short(v) => v.to_string(),
321        SimpleValue::Int(v) => v.to_string(),
322        SimpleValue::Long(v) => v.to_string(),
323        SimpleValue::Float(v) => v.to_string(),
324        SimpleValue::Double(v) => v.to_string(),
325        SimpleValue::Char(v) => v.to_string(),
326        SimpleValue::Timestamp(v) => v.milliseconds().to_string(),
327        SimpleValue::Uuid(v) => format!("{v:x}"),
328        other => format!("{other:?}"),
329    }
330}
331
332/// Extracts the payload bytes from a delivered body.
333///
334/// `Data` sections are the native byte path (what this crate publishes); a string or binary
335/// `AmqpValue` from a foreign peer is accepted as bytes too. Anything else has no faithful byte
336/// form and is reported as [`AmqpError::UnsupportedBody`].
337pub(crate) fn payload_from_body(body: Body<Value>, address: &str) -> Result<Bytes, AmqpError> {
338    match body {
339        Body::Data(batch) => {
340            let mut chunks = batch.into_iter();
341            match (chunks.next(), chunks.next()) {
342                (None, _) => Ok(Bytes::new()),
343                (Some(Data(first)), None) => Ok(Bytes::from(first.into_vec())),
344                (Some(Data(first)), Some(Data(second))) => {
345                    let mut all = first.into_vec();
346                    all.extend_from_slice(&second);
347                    for Data(chunk) in chunks {
348                        all.extend_from_slice(&chunk);
349                    }
350                    Ok(Bytes::from(all))
351                }
352            }
353        }
354        Body::Value(value) => match value.0 {
355            Value::Binary(b) => Ok(Bytes::from(b.into_vec())),
356            Value::String(s) => Ok(Bytes::from(s.into_bytes())),
357            _ => Err(AmqpError::UnsupportedBody {
358                address: address.to_owned(),
359            }),
360        },
361        Body::Empty => Ok(Bytes::new()),
362        Body::Sequence(_) => Err(AmqpError::UnsupportedBody {
363            address: address.to_owned(),
364        }),
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use ruststream::{BytesMut, OutgoingMessage};
371
372    use super::*;
373
374    /// Content equality cannot tell a hand-over from a copy, so the buffer the framework wrote is
375    /// identified by its address.
376    #[test]
377    fn the_data_body_keeps_the_buffer_the_framework_wrote() {
378        let payload = BytesMut::from(&br#"{"id":1}"#[..]);
379        let written = payload.as_ptr();
380        let outgoing: OutgoingFor<'_, Take> = OutgoingMessage::produced("orders", payload);
381
382        let message = to_amqp_message(outgoing);
383
384        assert_eq!(
385            message.body.0.as_ptr(),
386            written,
387            "the client keeps the body until the transfer settles, so the buffer is handed over \
388             rather than copied"
389        );
390    }
391
392    #[test]
393    fn well_known_headers_ride_the_properties_section() {
394        let mut headers = HeaderMap::new();
395        headers.insert("content-type", "application/json");
396        headers.insert("correlation-id", "corr-1");
397        headers.insert("reply-to", "replies");
398        headers.insert("message-id", "msg-1");
399        headers.insert(PARTITION_KEY_HEADER, "user-42");
400        headers.insert("x-custom", "value");
401        let outgoing = OutgoingMessage::new("orders", b"{}".as_slice()).with_headers(headers);
402
403        let message = to_amqp_message(outgoing);
404        let properties = message.properties.as_ref().expect("properties set");
405        assert_eq!(
406            properties.content_type.as_ref().map(Symbol::as_str),
407            Some("application/json")
408        );
409        assert_eq!(
410            properties.correlation_id,
411            Some(MessageId::String("corr-1".into()))
412        );
413        assert_eq!(properties.reply_to.as_deref(), Some("replies"));
414        assert_eq!(properties.group_id.as_deref(), Some("user-42"));
415        let application = message
416            .application_properties
417            .as_ref()
418            .expect("application properties set");
419        assert_eq!(
420            application.get("x-custom"),
421            Some(&SimpleValue::String("value".into()))
422        );
423        assert!(application.get("content-type").is_none());
424    }
425
426    #[test]
427    fn headers_round_trip_through_the_amqp_sections() {
428        let mut headers = HeaderMap::new();
429        headers.insert("content-type", "application/json");
430        headers.insert("correlation-id", "corr-1");
431        headers.insert(PARTITION_KEY_HEADER, "user-42");
432        headers.insert("x-custom", "value");
433        let outgoing =
434            OutgoingMessage::new("orders", b"{}".as_slice()).with_headers(headers.clone());
435
436        let restored = headers_from_amqp(&to_amqp_message(outgoing));
437        assert_eq!(restored.get_str("content-type"), Some("application/json"));
438        assert_eq!(restored.get_str("correlation-id"), Some("corr-1"));
439        assert_eq!(restored.get_str(PARTITION_KEY_HEADER), Some("user-42"));
440        assert_eq!(restored.get_str("x-custom"), Some("value"));
441    }
442
443    #[test]
444    fn data_body_yields_payload_bytes() {
445        let outgoing = OutgoingMessage::new("orders", b"payload".as_slice());
446        let message = to_amqp_message(outgoing);
447        let body = Body::<Value>::Data(vec![message.body].into());
448        let payload = payload_from_body(body, "orders").expect("data body decodes");
449        assert_eq!(payload.as_ref(), b"payload");
450    }
451
452    #[test]
453    fn string_and_binary_values_are_accepted_as_bytes() {
454        let s = Body::Value(fe2o3_amqp_types::messaging::AmqpValue(Value::String(
455            "hi".into(),
456        )));
457        assert_eq!(
458            payload_from_body(s, "a").expect("string decodes").as_ref(),
459            b"hi"
460        );
461
462        let b = Body::Value(fe2o3_amqp_types::messaging::AmqpValue(Value::Binary(
463            Binary::from(b"raw".to_vec()),
464        )));
465        assert_eq!(
466            payload_from_body(b, "a").expect("binary decodes").as_ref(),
467            b"raw"
468        );
469    }
470
471    #[test]
472    fn foreign_value_bodies_are_reported_unsupported() {
473        let body = Body::Value(fe2o3_amqp_types::messaging::AmqpValue(Value::Bool(true)));
474        assert!(matches!(
475            payload_from_body(body, "a"),
476            Err(AmqpError::UnsupportedBody { .. })
477        ));
478    }
479}