//! What arrives, and what it means.
//!
//! Kept apart from the connection so it can be tested without one. Two
//! shapes reach here:
//!
//! * An Ankyra board: one JSON object per tick, keyed `"<type>-<queryId>"`,
//! each value `{data, query: {name, meta}}` -- or, from a publisher that
//! could not resolve the query, the bare data.
//! * A Keryx `.meta` message: one query's `{data, query}`, its type and id in
//! the topic rather than a key.
//!
//! Both come to the same [`Reading`], so nothing downstream knows which.
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// One query's latest, wherever it came from.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Reading {
/// `gtfs-12`: the key the board uses, and what everything else keys on.
pub key: String,
pub kind: String,
pub query_id: u32,
pub name: Option<String>,
pub meta: Value,
pub data: Value,
}
impl Reading {
fn new(kind: &str, query_id: u32, value: Value) -> Reading {
let (data, name, meta) = unwrap(value);
Reading {
key: format!("{kind}-{query_id}"),
kind: kind.to_string(),
query_id,
name,
meta,
data,
}
}
}
/// `{data, query: {name, meta}}` apart, or the bare data as it is.
fn unwrap(value: Value) -> (Value, Option<String>, Value) {
match value {
Value::Object(mut map) if map.contains_key("data") && map.contains_key("query") => {
let data = map.remove("data").unwrap_or(Value::Null);
let query = map.remove("query").unwrap_or(Value::Null);
let name = query
.get("name")
.and_then(Value::as_str)
.map(str::to_string);
let meta = query.get("meta").cloned().unwrap_or(Value::Null);
(data, name, meta)
}
bare => (bare, None, Value::Null),
}
}
/// `gtfs-12` into its type and id. The type is everything before the last
/// dash, so a type with a dash in it survives.
pub fn split_key(key: &str) -> Option<(&str, u32)> {
let (kind, id) = key.rsplit_once('-')?;
Some((kind, id.parse().ok()?))
}
/// A routing key as it reaches an MQTT client has its dots as slashes. Put
/// back, so a topic compares the same however the broker spelled it.
pub fn normalise(topic: &str) -> String {
topic.replace('/', ".")
}
/// Every query on a board. Entries whose key is not `<type>-<id>` are
/// skipped rather than fatal: a newer publisher should not break this.
pub fn board(payload: &[u8]) -> Result<Vec<Reading>, serde_json::Error> {
let value: Value = serde_json::from_slice(payload)?;
let Value::Object(map) = value else {
return Ok(vec![]);
};
Ok(map
.into_iter()
.filter_map(|(key, value)| {
let (kind, id) = split_key(&key)?;
Some(Reading::new(kind, id, value))
})
.collect())
}
/// The Plus board: the same keys, each the extended reading. Kept whole --
/// what to make of it is a consumer's business.
pub fn plus(payload: &[u8]) -> Result<Vec<(String, Value)>, serde_json::Error> {
let value: Value = serde_json::from_slice(payload)?;
let Value::Object(map) = value else {
return Ok(vec![]);
};
Ok(map
.into_iter()
.filter(|(k, _)| split_key(k).is_some())
.collect())
}
/// A Keryx `.meta` message, if this topic is one: `apollos.keryx.<id>.<type>.<qid>.meta`.
pub fn keryx_meta(keryx_id: u32, topic: &str, payload: &[u8]) -> Option<Reading> {
let topic = normalise(topic);
let prefix = format!("apollos.keryx.{keryx_id}.");
let rest = topic.strip_prefix(&prefix)?;
let mut parts = rest.split('.');
let (kind, qid, meta) = (parts.next()?, parts.next()?, parts.next()?);
if meta != "meta" || parts.next().is_some() {
return None;
}
let value: Value = serde_json::from_slice(payload).ok()?;
Some(Reading::new(kind, qid.parse().ok()?, value))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn a_board_unwraps_each_query() {
let payload = br##"{
"gtfs-12": {"data": [{"route": "14"}], "query": {"name": "Forest Hills", "meta": {"x": 1}}},
"weather-3": [{"name": "Home"}],
"nonsense": {}
}"##;
let mut r = board(payload).unwrap();
r.sort_by(|a, b| a.key.cmp(&b.key));
assert_eq!(r.len(), 2, "a key that is not <type>-<id> is skipped");
assert_eq!(r[0].key, "gtfs-12");
assert_eq!(r[0].name.as_deref(), Some("Forest Hills"));
assert_eq!(r[0].meta, json!({"x": 1}));
assert_eq!(r[0].data, json!([{"route": "14"}]));
assert_eq!(r[1].name, None, "the bare list has no name to give");
assert_eq!(r[1].data, json!([{"name": "Home"}]));
}
#[test]
fn keryx_meta_is_read_from_either_spelling_of_the_topic() {
let payload = br#"{"data": [], "query": {"name": "Bikes", "meta": {}}}"#;
let slashed = keryx_meta(7, "apollos/keryx/7/gbfs/4/meta", payload).unwrap();
let dotted = keryx_meta(7, "apollos.keryx.7.gbfs.4.meta", payload).unwrap();
assert_eq!(slashed, dotted);
assert_eq!(slashed.key, "gbfs-4");
assert_eq!(slashed.name.as_deref(), Some("Bikes"));
}
#[test]
fn keryx_ignores_what_is_not_meta() {
let p = b"[]";
assert!(keryx_meta(7, "apollos/keryx/7/gbfs/4", p).is_none());
assert!(keryx_meta(7, "apollos/keryx/7/gbfs/4/some-dock", p).is_none());
assert!(keryx_meta(7, "apollos/keryx/7/control", p).is_none());
assert!(keryx_meta(7, "apollos/keryx/71/gbfs/4/meta", p).is_none());
}
#[test]
fn a_key_splits_on_its_last_dash() {
assert_eq!(split_key("gtfs-12"), Some(("gtfs", 12)));
assert_eq!(split_key("some-type-3"), Some(("some-type", 3)));
assert_eq!(split_key("gtfs"), None);
}
}
neiam
/apollos-dbus
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
5.8 KiB · text
History
5fae34f