use ruststream::runtime::{Names, Outgoing, PublishContext};
use ruststream_rumqttc::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, PartialEq, Deserialize, Serialize, Outgoing)]
#[outgoing(name = "devices/dev42/config/get")]
struct ConfigQuery {
key: String,
}
#[derive(Debug, PartialEq, Deserialize, Serialize, Outgoing)]
struct ConfigValue {
key: String,
value: String,
}
fn setting(key: &str) -> Option<&'static str> {
match key {
"sample-interval" => Some("30s"),
"firmware" => Some("2.1.0"),
_ => None,
}
}
#[subscriber(
MqttTopic::new("devices/dev42/config/get"),
reply("devices/dev42/config")
)]
async fn answer(query: &ConfigQuery) -> Result<ConfigValue, HandlerOutcome> {
let Some(value) = setting(&query.key) else {
tracing::warn!(query.key, "unknown setting");
return Err(HandlerOutcome::drop());
};
tracing::info!(query.key, "setting reported");
Ok(ConfigValue {
key: query.key.clone(),
value: value.to_owned(),
})
}
struct ToResponseTopic;
impl<C, Options> PublishTransform<ForReply<C>, Options> for ToResponseTopic {
type Destination = Names;
fn apply(
&self,
out: &mut Outgoing<'_>,
_options: &mut Option<Options>,
cx: &PublishContext<'_, C>,
) {
if let Some(topic) = cx.headers().reply_to() {
out.set_name(topic.to_owned());
}
if let Some(id) = cx.headers().correlation_id() {
out.headers_mut().insert("correlation-id", id.to_owned());
}
}
}
#[ruststream::app]
fn app() -> impl App<State = ()> {
RustStream::new(AppInfo::new("device-agent", "0.1.0")).with_broker(
MqttBroker::new("mqtt://localhost:1883", "device-agent-dev42"),
|b| {
b.include(answer)
.out_reply(Publish::default())
.transform(ToResponseTopic);
},
)
}
#[cfg(test)]
mod tests {
use std::error::Error;
use ruststream::testing::TestApp;
use ruststream_rumqttc::prelude::*;
use super::{ConfigQuery, ConfigValue, app};
fn query(key: &str) -> ConfigQuery {
ConfigQuery {
key: key.to_owned(),
}
}
fn interval() -> ConfigValue {
ConfigValue {
key: "sample-interval".to_owned(),
value: "30s".to_owned(),
}
}
#[tokio::test]
async fn the_answer_goes_to_the_response_topic_with_the_correlation_data()
-> Result<(), Box<dyn Error>> {
let tb = TestApp::start(app()).await?;
let mut headers = HeaderMap::new();
headers.insert("reply-to", "consoles/c1/responses");
headers.insert("correlation-id", "q-17");
tb.broker::<MqttBroker>()
.message(&query("sample-interval"))
.with_headers(headers)
.publish()
.await?;
tb.broker::<MqttBroker>()
.published::<ConfigValue>("consoles/c1/responses")
.assert_called_once()
.with(&interval())
.with_header("correlation-id", "q-17");
tb.broker::<MqttBroker>()
.published::<ConfigValue>("devices/dev42/config")
.assert_not_called();
tb.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn a_query_without_a_response_topic_is_answered_on_the_mount_site_one()
-> Result<(), Box<dyn Error>> {
let tb = TestApp::start(app()).await?;
tb.broker::<MqttBroker>()
.message(&query("sample-interval"))
.publish()
.await?;
tb.broker::<MqttBroker>()
.published::<ConfigValue>("devices/dev42/config")
.assert_called_once()
.with(&interval());
tb.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn an_unknown_setting_gets_no_answer() -> Result<(), Box<dyn Error>> {
let tb = TestApp::start(app()).await?;
let mut headers = HeaderMap::new();
headers.insert("reply-to", "consoles/c1/responses");
tb.broker::<MqttBroker>()
.message(&query("colour"))
.with_headers(headers)
.publish()
.await?;
tb.broker::<MqttBroker>()
.subscriber("devices/dev42/config/get")
.assert_called_once()
.settled(HandlerOutcome::drop());
tb.broker::<MqttBroker>()
.published::<ConfigValue>("consoles/c1/responses")
.assert_not_called();
tb.shutdown().await?;
Ok(())
}
}