oa_gateway_stomp/dest.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149
//! STOMP destination ↔ engine [`RouteKey`] mapping.
//!
//! ActiveMQ Classic maps `/topic/Name` to JMS topic `Name`. Engine
//! `topic` is that name. `type_hint` rides in [`HDR_TYPE_HINT`] and/or
//! is sniffed from the body. Inbound MESSAGE frames are stamped so
//! outbound SEND can refuse the echo and so `stomp.*` headers are not
//! copied back onto the wire.
use oa_gateway_agra::xml_root_local_name;
use oa_gateway_core::{ContentType, RouteKey};
pub use oa_gateway_core::{HDR_ID, HDR_ORIGIN, HDR_TOPIC, HDR_TYPE_HINT};
/// STOMP `destination` as received. Stamped inbound; stripped on
/// outbound because `stomp.*` headers are not copied.
pub const HDR_STOMP_DEST: &str = "stomp.destination";
/// `/topic/` prefix used by ActiveMQ STOMP for JMS topics.
///
/// [`Self::new`] forces a trailing slash so `to_stomp` / `from_stomp`
/// stay inverses. A topic with a leading slash is stripped on the way
/// out so `demo` and `/demo` share one destination.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DestinationMap {
prefix: String,
}
impl DestinationMap {
/// Builds a map that prepends `prefix` to engine topics.
///
/// A missing trailing `/` is added.
#[must_use]
pub fn new(prefix: impl Into<String>) -> Self {
let mut prefix = prefix.into();
if !prefix.ends_with('/') {
prefix.push('/');
}
Self { prefix }
}
/// The normalized prefix, always ending in `/`.
#[must_use]
pub fn prefix(&self) -> &str {
&self.prefix
}
/// STOMP destination for an engine topic (`{prefix}{topic}`).
#[must_use]
pub fn to_stomp(&self, topic: &str) -> String {
format!("{}{}", self.prefix, topic.trim_start_matches('/'))
}
/// Engine topic if `dest` starts with this prefix and the suffix is
/// not empty. `/queue/…` and the bare prefix return [`None`].
#[must_use]
pub fn from_stomp(&self, dest: &str) -> Option<String> {
dest.strip_prefix(&self.prefix)
.filter(|s| !s.is_empty())
.map(str::to_owned)
}
}
impl Default for DestinationMap {
/// ActiveMQ topic prefix: `/topic/`.
fn default() -> Self {
Self::new("/topic/")
}
}
/// Engine route for an inbound STOMP MESSAGE.
///
/// A non-empty type hint becomes [`RouteKey::typed`]; missing or empty
/// is [`RouteKey::topic`] (wildcard).
#[must_use]
pub fn inbound_route(topic: &str, type_hint: Option<String>) -> RouteKey {
match type_hint {
Some(hint) if !hint.is_empty() => RouteKey::typed(topic, hint),
_ => RouteKey::topic(topic),
}
}
/// Best-effort type hint from a JSON or XML body.
///
/// JSON must be a single-key object; the key is the hint. XML uses the
/// root local name (prefix stripped). Multi-key JSON, non-UTF-8, and
/// other shapes return [`None`].
#[must_use]
pub fn sniff_type_hint(payload: &[u8]) -> Option<String> {
let text = std::str::from_utf8(payload).ok()?.trim_start();
if text.starts_with('{') {
let value: serde_json::Value = serde_json::from_str(text).ok()?;
let obj = value.as_object()?;
if obj.len() == 1 {
return obj.keys().next().cloned();
}
return None;
}
if text.starts_with('<') {
return xml_root_local_name(text);
}
None
}
/// MIME type from a STOMP `content-type` header, or a sniff of the
/// body.
///
/// The header wins when its type (before `;`) is non-empty. Otherwise
/// a leading `{` is JSON, a leading `<` is XML, and everything else is
/// `application/octet-stream`. Does not fail.
#[must_use]
pub fn sniff_content_type(payload: &[u8], header: Option<&str>) -> ContentType {
if let Some(ct) = header {
let mime = ct.split(';').next().unwrap_or(ct).trim();
if !mime.is_empty() {
return ContentType::new(mime);
}
}
match std::str::from_utf8(payload).map(str::trim_start) {
Ok(t) if t.starts_with('{') => ContentType::json(),
Ok(t) if t.starts_with('<') => ContentType::xml(),
_ => ContentType::octet_stream(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prefix_round_trip() {
let map = DestinationMap::new("/topic/");
assert_eq!(map.to_stomp("PositionReport"), "/topic/PositionReport");
assert_eq!(
map.from_stomp("/topic/PositionReport").as_deref(),
Some("PositionReport")
);
assert_eq!(map.from_stomp("/queue/x"), None);
}
#[test]
fn sniff_json_and_xml_with_prolog() {
assert_eq!(
sniff_type_hint(br#"{"Ping":{"n":1}}"#).as_deref(),
Some("Ping")
);
let xml = b"<?xml version=\"1.0\"?>\n<uci:PositionReport xmlns:uci=\"x\"/>";
assert_eq!(sniff_type_hint(xml).as_deref(), Some("PositionReport"));
}
}