diff --git a/asyncapi-rust-codegen/src/asyncapi_attrs.rs b/asyncapi-rust-codegen/src/asyncapi_attrs.rs index 635ee0b..2222d80 100644 --- a/asyncapi-rust-codegen/src/asyncapi_attrs.rs +++ b/asyncapi-rust-codegen/src/asyncapi_attrs.rs @@ -1,6 +1,27 @@ //! Utilities for parsing asyncapi attributes -use syn::Attribute; +use syn::{Attribute, Path}; + +#[derive(Clone, Debug, PartialEq)] +pub enum ResponseTopicMeta { + Reference(Path), + Uri(String), +} + +impl Default for ResponseTopicMeta { + fn default() -> Self { + Self::Uri(String::default()) + } +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct MqttMessageBindingsMeta { + pub payload_format_indicator: Option, + pub correlation_data: Option, + pub content_type: Option, + pub response_topic: Option, + pub binding_version: Option, +} /// AsyncAPI metadata extracted from attributes #[derive(Debug, Default, Clone)] @@ -13,6 +34,18 @@ pub struct AsyncApiMeta { /// Override the message name used in `components.messages` and `asyncapi_message_names()`. /// When absent the Rust variant/type identifier is used. pub message_name: Option, + pub mqtt: Option, +} + +fn parse_response_topic(expr: syn::Expr) -> Option { + match expr { + syn::Expr::Lit(expr_lit) => match expr_lit.lit { + syn::Lit::Str(s) => Some(ResponseTopicMeta::Uri(s.value())), + _ => None, + }, + syn::Expr::Path(expr_path) => Some(ResponseTopicMeta::Reference(expr_path.path)), + _ => None, + } } /// Extract asyncapi metadata from `#[asyncapi(...)]` attributes @@ -48,6 +81,36 @@ pub fn extract_asyncapi_meta(attrs: &[Attribute]) -> AsyncApiMeta { let value = nested.value()?; let s: syn::LitStr = value.parse()?; meta.message_name = Some(s.value()); + } else if nested.path.is_ident("mqtt") { + let mut binding = MqttMessageBindingsMeta::default(); + + nested.parse_nested_meta(|m| { + if m.path.is_ident("payload_format_indicator") { + let v = m.value()?; + let lit: syn::LitInt = v.parse()?; + binding.payload_format_indicator = Some(lit.base10_parse::()?); + } else if m.path.is_ident("correlation_data") { + let v = m.value()?; + let s: syn::Path = v.parse()?; + binding.correlation_data = Some(s); + } else if m.path.is_ident("content_type") { + let v = m.value()?; + let s: syn::LitStr = v.parse()?; + binding.content_type = Some(s.value()); + } else if m.path.is_ident("response_topic") { + let v = m.value()?; + let expr: syn::Expr = v.parse()?; + binding.response_topic = parse_response_topic(expr); + } else if m.path.is_ident("binding_version") { + let v = m.value()?; + let s: syn::LitStr = v.parse()?; + binding.binding_version = Some(s.value()); + } + + Ok(()) + })?; + + meta.mqtt = Some(binding); } Ok(()) }); @@ -86,6 +149,25 @@ mod tests { ); } + #[test] + fn test_extract_multiple_with_mqtt_bindings() { + let attrs: Vec = vec![parse_quote! { + #[asyncapi(mqtt(response_topic = "/a/b/d"))] + }]; + + let meta = extract_asyncapi_meta(&attrs); + assert_eq!( + meta.mqtt, + Some(MqttMessageBindingsMeta { + response_topic: Some(ResponseTopicMeta::Uri("/a/b/d".to_string())), + payload_format_indicator: None, + content_type: None, + correlation_data: None, + binding_version: None + }) + ) + } + #[test] fn test_extract_content_type() { let attrs: Vec = vec![parse_quote! { diff --git a/asyncapi-rust-codegen/src/asyncapi_spec_attrs.rs b/asyncapi-rust-codegen/src/asyncapi_spec_attrs.rs index 978b4ce..74a11d3 100644 --- a/asyncapi-rust-codegen/src/asyncapi_spec_attrs.rs +++ b/asyncapi-rust-codegen/src/asyncapi_spec_attrs.rs @@ -1,6 +1,6 @@ //! Utilities for parsing asyncapi spec-level attributes -use syn::{Attribute, Path}; +use syn::{Attribute, LitStr, Path}; /// AsyncAPI spec metadata extracted from attributes #[derive(Debug, Default, Clone)] @@ -23,6 +23,32 @@ pub struct ServerMeta { pub pathname: Option, pub description: Option, pub variables: Vec, + pub mqtt: Option, +} + +#[derive(Debug, Clone)] +pub struct LastWillMeta { + pub topic: String, + pub qos: u8, + pub retain: bool, + pub message: String, +} + +#[derive(Debug, Clone)] +pub enum MqttBindingNumValueMeta { + Value(u32), + Reference(Path), +} + +#[derive(Debug, Clone)] +pub struct MqttServerBindingsMeta { + pub client_id: Option, + pub clean_session: Option, + pub last_will: Option, + pub keep_alive: Option, + pub session_expiry_interval: Option, + pub maximum_packet_size: Option, + pub binding_version: Option, } /// Server variable metadata @@ -64,6 +90,28 @@ pub struct OperationMeta { pub channel: String, #[allow(dead_code)] // Reserved for future use pub description: Option, + pub mqtt: Option, +} + +#[derive(Debug, Clone)] +pub struct OperationMqttBindingsMeta { + pub qos: Option, + pub retain: Option, + pub message_expiry_interval: Option, + pub binding_version: Option, +} + +fn parse_mqtt_binding_value( + expr: syn::Expr, +) -> Result, syn::Error> { + match expr { + syn::Expr::Lit(expr_lit) => match expr_lit.lit { + syn::Lit::Int(s) => Ok(Some(MqttBindingNumValueMeta::Value(s.base10_parse()?))), + _ => Ok(None), + }, + syn::Expr::Path(expr_path) => Ok(Some(MqttBindingNumValueMeta::Reference(expr_path.path))), + _ => Ok(None), + } } /// Extract asyncapi spec metadata from `#[asyncapi(...)]` attributes @@ -133,6 +181,7 @@ fn extract_server(attr: &Attribute) -> Option { let mut pathname = None; let mut description = None; let mut variables = Vec::new(); + let mut mqtt = None; let _ = attr.parse_nested_meta(|nested| { if nested.path.is_ident("name") { @@ -160,6 +209,10 @@ fn extract_server(attr: &Attribute) -> Option { if let Some(var) = extract_server_variable(&nested) { variables.push(var); } + } else if nested.path.is_ident("mqtt") { + if let Some(var) = extract_mqtt_server_bindings(&nested) { + mqtt = Some(var); + } } Ok(()) }); @@ -172,6 +225,97 @@ fn extract_server(attr: &Attribute) -> Option { pathname, description, variables, + mqtt, + }) +} + +fn extract_mqtt_server_bindings( + nested: &syn::meta::ParseNestedMeta, +) -> Option { + let mut client_id: Option = None; + let mut clean_session: Option = None; + let mut keep_alive = None; + let mut session_expiry_interval = None; + let mut maximum_packet_size = None; + let mut binding_version: Option = None; + + let mut last_will: Option = None; + + let _ = nested.parse_nested_meta(|inner| { + if inner.path.is_ident("client_id") { + let value = inner.value()?; + let s: syn::LitStr = value.parse()?; + client_id = Some(s.value()); + } else if inner.path.is_ident("clean_session") { + let value = inner.value()?; + let s: syn::LitBool = value.parse()?; + clean_session = Some(s.value()); + } else if inner.path.is_ident("keep_alive") { + let value = inner.value()?; + let s: syn::LitInt = value.parse()?; + keep_alive = Some(s.base10_parse()?); + } else if inner.path.is_ident("session_expiry_interval") { + let v = inner.value()?; + let expr: syn::Expr = v.parse()?; + session_expiry_interval = parse_mqtt_binding_value(expr)?; + } else if inner.path.is_ident("maximum_packet_size") { + let v = inner.value()?; + let expr: syn::Expr = v.parse()?; + maximum_packet_size = parse_mqtt_binding_value(expr)?; + } else if inner.path.is_ident("binding_version") { + let value = inner.value()?; + let s: syn::LitStr = value.parse()?; + binding_version = Some(s.value()); + } else if inner.path.is_ident("last_will") { + let mut topic: Option = None; + let mut qos: Option = None; + let mut message: Option = None; + let mut retain: Option = None; + + inner.parse_nested_meta(|meta| { + if meta.path.is_ident("topic") { + let value = meta.value()?; + let s: syn::LitStr = value.parse()?; + topic = Some(s.value()); + } else if meta.path.is_ident("qos") { + let value = meta.value()?; + let v: syn::LitInt = value.parse()?; + qos = Some(v.base10_parse()?); + } else if meta.path.is_ident("message") { + let value = meta.value()?; + let s: syn::LitStr = value.parse()?; + message = Some(s.value()); + } else if meta.path.is_ident("retain") { + let value = meta.value()?; + let b: syn::LitBool = value.parse()?; + retain = Some(b.value()); + } + + Ok(()) + })?; + + last_will = Some(LastWillMeta { + topic: topic + .ok_or_else(|| syn::Error::new_spanned(&inner.path, "missing topic"))?, + qos: qos.ok_or_else(|| syn::Error::new_spanned(&inner.path, "missing qos"))?, + message: message + .ok_or_else(|| syn::Error::new_spanned(&inner.path, "missing message"))?, + retain: retain + .ok_or_else(|| syn::Error::new_spanned(&inner.path, "missing retain"))?, + }); + } + + Ok(()) + }); + + Some(MqttServerBindingsMeta { + client_id, + clean_session, + binding_version, + last_will, + keep_alive, + maximum_packet_size, + session_expiry_interval, }) } @@ -317,12 +461,50 @@ fn extract_channel_parameter(nested: &syn::meta::ParseNestedMeta) -> Option Option { + let mut qos = None; + let mut retain = None; + let mut message_expiry_interval = None; + let mut binding_version = None; + + let _ = nested.parse_nested_meta(|inner| { + if inner.path.is_ident("qos") { + let value = inner.value()?; + let s: syn::LitInt = value.parse()?; + qos = Some(s.base10_parse()?); + } else if inner.path.is_ident("retain") { + let value = inner.value()?; + let s: syn::LitBool = value.parse()?; + retain = Some(s.value()); + } else if inner.path.is_ident("message_expiry_interval") { + let value = inner.value()?; + let expr: syn::Expr = value.parse()?; + message_expiry_interval = parse_mqtt_binding_value(expr)?; + } else if inner.path.is_ident("binding_version") { + let value = inner.value()?; + let s: LitStr = value.parse()?; + binding_version = Some(s.value()); + } + Ok(()) + }); + + Some(OperationMqttBindingsMeta { + qos, + retain, + message_expiry_interval, + binding_version, + }) +} + /// Extract operation metadata from `#[asyncapi_operation(...)]` attribute fn extract_operation(attr: &Attribute) -> Option { let mut name = None; let mut action = None; let mut channel = None; let mut description = None; + let mut mqtt = None; let _ = attr.parse_nested_meta(|nested| { if nested.path.is_ident("name") { @@ -341,6 +523,8 @@ fn extract_operation(attr: &Attribute) -> Option { let value = nested.value()?; let s: syn::LitStr = value.parse()?; description = Some(s.value()); + } else if nested.path.is_ident("mqtt") { + mqtt = extract_mqtt_operation_bindings(&nested); } Ok(()) }); @@ -351,6 +535,7 @@ fn extract_operation(attr: &Attribute) -> Option { action: action?, channel: channel?, description, + mqtt, }) } @@ -459,6 +644,26 @@ mod tests { assert_eq!(meta.operations[0].channel, "chat"); } + #[test] + fn test_extract_operation_with_mqtt_binding() { + let attrs: Vec = vec![parse_quote! { + #[asyncapi_operation(name = "sendMessage", action = "send", channel = "chat", mqtt(qos = 1, retain = true))] + }]; + + let meta = extract_asyncapi_spec_meta(&attrs); + assert_eq!(meta.operations.len(), 1); + assert_eq!(meta.operations[0].name, "sendMessage"); + assert_eq!(meta.operations[0].action, "send"); + assert_eq!(meta.operations[0].channel, "chat"); + + assert!(meta.operations[0].mqtt.is_some()); + let mqtt = meta.operations[0].mqtt.clone().unwrap(); + assert_eq!(mqtt.qos, Some(1)); + assert!(mqtt.retain.unwrap()); + assert!(mqtt.binding_version.is_none()); + assert!(mqtt.message_expiry_interval.is_none()); + } + #[test] fn test_extract_multiple_components() { let attrs: Vec = vec![ @@ -573,6 +778,30 @@ mod tests { assert_eq!(var1.examples, vec!["12".to_string(), "13".to_string()]); } + #[test] + fn test_extract_server_with_mqtt_bindings() { + let attrs: Vec = vec![parse_quote! { + #[asyncapi_server( + name = "staging", + host = "staging.example.com", + protocol = "wss", + pathname = "/api/{version}/ws/{userId}", + mqtt( + client_id = "abc", + ) + )] + }]; + + let meta = extract_asyncapi_spec_meta(&attrs); + let server = &meta.servers[0]; + + let mqtt = &server.mqtt; + + assert!(mqtt.is_some()); + let mqtt = mqtt.clone().unwrap(); + assert_eq!(mqtt.client_id, Some("abc".to_string())); + } + #[test] fn test_extract_channel_with_parameters() { let attrs: Vec = vec![parse_quote! { diff --git a/asyncapi-rust-codegen/src/lib.rs b/asyncapi-rust-codegen/src/lib.rs index 4bd97b2..2f3c854 100644 --- a/asyncapi-rust-codegen/src/lib.rs +++ b/asyncapi-rust-codegen/src/lib.rs @@ -222,6 +222,7 @@ pub fn derive_to_asyncapi_message(input: TokenStream) -> TokenStream { title: Option, content_type: Option, triggers_binary: bool, + mqtt: Option, } // Parse enum variants or struct @@ -256,6 +257,7 @@ pub fn derive_to_asyncapi_message(input: TokenStream) -> TokenStream { title: asyncapi_meta.title, content_type: asyncapi_meta.content_type, triggers_binary: asyncapi_meta.triggers_binary, + mqtt: asyncapi_meta.mqtt, }); } @@ -278,6 +280,7 @@ pub fn derive_to_asyncapi_message(input: TokenStream) -> TokenStream { title: asyncapi_meta.title, content_type: asyncapi_meta.content_type, triggers_binary: asyncapi_meta.triggers_binary, + mqtt: asyncapi_meta.mqtt, }] } Data::Union(_) => { @@ -326,6 +329,81 @@ pub fn derive_to_asyncapi_message(input: TokenStream) -> TokenStream { } }); + let message_mqtt_bindings = messages.iter().map(|m| { + if let Some(ref mqtt) = m.mqtt { + let payload_format_indicator = if let Some(s) = mqtt.payload_format_indicator { + quote! { Some(#s) } + } else { + quote! { None } + }; + let content_type = if let Some(s) = &mqtt.content_type { + quote! { Some(#s.to_string()) } + } else { + quote! { None } + }; + let binding_version = if let Some(s) = &mqtt.binding_version { + quote! { Some(#s.to_string()) } + } else { + quote! { None } + }; + let response_topic = match &mqtt.response_topic { + Some(crate::asyncapi_attrs::ResponseTopicMeta::Uri(t)) => { + quote! { + Some(asyncapi_rust::MqttResponseTopic::Uri( + #t.to_string() + )) + } + } + Some(crate::asyncapi_attrs::ResponseTopicMeta::Reference(r)) => { + quote! { + Some(asyncapi_rust::MqttResponseTopic::Schema({ + let schema = schemars::schema_for!(#r); + + let schema_json = serde_json::to_value(&schema) + .expect("Failed to serialize schema"); + + serde_json::from_value(schema_json) + .expect("Failed to deserialize schema") + })) + } + } + _ => quote! { None }, + }; + + let correlation_data = if let Some(c) = &mqtt.correlation_data { + quote! { + Some({ + let schema = schemars::schema_for!(#c); + + let schema_json = serde_json::to_value(&schema) + .expect("Failed to serialize schema"); + + serde_json::from_value::(schema_json) + .expect("Failed to deserialize schema") + }) + } + } else { + quote! { None } + }; + + quote! { + Some( + asyncapi_rust::MessageBindings { + mqtt: Some(asyncapi_rust::MqttMessageBindings { + payload_format_indicator: #payload_format_indicator, + content_type: #content_type, + binding_version: #binding_version, + response_topic: #response_topic, + correlation_data: #correlation_data + }) + } + ) + } + } else { + quote! { None } + } + }); + let tag_info = if let Some(tag) = tag_field { quote! { Some(#tag) @@ -466,6 +544,7 @@ pub fn derive_to_asyncapi_message(input: TokenStream) -> TokenStream { let summaries: &[Option] = &[#(#message_summaries),*]; let descriptions: &[Option] = &[#(#message_descriptions),*]; let content_types: &[Option] = &[#(#message_content_types),*]; + let bindings: &[Option] = &[#(#message_mqtt_bindings),*]; let mut messages = Vec::with_capacity(names.len()); for i in 0..names.len() { @@ -490,6 +569,7 @@ pub fn derive_to_asyncapi_message(input: TokenStream) -> TokenStream { description: descriptions[i].clone(), content_type: content_types[i].clone(), payload, + bindings: bindings[i].clone() }); } messages @@ -634,6 +714,112 @@ pub fn derive_asyncapi(input: TokenStream) -> TokenStream { } }; + let bindings = if let Some(mqtt) = &server.mqtt { + let client_id = if let Some(s) = &mqtt.client_id { + quote! { Some(#s.to_string()) } + } else { + quote! { None } + }; + let clean_session = if let Some(s) = mqtt.clean_session { + quote! { Some(#s) } + } else { + quote! { None } + }; + let last_will = if let Some(lw) = &mqtt.last_will { + let topic = &lw.topic; + let qos = lw.qos; + let retain = lw.retain; + let message = &lw.message; + quote! { + Some( + asyncapi_rust::MqttLastWill { + topic: #topic.to_string(), + qos: #qos, + retain: #retain, + message: #message.to_string() + } + ) + } + } else { + quote! { None } + }; + let binding_version = if let Some(s) = &mqtt.binding_version { + quote! { Some(#s.to_string()) } + } else { + quote! { None } + }; + + let session_expiry_interval = match &mqtt.session_expiry_interval { + Some(crate::asyncapi_spec_attrs::MqttBindingNumValueMeta::Value(t)) => { + quote! { + Some(asyncapi_rust::MqttBindingNumValue::Value( + #t + )) + } + } + Some(crate::asyncapi_spec_attrs::MqttBindingNumValueMeta::Reference(r)) => { + quote! { + Some(asyncapi_rust::MqttBindingNumValue::Schema({ + let schema = schemars::schema_for!(#r); + + let schema_json = serde_json::to_value(&schema) + .expect("Failed to serialize schema"); + + serde_json::from_value(schema_json) + .expect("Failed to deserialize schema") + })) + } + } + _ => quote! { None }, + }; + + let keep_alive = if let Some(k) = mqtt.keep_alive { + quote! { Some(#k) } + } else { + quote! { None } + }; + + let max_packet_size = match &mqtt.maximum_packet_size { + Some(crate::asyncapi_spec_attrs::MqttBindingNumValueMeta::Value(t)) => { + quote! { + Some(asyncapi_rust::MqttBindingNumValue::Value( + #t + )) + } + } + Some(crate::asyncapi_spec_attrs::MqttBindingNumValueMeta::Reference(r)) => { + quote! { + Some(asyncapi_rust::MqttBindingNumValue::Schema({ + let schema = schemars::schema_for!(#r); + + let schema_json = serde_json::to_value(&schema) + .expect("Failed to serialize schema"); + + serde_json::from_value(schema_json) + .expect("Failed to deserialize schema") + })) + } + } + _ => quote! { None }, + }; + + quote! { + Some(asyncapi_rust::ServerBindings { + mqtt: Some(asyncapi_rust::MqttServerBindings { + client_id: #client_id, + clean_session: #clean_session, + session_expiry_interval: #session_expiry_interval, + binding_version: #binding_version, + last_will: #last_will, + keep_alive: #keep_alive, + max_packet_size: #max_packet_size + }) + }) + } + } else { + quote! { None } + }; + quote! { servers.insert( #name.to_string(), @@ -643,6 +829,7 @@ pub fn derive_asyncapi(input: TokenStream) -> TokenStream { pathname: #pathname, description: #desc, variables: #variables, + bindings: #bindings } ); } @@ -769,6 +956,63 @@ pub fn derive_asyncapi(input: TokenStream) -> TokenStream { .to_compile_error(); }; + let bindings = if let Some(mqtt) = &operation.mqtt { + let qos = if let Some(s) = mqtt.qos { + quote! { Some(#s) } + } else { + quote! { None } + }; + + let retain = if let Some(b) = mqtt.retain { + quote! { Some(#b) } + } else { + quote! { None } + }; + + let binding_version = if let Some(s) = &mqtt.binding_version { + quote! { Some(#s.to_string()) } + } else { + quote! { None } + }; + + let message_expiry = match &mqtt.message_expiry_interval { + Some(crate::asyncapi_spec_attrs::MqttBindingNumValueMeta::Value(t)) => { + quote! { + Some(asyncapi_rust::MqttBindingNumValue::Value( + #t + )) + } + } + Some(crate::asyncapi_spec_attrs::MqttBindingNumValueMeta::Reference(r)) => { + quote! { + Some(asyncapi_rust::MqttBindingNumValue::Schema({ + let schema = schemars::schema_for!(#r); + + let schema_json = serde_json::to_value(&schema) + .expect("Failed to serialize schema"); + + serde_json::from_value(schema_json) + .expect("Failed to deserialize schema") + })) + } + } + _ => quote! { None }, + }; + + quote! { + Some(asyncapi_rust::OperationBindings { + mqtt: Some(asyncapi_rust::MqttOperationBindings { + qos: #qos, + retain: #retain, + message_expiry_interval: #message_expiry, + binding_version: #binding_version, + }) + }) + } + } else { + quote! { None } + }; + quote! { operations.insert( #name.to_string(), @@ -778,6 +1022,7 @@ pub fn derive_asyncapi(input: TokenStream) -> TokenStream { reference: format!("#/channels/{}", #channel_ref), }, messages: None, + bindings: #bindings } ); } diff --git a/asyncapi-rust-models/src/lib.rs b/asyncapi-rust-models/src/lib.rs index a587184..d56a295 100644 --- a/asyncapi-rust-models/src/lib.rs +++ b/asyncapi-rust-models/src/lib.rs @@ -151,9 +151,10 @@ pub struct Info { /// pathname: Some("/api/ws/{userId}".to_string()), /// description: Some("Production WebSocket server".to_string()), /// variables: Some(variables), +/// ..Default::default() /// }; /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Server { /// Server URL or host /// @@ -184,6 +185,80 @@ pub struct Server { /// A map of variable name to ServerVariable definition for variables used in the pathname #[serde(skip_serializing_if = "Option::is_none")] pub variables: Option>, + + /// Protocol specific bindings. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub bindings: Option, +} + +/// Protocol specific server bindings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerBindings { + /// Mqtt protocol specific bindings + #[serde(skip_serializing_if = "Option::is_none")] + pub mqtt: Option, +} + +/// Mqtt last will structure +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MqttLastWill { + /// The topic where the Last Will and Testament message will be sent. + pub topic: String, + + /// Defines how hard the broker/client will try to ensure that the Last Will and Testament message is received. Its value MUST be either 0, 1 or 2. + pub qos: u8, + + /// Last Will message. + pub message: String, + + /// Whether the broker should retain the Last Will and Testament message or not. + pub retain: bool, +} + +/// Represents mqtt binding properties which can be either a number or a json schema like sessionExpiryInterval or maximumPacketSize +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum MqttBindingNumValue { + /// The value variant + Value(u32), + /// The schema variant + Schema(Schema), +} + +/// Mqtt server binding +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MqttServerBindings { + /// The client identifier. + #[serde(skip_serializing_if = "Option::is_none", rename = "clientId")] + pub client_id: Option, + + /// Whether to create a persistent connection or not. When false, + /// the connection will be persistent. This is called clean start in MQTTv5. + #[serde(skip_serializing_if = "Option::is_none", rename = "cleanSession")] + pub clean_session: Option, + + /// Last Will and Testament configuration. topic, qos, message and retain are properties of this object as shown below. + #[serde(skip_serializing_if = "Option::is_none", rename = "lastWill")] + pub last_will: Option, + + /// Interval in seconds of the longest period of time the broker and the client can endure without sending a message. + #[serde(skip_serializing_if = "Option::is_none", rename = "keepAlive")] + pub keep_alive: Option, + + /// Interval in seconds or a Schema Object containing the definition of the interval. The broker maintains a session for a disconnected client until this interval expires. + #[serde( + skip_serializing_if = "Option::is_none", + rename = "sessionExpiryInterval" + )] + pub session_expiry_interval: Option, + + /// Number of bytes or a Schema Object representing the maximum packet size the client is willing to accept. + #[serde(skip_serializing_if = "Option::is_none", rename = "maximumPacketSize")] + pub max_packet_size: Option, + + /// The version of this binding. If omitted, "latest" MUST be assumed. + #[serde(skip_serializing_if = "Option::is_none", rename = "bindingVersion")] + pub binding_version: Option, } /// Server variable definition @@ -350,6 +425,7 @@ pub struct Parameter { /// description: None, /// content_type: Some("application/json".to_string()), /// payload: None, +/// ..Default::default() /// })); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] @@ -403,9 +479,10 @@ pub enum MessageRef { /// all_of: None, /// additional: IndexMap::new(), /// }))), +/// ..Default::default() /// }; /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Message { /// Message name /// @@ -446,6 +523,55 @@ pub struct Message { /// JSON Schema defining the structure of the message payload #[serde(skip_serializing_if = "Option::is_none")] pub payload: Option, + + /// Protocol specific bindings. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub bindings: Option, +} + +/// Protocol specific message bindings +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct MessageBindings { + /// Mqtt protocol specific message bindings + #[serde(skip_serializing_if = "Option::is_none")] + pub mqtt: Option, +} + +/// Mqtt response topic +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(untagged)] +pub enum MqttResponseTopic { + /// Topic Uri + Uri(String), + /// Schema for the response topic + Schema(Schema), +} + +/// Mqtt message bindings +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct MqttMessageBindings { + /// Either: 0 (zero): Indicates that the payload is unspecified bytes, or 1: Indicates that the payload is UTF-8 encoded character data. + #[serde( + skip_serializing_if = "Option::is_none", + rename = "payloadFormatIndicator" + )] + pub payload_format_indicator: Option, + + /// Correlation Data is used by the sender of the request message to identify which request the response message is for when it is received. + #[serde(skip_serializing_if = "Option::is_none", rename = "correlationData")] + pub correlation_data: Option, + + /// String describing the content type of the message payload. This should not conflict with the contentType field of the associated AsyncAPI Message object. + #[serde(skip_serializing_if = "Option::is_none", rename = "contentType")] + pub content_type: Option, + + /// The topic (channel URI) for a response message. + #[serde(skip_serializing_if = "Option::is_none", rename = "responseTopic")] + pub response_topic: Option, + + /// The version of this binding. If omitted, "latest" MUST be assumed. + #[serde(skip_serializing_if = "Option::is_none", rename = "bindingVersion")] + pub binding_version: Option, } /// Operation (send or receive) @@ -464,9 +590,10 @@ pub struct Message { /// reference: "#/channels/chat".to_string(), /// }, /// messages: None, +/// ..Default::default() /// }; /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Operation { /// Operation action (send or receive) /// @@ -483,20 +610,57 @@ pub struct Operation { /// Optional list of messages that can be used with this operation #[serde(skip_serializing_if = "Option::is_none")] pub messages: Option>, + + /// Protocol specific bindings + #[serde(skip_serializing_if = "Option::is_none", default)] + pub bindings: Option, +} + +/// Protocol specific operation bindings +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct OperationBindings { + /// Mqtt specific operation bindings + #[serde(skip_serializing_if = "Option::is_none")] + pub mqtt: Option, +} + +/// Mqtt operation bindings +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct MqttOperationBindings { + /// Defines the Quality of Service (QoS) levels for the message flow between client and server. + /// Its value MUST be either 0 (At most once delivery), 1 (At least once delivery), or 2 (Exactly once delivery). + #[serde(skip_serializing_if = "Option::is_none")] + pub qos: Option, + + /// Whether the broker should retain the message or not. + #[serde(skip_serializing_if = "Option::is_none")] + pub retain: Option, + + /// Interval in seconds or a Schema Object containing the definition of the lifetime of the message. + #[serde( + skip_serializing_if = "Option::is_none", + rename = "messageExpiryInterval" + )] + pub message_expiry_interval: Option, + + /// The version of this binding. If omitted, "latest" MUST be assumed. + #[serde(skip_serializing_if = "Option::is_none", rename = "bindingVersion")] + pub binding_version: Option, } /// Operation action type -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum OperationAction { /// Send message + #[default] Send, /// Receive message Receive, } /// Reference to a channel -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ChannelRef { /// $ref path #[serde(rename = "$ref")] diff --git a/asyncapi-rust/examples/chat_api.rs b/asyncapi-rust/examples/chat_api.rs index d26210c..ce88dbc 100644 --- a/asyncapi-rust/examples/chat_api.rs +++ b/asyncapi-rust/examples/chat_api.rs @@ -132,6 +132,7 @@ fn build_asyncapi_spec(messages: Vec) -> AsyncApiSpec { pathname: None, description: Some("Production WebSocket server".to_string()), variables: None, + bindings: None, }, ); @@ -168,6 +169,7 @@ fn build_asyncapi_spec(messages: Vec) -> AsyncApiSpec { }) .collect(), ), + bindings: None, }, ); @@ -190,6 +192,7 @@ fn build_asyncapi_spec(messages: Vec) -> AsyncApiSpec { }) .collect(), ), + bindings: None, }, ); diff --git a/asyncapi-rust/examples/mqtt_bindings.rs b/asyncapi-rust/examples/mqtt_bindings.rs new file mode 100644 index 0000000..1148344 --- /dev/null +++ b/asyncapi-rust/examples/mqtt_bindings.rs @@ -0,0 +1,147 @@ +use asyncapi_rust::{AsyncApi, ToAsyncApiMessage}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +// MQTT responseTopic is a topic string; model it as a string schema (a +// transparent newtype) rather than an object so the emitted schema is faithful. +#[derive(Serialize, Deserialize, JsonSchema)] +#[schemars(transparent)] +pub struct ExampleResponseTopic( + #[schemars(regex(pattern = "^response/client/([a-z0-9]+)$"))] pub String, +); + +#[derive(Serialize, Deserialize, JsonSchema)] +pub struct A { + pub correlation_data: u8, +} + +#[derive(Serialize, Deserialize, JsonSchema)] +pub struct MinMax { + #[schemars(range(min = 30, max = 1200))] + value: u32, +} + +#[derive(Serialize, Deserialize, JsonSchema)] +pub struct Min { + #[schemars(range(min = 256))] + value: u32, +} + +#[derive(Serialize, Deserialize, JsonSchema, ToAsyncApiMessage)] +#[asyncapi( + mqtt( + payload_format_indicator = 1, + content_type = "application/json", + response_topic = ExampleResponseTopic, + binding_version = "1.0", + correlation_data = A + ) +)] +pub struct MqttMessage; + +#[derive(AsyncApi)] +#[asyncapi( + title = "Mqtt Api", + version = "1.0.0", + description = "Mqtt bindings example" +)] +#[asyncapi_server( + name = "production", + host = "api.example.com", + protocol = "mqtt", + description = "Production mqtt broker", + mqtt( + client_id = "client1", + clean_session = false, + last_will( + topic = "devices/", + qos = 0, + message = "LastWillMessage", + retain = false + ), + keep_alive = 60, + session_expiry_interval = 3600, + maximum_packet_size = 1200, + binding_version = "1.0" + ) +)] +#[asyncapi_server( + name = "dev", + host = "dev.api.example.com", + protocol = "mqtt", + mqtt( + session_expiry_interval = MinMax, + maximum_packet_size = Min + ) +)] +#[asyncapi_channel(name = "test", address = "/test")] +#[asyncapi_operation( + name = "sendMqttMessage", + action = "send", + channel = "test", + mqtt( + qos = 2, + retain = true, + message_expiry_interval = 300, + binding_version = "1.0" + ) +)] +#[asyncapi_messages(MqttMessage)] +pub struct MqttApi; + +fn main() { + let spec = MqttApi::asyncapi_spec(); + + // Display Servers + if let Some(servers) = &spec.servers { + println!("🖥️ Servers ({}):", servers.len()); + for (name, server) in servers { + println!(" • {}", name); + println!(" Host: {}", server.host); + println!(" Protocol: {}", server.protocol); + if let Some(desc) = &server.description { + println!(" Description: {}", desc); + } + println!(" bindings: {:?}", server.bindings) + } + println!(); + } + + // Display messages (automatically populated from MqttMessage) + if let Some(components) = &spec.components { + if let Some(messages) = &components.messages { + println!("Messages (automatically included from message types):"); + for (name, message) in messages { + println!(" - {}", name); + if let Some(mqtt) = message.bindings.as_ref().and_then(|b| b.mqtt.as_ref()) { + println!(" Mqtt Bindings: {:?}", mqtt); + } + } + println!(); + } + } + + // Display Operations + if let Some(operations) = &spec.operations { + println!("⚡ Operations ({}):", operations.len()); + for (name, operation) in operations { + let action = match operation.action { + asyncapi_rust::OperationAction::Send => "send", + asyncapi_rust::OperationAction::Receive => "receive", + }; + println!(" • {} ({})", name, action); + println!(" Channel: {}", operation.channel.reference); + println!( + " Mqtt: {:?}", + operation.bindings.as_ref().and_then(|b| b.mqtt.as_ref()) + ); + } + println!(); + } + + // Serialize to JSON + let json = serde_json::to_string_pretty(&spec).expect("Failed to serialize spec"); + + println!("📄 Complete JSON Specification:\n"); + println!("{}", json); +} diff --git a/asyncapi-rust/tests/mqtt_bindings_integration.rs b/asyncapi-rust/tests/mqtt_bindings_integration.rs new file mode 100644 index 0000000..3de0f6c --- /dev/null +++ b/asyncapi-rust/tests/mqtt_bindings_integration.rs @@ -0,0 +1,108 @@ +//! Integration tests that derive a type with `mqtt(...)` bindings on a server, +//! operation, and message and assert the bindings appear in the generated +//! `AsyncApiSpec`. Unlike the `extract_*` unit tests (which only cover parsing) +//! and the `mqtt_bindings` example (which only prints), these assert the emitted +//! output, guarding against silent codegen regressions. + +use asyncapi_rust::{AsyncApi, ToAsyncApiMessage, schemars::JsonSchema}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, JsonSchema, ToAsyncApiMessage)] +#[asyncapi(mqtt( + payload_format_indicator = 1, + content_type = "application/json", + binding_version = "1.0" +))] +pub struct SensorReading { + pub value: f64, +} + +#[derive(AsyncApi)] +#[asyncapi(title = "MQTT Binding Test API", version = "1.0.0")] +#[asyncapi_server( + name = "production", + host = "mqtt.example.com", + protocol = "mqtt", + mqtt( + client_id = "test-client", + clean_session = false, + keep_alive = 60, + session_expiry_interval = 3600, + maximum_packet_size = 1200, + binding_version = "1.0" + ) +)] +#[asyncapi_channel(name = "sensors", address = "/sensors")] +#[asyncapi_operation( + name = "publishReading", + action = "send", + channel = "sensors", + mqtt( + qos = 2, + retain = true, + message_expiry_interval = 300, + binding_version = "1.0" + ) +)] +#[asyncapi_messages(SensorReading)] +pub struct MqttBindingApi; + +#[test] +fn server_mqtt_bindings_emitted() { + let spec = MqttBindingApi::asyncapi_spec(); + let servers = spec.servers.expect("servers present"); + let server = servers + .get("production") + .expect("production server present"); + let mqtt = server + .bindings + .as_ref() + .and_then(|b| b.mqtt.as_ref()) + .expect("server mqtt bindings present"); + + assert_eq!(mqtt.client_id.as_deref(), Some("test-client")); + assert_eq!(mqtt.clean_session, Some(false)); + assert_eq!(mqtt.keep_alive, Some(60)); + assert_eq!(mqtt.binding_version.as_deref(), Some("1.0")); + // Numeric-or-schema fields: assert they were carried through. + assert!(mqtt.session_expiry_interval.is_some()); + assert!(mqtt.max_packet_size.is_some()); +} + +#[test] +fn operation_mqtt_bindings_emitted() { + let spec = MqttBindingApi::asyncapi_spec(); + let operations = spec.operations.expect("operations present"); + let op = operations + .get("publishReading") + .expect("publishReading operation present"); + let mqtt = op + .bindings + .as_ref() + .and_then(|b| b.mqtt.as_ref()) + .expect("operation mqtt bindings present"); + + assert_eq!(mqtt.qos, Some(2)); + assert_eq!(mqtt.retain, Some(true)); + assert_eq!(mqtt.binding_version.as_deref(), Some("1.0")); + assert!(mqtt.message_expiry_interval.is_some()); +} + +#[test] +fn message_mqtt_bindings_emitted() { + let spec = MqttBindingApi::asyncapi_spec(); + let components = spec.components.expect("components present"); + let messages = components.messages.expect("messages present"); + let message = messages + .get("SensorReading") + .expect("SensorReading message present"); + let mqtt = message + .bindings + .as_ref() + .and_then(|b| b.mqtt.as_ref()) + .expect("message mqtt bindings present"); + + assert_eq!(mqtt.payload_format_indicator, Some(1)); + assert_eq!(mqtt.content_type.as_deref(), Some("application/json")); + assert_eq!(mqtt.binding_version.as_deref(), Some("1.0")); +}