roas-asyncapi 0.4.0

Rust implementation of the AsyncAPI Specification v2.6 / v3.0 / v3.1 — parse and validate
Documentation
//! Protocol bindings, kept untyped.
//!
//! A Bindings Object maps a protocol name (`kafka`, `amqp`, `mqtt`, …)
//! to a protocol-specific object. AsyncAPI 3.0's schema types ~17
//! protocols across server / channel / operation / message, and each
//! protocol's binding carries its *own* `bindingVersion` that evolves
//! independently of the document version — kafka alone ships 0.3.0,
//! 0.4.0, and 0.5.0 shapes in the same schema.
//!
//! Modeling that cross-product in Rust would be the single largest part
//! of this crate and would go stale with every binding release, for
//! little validation value. So bindings are held as raw JSON keyed by
//! protocol: they round-trip losslessly, and typed accessors can be
//! layered on later behind a feature without a breaking change.

use crate::validation::{Context, ValidateWithContext};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;

/// A map of protocol name → protocol-specific binding object.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Default)]
#[serde(transparent)]
pub struct Bindings(pub BTreeMap<String, serde_json::Value>);

impl Bindings {
    /// The binding object for `protocol`, if present.
    #[must_use]
    pub fn get(&self, protocol: &str) -> Option<&serde_json::Value> {
        self.0.get(protocol)
    }

    /// The `bindingVersion` declared by `protocol`'s binding, if any.
    ///
    /// A binding that omits it means "latest" per the specification.
    #[must_use]
    pub fn binding_version(&self, protocol: &str) -> Option<&str> {
        self.get(protocol)?.get("bindingVersion")?.as_str()
    }

    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.0.is_empty()
    }
}

impl ValidateWithContext for Bindings {
    fn validate_with_context(&self, ctx: &mut Context) {
        for (protocol, value) in &self.0 {
            // The binding payload itself is protocol-defined and not
            // checked here, but it must be an object for any binding to
            // make sense — a bare string or array is a modeling error.
            if !value.is_object() {
                ctx.error_field(protocol, "binding must be an object");
            }
        }
    }
}

/// The four places bindings are declared, each its own type.
///
/// A Bindings Object is the same shape wherever it appears, but *which*
/// bindings a position may name is not: a channel's bindings are
/// channel bindings, and a reference from one to a message's is a
/// document bug. Giving each position its own type is what lets a
/// reference be judged there like any other.
macro_rules! bindings {
    ($( $name:ident => $kind:literal, $what:literal );+ $(;)?) => {
        $(
            #[doc = concat!("Bindings declared on ", $what, ".")]
            #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Default)]
            #[serde(transparent)]
            pub struct $name(pub Bindings);

            impl std::ops::Deref for $name {
                type Target = Bindings;

                fn deref(&self) -> &Bindings {
                    &self.0
                }
            }

            impl From<Bindings> for $name {
                fn from(bindings: Bindings) -> Self {
                    Self(bindings)
                }
            }

            impl ValidateWithContext for $name {
                fn validate_with_context(&self, ctx: &mut Context) {
                    self.0.validate_with_context(ctx);
                }
            }
        )+

        crate::common::resolve::kinds! {
            $( $name => Some($kind), )+
        }
    };
}

bindings! {
    ServerBindings => "serverBindings", "a server";
    ChannelBindings => "channelBindings", "a channel";
    OperationBindings => "operationBindings", "an operation or an operation trait";
    MessageBindings => "messageBindings", "a message or a message trait";
}

#[cfg(test)]
mod tests {
    use crate::common::reference::RefOr;

    #[test]
    fn a_typed_binding_wraps_a_plain_one() {
        let plain: Bindings = serde_json::from_value(json!({ "kafka": { "topic": "t" } })).unwrap();
        let channel = ChannelBindings::from(plain.clone());
        // Transparent both ways: it serializes as the map it holds, and
        // reads through to it.
        assert_eq!(
            serde_json::to_value(&channel).unwrap(),
            serde_json::to_value(&plain).unwrap()
        );
        assert_eq!(channel.binding_version("kafka"), None);
        assert!(channel.get("kafka").is_some());

        // And it validates whatever the plain one would.
        let mut ctx = Context::with_path(EnumSet::empty(), "#.channels.c.bindings");
        let bad: RefOr<ChannelBindings> =
            serde_json::from_value(json!({ "kafka": "not an object" })).unwrap();
        bad.validate_with_context(&mut ctx);
        assert_eq!(
            ctx.errors.first().map(ToString::to_string),
            Some("#.channels.c.bindings.kafka: binding must be an object".to_owned())
        );
    }

    use super::*;
    use enumset::EnumSet;
    use serde_json::json;

    #[test]
    fn round_trips_transparently() {
        let value = json!({
            "kafka": { "topic": "my-topic", "bindingVersion": "0.5.0" },
            "ws": { "method": "GET" }
        });
        let bindings: Bindings = serde_json::from_value(value.clone()).unwrap();
        assert_eq!(serde_json::to_value(&bindings).unwrap(), value);
    }

    #[test]
    fn accessors_read_protocol_and_binding_version() {
        let bindings: Bindings = serde_json::from_value(json!({
            "kafka": { "topic": "t", "bindingVersion": "0.5.0" },
            "mqtt": { "qos": 1 }
        }))
        .unwrap();

        assert_eq!(bindings.binding_version("kafka"), Some("0.5.0"));
        // Absent `bindingVersion` means "latest", not an error.
        assert_eq!(bindings.binding_version("mqtt"), None);
        assert_eq!(bindings.binding_version("amqp"), None);
        assert!(bindings.get("kafka").is_some());
        assert!(bindings.get("amqp").is_none());
        assert!(!bindings.is_empty());
        assert!(Bindings::default().is_empty());
    }

    #[test]
    fn validate_rejects_non_object_binding() {
        let bindings: Bindings =
            serde_json::from_value(json!({ "kafka": { "topic": "t" }, "ws": "nope" })).unwrap();
        let mut ctx = Context::with_path(EnumSet::empty(), "#.channels.user.bindings");
        bindings.validate_with_context(&mut ctx);
        assert_eq!(ctx.errors.len(), 1);
        assert!(ctx.errors[0] == "#.channels.user.bindings.ws: binding must be an object");
    }
}