5.8 KiB · text History 5fae34f
//! 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);
    }
}