asyncapi_rust_codegen/lib.rs
1//! Procedural macro implementation for asyncapi-rust
2//!
3//! This crate provides the procedural macros that power `asyncapi-rust`, enabling
4//! compile-time generation of AsyncAPI 3.0 specifications from Rust code.
5//!
6//! ## Overview
7//!
8//! Two derive macros are provided:
9//!
10//! ### `#[derive(ToAsyncApiMessage)]`
11//!
12//! Generates message metadata and JSON schemas from Rust types (structs or enums).
13//!
14//! - Works with [`serde`](https://serde.rs) for serialization patterns
15//! - Uses [`schemars`](https://docs.rs/schemars) for JSON Schema generation
16//! - Supports `#[asyncapi(...)]` helper attributes for documentation
17//! - Generates methods: `asyncapi_message_names()`, `asyncapi_messages()`, etc.
18//!
19//! **Example:**
20//! ```rust,ignore
21//! use asyncapi_rust::{ToAsyncApiMessage, schemars::JsonSchema};
22//! use serde::{Deserialize, Serialize};
23//!
24//! #[derive(Serialize, Deserialize, JsonSchema, ToAsyncApiMessage)]
25//! #[serde(tag = "type")]
26//! pub enum ChatMessage {
27//! #[serde(rename = "user.join")]
28//! #[asyncapi(
29//! summary = "User joins",
30//! description = "Sent when a user enters a room"
31//! )]
32//! UserJoin { username: String, room: String },
33//!
34//! #[serde(rename = "chat.message")]
35//! #[asyncapi(summary = "Chat message")]
36//! Chat { username: String, room: String, text: String },
37//! }
38//!
39//! // Generated methods available:
40//! let names = ChatMessage::asyncapi_message_names();
41//! let messages = ChatMessage::asyncapi_messages(); // Requires JsonSchema
42//! ```
43//!
44//! ### `#[derive(AsyncApi)]`
45//!
46//! Generates complete AsyncAPI 3.0 specifications with servers, channels, and operations.
47//!
48//! - Requires `title` and `version` attributes
49//! - Supports optional `description` attribute
50//! - Use `#[asyncapi_server(...)]` to define servers
51//! - Use `#[asyncapi_channel(...)]` to define channels
52//! - Use `#[asyncapi_operation(...)]` to define operations
53//! - Can use multiple of each attribute type
54//!
55//! **Example:**
56//! ```rust,ignore
57//! use asyncapi_rust::AsyncApi;
58//!
59//! #[derive(AsyncApi)]
60//! #[asyncapi(
61//! title = "Chat API",
62//! version = "1.0.0",
63//! description = "Real-time chat application"
64//! )]
65//! #[asyncapi_server(
66//! name = "production",
67//! host = "chat.example.com",
68//! protocol = "wss",
69//! description = "Production WebSocket server"
70//! )]
71//! #[asyncapi_channel(
72//! name = "chat",
73//! address = "/ws/chat"
74//! )]
75//! #[asyncapi_operation(
76//! name = "sendMessage",
77//! action = "send",
78//! channel = "chat"
79//! )]
80//! #[asyncapi_operation(
81//! name = "receiveMessage",
82//! action = "receive",
83//! channel = "chat"
84//! )]
85//! struct ChatApi;
86//!
87//! // Generated method:
88//! let spec = ChatApi::asyncapi_spec();
89//! ```
90//!
91//! ## Supported Attributes
92//!
93//! ### `#[asyncapi(...)]` on message types
94//!
95//! Helper attributes for documenting messages (used with `ToAsyncApiMessage`):
96//!
97//! - `summary = "..."` - Short summary of the message
98//! - `description = "..."` - Detailed description
99//! - `title = "..."` - Human-readable title (defaults to message name)
100//! - `content_type = "..."` - Content type (defaults to "application/json")
101//! - `triggers_binary` - Flag for binary messages (sets content_type to "application/octet-stream")
102//!
103//! ### `#[asyncapi(...)]` on API specs
104//!
105//! Required attributes for complete specifications (used with `AsyncApi`):
106//!
107//! - `title = "..."` - API title (required)
108//! - `version = "..."` - API version (required)
109//! - `description = "..."` - API description (optional)
110//!
111//! ### `#[asyncapi_server(...)]`
112//!
113//! Define server connection information:
114//!
115//! - `name = "..."` - Server identifier (required)
116//! - `host = "..."` - Server host/URL (required)
117//! - `protocol = "..."` - Protocol (e.g., "wss", "ws", "grpc") (required)
118//! - `description = "..."` - Server description (optional)
119//!
120//! ### `#[asyncapi_channel(...)]`
121//!
122//! Define communication channels:
123//!
124//! - `name = "..."` - Channel identifier (required)
125//! - `address = "..."` - Channel path/address (optional)
126//!
127//! ### `#[asyncapi_operation(...)]`
128//!
129//! Define send/receive operations:
130//!
131//! - `name = "..."` - Operation identifier (required)
132//! - `action = "send"|"receive"` - Operation type (required)
133//! - `channel = "..."` - Channel reference (required)
134//!
135//! ## Integration with serde
136//!
137//! The macros respect serde attributes for naming and structure:
138//!
139//! - `#[serde(rename = "...")]` - Use custom name in AsyncAPI spec
140//! - `#[serde(tag = "...")]` - Tagged enum with discriminator field
141//! - `#[serde(skip)]` - Exclude fields from schema
142//! - `#[serde(skip_serializing_if = "...")]` - Optional fields
143//!
144//! ## Integration with schemars
145//!
146//! JSON schemas are generated automatically using schemars:
147//!
148//! - Requires `JsonSchema` derive on message types
149//! - Generates complete JSON Schema from Rust type definitions
150//! - Supports nested types, generics, and references
151//! - Schemas include validation rules from type constraints
152//!
153//! ## Generated Code
154//!
155//! The macros generate implementations with these methods:
156//!
157//! **From `ToAsyncApiMessage`:**
158//! - `asyncapi_message_names() -> Vec<&'static str>` - Get all message names
159//! - `asyncapi_message_count() -> usize` - Number of messages
160//! - `asyncapi_tag_field() -> Option<&'static str>` - Serde tag field if present
161//! - `asyncapi_messages() -> Vec<Message>` - Generate messages with schemas
162//!
163//! **From `AsyncApi`:**
164//! - `asyncapi_spec() -> AsyncApiSpec` - Generate complete specification
165//!
166//! ## Implementation Notes
167//!
168//! - All code generation happens at compile time (proc macros)
169//! - Zero runtime cost - generates plain Rust code
170//! - Compile errors if documentation drifts from code
171//! - Type-safe - uses Rust's type system for validation
172
173#![warn(clippy::all)]
174
175use proc_macro::TokenStream;
176use quote::quote;
177use syn::{Data, DeriveInput, parse_macro_input};
178
179mod asyncapi_attrs;
180mod asyncapi_spec_attrs;
181mod serde_attrs;
182
183use asyncapi_attrs::extract_asyncapi_meta;
184use asyncapi_spec_attrs::extract_asyncapi_spec_meta;
185use serde_attrs::{extract_serde_rename, extract_serde_tag};
186
187/// Derive macro for generating AsyncAPI message metadata
188///
189/// # Example
190///
191/// ```rust,ignore
192/// use asyncapi_rust::ToAsyncApiMessage;
193/// use serde::{Deserialize, Serialize};
194///
195/// #[derive(Serialize, Deserialize, ToAsyncApiMessage)]
196/// #[serde(tag = "type")]
197/// pub enum Message {
198/// #[serde(rename = "chat")]
199/// Chat { room: String, text: String },
200/// Echo { id: i64, text: String },
201/// }
202/// ```
203#[proc_macro_derive(ToAsyncApiMessage, attributes(asyncapi))]
204pub fn derive_to_asyncapi_message(input: TokenStream) -> TokenStream {
205 let input = parse_macro_input!(input as DeriveInput);
206 let name = &input.ident;
207
208 // Extract serde tag attribute from enum
209 let tag_field = extract_serde_tag(&input.attrs);
210
211 // Struct to hold message metadata
212 struct MessageMeta {
213 /// Stable message identity used in components.messages and asyncapi_message_names().
214 /// Defaults to the Rust variant/type identifier; overridable via
215 /// `#[asyncapi(message_name = "...")]`.
216 name: String,
217 /// Wire discriminant value from serde rename (used for payload schema lookup).
218 /// May be an empty string when `#[serde(rename = "")]`; defaults to variant ident.
219 discriminant: String,
220 summary: Option<String>,
221 description: Option<String>,
222 title: Option<String>,
223 content_type: Option<String>,
224 triggers_binary: bool,
225 }
226
227 // Parse enum variants or struct
228 let messages = match &input.data {
229 Data::Enum(data_enum) => {
230 let mut message_metas = Vec::new();
231
232 for variant in &data_enum.variants {
233 let variant_name = &variant.ident;
234 let variant_ident_str = variant_name.to_string();
235
236 // Wire discriminant: serde rename if present (even if empty), else variant ident.
237 let discriminant = extract_serde_rename(&variant.attrs)
238 .unwrap_or_else(|| variant_ident_str.clone());
239
240 // Extract asyncapi metadata
241 let asyncapi_meta = extract_asyncapi_meta(&variant.attrs);
242
243 // Message identity: explicit message_name override, else variant ident.
244 // We deliberately do NOT use the serde rename here — it may be empty,
245 // non-unique across enums, or unsuitable as a code identifier.
246 let message_name = asyncapi_meta
247 .message_name
248 .clone()
249 .unwrap_or_else(|| variant_ident_str.clone());
250
251 message_metas.push(MessageMeta {
252 name: message_name,
253 discriminant,
254 summary: asyncapi_meta.summary,
255 description: asyncapi_meta.description,
256 title: asyncapi_meta.title,
257 content_type: asyncapi_meta.content_type,
258 triggers_binary: asyncapi_meta.triggers_binary,
259 });
260 }
261
262 message_metas
263 }
264 Data::Struct(_) => {
265 // For structs, extract metadata from the struct itself
266 let asyncapi_meta = extract_asyncapi_meta(&input.attrs);
267 let struct_name = name.to_string();
268 let message_name = asyncapi_meta
269 .message_name
270 .clone()
271 .unwrap_or_else(|| struct_name.clone());
272
273 vec![MessageMeta {
274 name: message_name,
275 discriminant: struct_name,
276 summary: asyncapi_meta.summary,
277 description: asyncapi_meta.description,
278 title: asyncapi_meta.title,
279 content_type: asyncapi_meta.content_type,
280 triggers_binary: asyncapi_meta.triggers_binary,
281 }]
282 }
283 Data::Union(_) => {
284 return syn::Error::new_spanned(name, "ToAsyncApiMessage cannot be derived for unions")
285 .to_compile_error()
286 .into();
287 }
288 };
289
290 let message_count = messages.len();
291 let message_literals = messages.iter().map(|m| m.name.as_str());
292
293 // Prepare metadata for message generation
294 let message_names_for_gen = messages.iter().map(|m| m.name.as_str());
295 // Wire discriminant for each variant — used to look up per-variant schemas at runtime.
296 let message_discriminants = messages.iter().map(|m| m.discriminant.as_str());
297 let message_titles = messages.iter().map(|m| {
298 if let Some(ref title) = m.title {
299 quote! { Some(#title.to_string()) }
300 } else {
301 let name = &m.name;
302 quote! { Some(#name.to_string()) }
303 }
304 });
305 let message_summaries = messages.iter().map(|m| {
306 if let Some(ref summary) = m.summary {
307 quote! { Some(#summary.to_string()) }
308 } else {
309 quote! { None }
310 }
311 });
312 let message_descriptions = messages.iter().map(|m| {
313 if let Some(ref desc) = m.description {
314 quote! { Some(#desc.to_string()) }
315 } else {
316 quote! { None }
317 }
318 });
319 let message_content_types = messages.iter().map(|m| {
320 if let Some(ref ct) = m.content_type {
321 quote! { Some(#ct.to_string()) }
322 } else if m.triggers_binary {
323 quote! { Some("application/octet-stream".to_string()) }
324 } else {
325 quote! { Some("application/json".to_string()) }
326 }
327 });
328
329 let tag_info = if let Some(tag) = tag_field {
330 quote! {
331 Some(#tag)
332 }
333 } else {
334 quote! { None }
335 };
336
337 let expanded = quote! {
338 // const _: () scopes the helper so it doesn't leak into the user's namespace
339 const _: () = {
340 /// Rewrites schemars' `#/$defs/X` refs to `#/components/schemas/X` in-place.
341 fn rewrite_defs_refs(value: &mut serde_json::Value) {
342 match value {
343 serde_json::Value::Object(map) => {
344 if let Some(r) = map.get_mut("$ref") {
345 if let Some(s) = r.as_str() {
346 if let Some(name) = s.strip_prefix("#/$defs/") {
347 *r = serde_json::Value::String(
348 format!("#/components/schemas/{}", name)
349 );
350 }
351 }
352 }
353 for v in map.values_mut() {
354 rewrite_defs_refs(v);
355 }
356 }
357 serde_json::Value::Array(arr) => {
358 for v in arr.iter_mut() {
359 rewrite_defs_refs(v);
360 }
361 }
362 _ => {}
363 }
364 }
365
366 impl #name {
367 /// Get AsyncAPI message names for this type
368 pub fn asyncapi_message_names() -> Vec<&'static str> {
369 vec![#(#message_literals),*]
370 }
371
372 /// Get the number of messages in this type
373 pub fn asyncapi_message_count() -> usize {
374 #message_count
375 }
376
377 /// Get the serde tag field name if this is a tagged enum
378 pub fn asyncapi_tag_field() -> Option<&'static str> {
379 #tag_info
380 }
381
382 /// Return shared schema definitions for this type, keyed by name.
383 ///
384 /// These are the `$defs` that schemars generates for sub-types referenced
385 /// by this type's variants. The `AsyncApi` derive collects them into
386 /// `components.schemas` so message payloads can reference them via
387 /// `#/components/schemas/X` instead of embedding them inline.
388 pub fn asyncapi_schemas() -> asyncapi_rust::indexmap::IndexMap<String, asyncapi_rust::Schema>
389 where
390 Self: schemars::JsonSchema,
391 {
392 use schemars::schema_for;
393 let schema = schema_for!(Self);
394 let schema_json = serde_json::to_value(&schema)
395 .expect("Failed to serialize schema");
396
397 let mut result = asyncapi_rust::indexmap::IndexMap::new();
398 if let Some(defs) = schema_json.get("$defs").and_then(|v| v.as_object()) {
399 for (name, def_schema) in defs {
400 let mut def = def_schema.clone();
401 rewrite_defs_refs(&mut def);
402 if let Ok(s) = serde_json::from_value::<asyncapi_rust::Schema>(def) {
403 result.insert(name.clone(), s);
404 }
405 }
406 }
407 result
408 }
409
410 /// Generate AsyncAPI Message objects with JSON schemas.
411 ///
412 /// For internally-tagged enums each message carries only its own variant
413 /// schema. `$ref`s within payloads point to `#/components/schemas/X`;
414 /// the corresponding definitions are available via `asyncapi_schemas()`.
415 pub fn asyncapi_messages() -> Vec<asyncapi_rust::Message>
416 where
417 Self: schemars::JsonSchema,
418 {
419 use schemars::schema_for;
420
421 let schema = schema_for!(Self);
422 let schema_json = serde_json::to_value(&schema)
423 .expect("Failed to serialize schema");
424
425 // Build a discriminant→schema map using the actual serde tag field name.
426 let tag_field = Self::asyncapi_tag_field();
427 let mut variant_schemas: asyncapi_rust::indexmap::IndexMap<String, serde_json::Value> =
428 asyncapi_rust::indexmap::IndexMap::new();
429 if let Some(tag) = tag_field {
430 if let Some(variants) = schema_json.get("oneOf").and_then(|v| v.as_array()) {
431 for variant in variants {
432 let discriminant = variant
433 .get("properties")
434 .and_then(|props| props.get(tag))
435 .and_then(|tag_prop| {
436 tag_prop.get("const").or_else(|| {
437 tag_prop
438 .get("enum")
439 .and_then(|e| e.as_array())
440 .and_then(|a| a.first())
441 })
442 })
443 .and_then(|v| v.as_str())
444 .map(|s| s.to_string());
445
446 if let Some(name) = discriminant {
447 let mut variant_schema = variant.clone();
448 // Drop $defs — they live in components.schemas, not the payload.
449 if let Some(obj) = variant_schema.as_object_mut() {
450 obj.remove("$defs");
451 }
452 rewrite_defs_refs(&mut variant_schema);
453 variant_schemas.insert(name, variant_schema);
454 }
455 }
456 }
457 }
458
459 // Metadata arrays are baked in at compile time; schemas resolved at runtime.
460 let names: &[&str] = &[#(#message_names_for_gen),*];
461 // Discriminants are the serde rename values — used to look up per-variant
462 // schemas. Separate from names so empty renames and cross-enum collisions
463 // don't affect message identity.
464 let discriminants: &[&str] = &[#(#message_discriminants),*];
465 let titles: &[Option<String>] = &[#(#message_titles),*];
466 let summaries: &[Option<String>] = &[#(#message_summaries),*];
467 let descriptions: &[Option<String>] = &[#(#message_descriptions),*];
468 let content_types: &[Option<String>] = &[#(#message_content_types),*];
469
470 let mut messages = Vec::with_capacity(names.len());
471 for i in 0..names.len() {
472 let msg_name = names[i];
473 let discriminant = discriminants[i];
474 let payload = if let Some(v) = variant_schemas.get(discriminant) {
475 serde_json::from_value(v.clone()).ok()
476 } else {
477 // Structs, untagged enums, or variants not in the map:
478 // remove $defs and rewrite refs in the full schema.
479 let mut fallback = schema_json.clone();
480 if let Some(obj) = fallback.as_object_mut() {
481 obj.remove("$defs");
482 }
483 rewrite_defs_refs(&mut fallback);
484 serde_json::from_value(fallback).ok()
485 };
486 messages.push(asyncapi_rust::Message {
487 name: Some(msg_name.to_string()),
488 title: titles[i].clone(),
489 summary: summaries[i].clone(),
490 description: descriptions[i].clone(),
491 content_type: content_types[i].clone(),
492 payload,
493 });
494 }
495 messages
496 }
497 }
498 };
499 };
500
501 TokenStream::from(expanded)
502}
503
504/// Derive macro for generating complete AsyncAPI specification
505///
506/// # Example
507///
508/// ```rust,ignore
509/// use asyncapi_rust::AsyncApi;
510///
511/// #[derive(AsyncApi)]
512/// #[asyncapi(
513/// title = "Chat API",
514/// version = "1.0.0",
515/// description = "A real-time chat API"
516/// )]
517/// struct ChatApi;
518/// ```
519#[proc_macro_derive(
520 AsyncApi,
521 attributes(
522 asyncapi,
523 asyncapi_server,
524 asyncapi_channel,
525 asyncapi_operation,
526 asyncapi_messages
527 )
528)]
529pub fn derive_asyncapi(input: TokenStream) -> TokenStream {
530 let input = parse_macro_input!(input as DeriveInput);
531 let name = &input.ident;
532
533 // Extract asyncapi spec metadata
534 let spec_meta = extract_asyncapi_spec_meta(&input.attrs);
535
536 // Validate required fields
537 let title = match spec_meta.title {
538 Some(t) => t,
539 None => {
540 return syn::Error::new_spanned(
541 name,
542 "AsyncApi requires a title attribute: #[asyncapi(title = \"...\")]",
543 )
544 .to_compile_error()
545 .into();
546 }
547 };
548
549 let version = match spec_meta.version {
550 Some(v) => v,
551 None => {
552 return syn::Error::new_spanned(
553 name,
554 "AsyncApi requires a version attribute: #[asyncapi(version = \"...\")]",
555 )
556 .to_compile_error()
557 .into();
558 }
559 };
560
561 let description = if let Some(desc) = spec_meta.description {
562 quote! { Some(#desc.to_string()) }
563 } else {
564 quote! { None }
565 };
566
567 // Generate servers
568 let servers_code = if spec_meta.servers.is_empty() {
569 quote! { None }
570 } else {
571 let server_entries = spec_meta.servers.iter().map(|server| {
572 let name = &server.name;
573 let host = &server.host;
574 let protocol = &server.protocol;
575 let pathname = if let Some(p) = &server.pathname {
576 quote! { Some(#p.to_string()) }
577 } else {
578 quote! { None }
579 };
580 let desc = if let Some(d) = &server.description {
581 quote! { Some(#d.to_string()) }
582 } else {
583 quote! { None }
584 };
585
586 // Generate server variables
587 let variables = if server.variables.is_empty() {
588 quote! { None }
589 } else {
590 let var_entries = server.variables.iter().map(|var| {
591 let var_name = &var.name;
592 let var_desc = if let Some(d) = &var.description {
593 quote! { Some(#d.to_string()) }
594 } else {
595 quote! { None }
596 };
597 let var_default = if let Some(d) = &var.default {
598 quote! { Some(#d.to_string()) }
599 } else {
600 quote! { None }
601 };
602 let var_enum = if var.enum_values.is_empty() {
603 quote! { None }
604 } else {
605 let enum_vals = &var.enum_values;
606 quote! { Some(vec![#(#enum_vals.to_string()),*]) }
607 };
608 let var_examples = if var.examples.is_empty() {
609 quote! { None }
610 } else {
611 let examples = &var.examples;
612 quote! { Some(vec![#(#examples.to_string()),*]) }
613 };
614
615 quote! {
616 server_variables.insert(
617 #var_name.to_string(),
618 asyncapi_rust::ServerVariable {
619 description: #var_desc,
620 default: #var_default,
621 enum_values: #var_enum,
622 examples: #var_examples,
623 }
624 );
625 }
626 });
627
628 quote! {
629 {
630 let mut server_variables = asyncapi_rust::indexmap::IndexMap::new();
631 #(#var_entries)*
632 Some(server_variables)
633 }
634 }
635 };
636
637 quote! {
638 servers.insert(
639 #name.to_string(),
640 asyncapi_rust::Server {
641 host: #host.to_string(),
642 protocol: #protocol.to_string(),
643 pathname: #pathname,
644 description: #desc,
645 variables: #variables,
646 }
647 );
648 }
649 });
650
651 quote! {
652 {
653 let mut servers = asyncapi_rust::indexmap::IndexMap::new();
654 #(#server_entries)*
655 Some(servers)
656 }
657 }
658 };
659
660 // Generate channels
661 let channels_code = if spec_meta.channels.is_empty() {
662 quote! { None }
663 } else {
664 let channel_entries = spec_meta.channels.iter().map(|channel| {
665 let name = &channel.name;
666 let address = if let Some(addr) = &channel.address {
667 quote! { Some(#addr.to_string()) }
668 } else {
669 quote! { None }
670 };
671
672 // Generate channel parameters
673 let parameters = if channel.parameters.is_empty() {
674 quote! { None }
675 } else {
676 let param_entries = channel.parameters.iter().map(|param| {
677 let param_name = ¶m.name;
678 let param_desc = if let Some(d) = ¶m.description {
679 quote! { Some(#d.to_string()) }
680 } else {
681 quote! { None }
682 };
683 let param_default = if let Some(d) = ¶m.default {
684 quote! { Some(#d.to_string()) }
685 } else {
686 quote! { None }
687 };
688 let param_enum = if param.enum_values.is_empty() {
689 quote! { None }
690 } else {
691 let vals = ¶m.enum_values;
692 quote! { Some(vec![#(#vals.to_string()),*]) }
693 };
694 let param_examples = if param.examples.is_empty() {
695 quote! { None }
696 } else {
697 let vals = ¶m.examples;
698 quote! { Some(vec![#(#vals.to_string()),*]) }
699 };
700 let param_location = if let Some(l) = ¶m.location {
701 quote! { Some(#l.to_string()) }
702 } else {
703 quote! { None }
704 };
705
706 quote! {
707 channel_parameters.insert(
708 #param_name.to_string(),
709 asyncapi_rust::Parameter {
710 description: #param_desc,
711 default: #param_default,
712 enum_values: #param_enum,
713 examples: #param_examples,
714 location: #param_location,
715 }
716 );
717 }
718 });
719
720 quote! {
721 {
722 let mut channel_parameters = asyncapi_rust::indexmap::IndexMap::new();
723 #(#param_entries)*
724 Some(channel_parameters)
725 }
726 }
727 };
728
729 quote! {
730 channels.insert(
731 #name.to_string(),
732 asyncapi_rust::Channel {
733 address: #address,
734 messages: None,
735 parameters: #parameters,
736 }
737 );
738 }
739 });
740
741 quote! {
742 {
743 let mut channels = asyncapi_rust::indexmap::IndexMap::new();
744 #(#channel_entries)*
745 Some(channels)
746 }
747 }
748 };
749
750 // Generate operations
751 let operations_code = if spec_meta.operations.is_empty() {
752 quote! { None }
753 } else {
754 let operation_entries = spec_meta.operations.iter().map(|operation| {
755 let name = &operation.name;
756 let channel_ref = &operation.channel;
757 let action = &operation.action;
758
759 // Convert action string to OperationAction enum
760 let action_enum = if action == "send" {
761 quote! { asyncapi_rust::OperationAction::Send }
762 } else if action == "receive" {
763 quote! { asyncapi_rust::OperationAction::Receive }
764 } else {
765 return syn::Error::new_spanned(
766 name,
767 format!("Invalid action '{}', must be 'send' or 'receive'", action),
768 )
769 .to_compile_error();
770 };
771
772 quote! {
773 operations.insert(
774 #name.to_string(),
775 asyncapi_rust::Operation {
776 action: #action_enum,
777 channel: asyncapi_rust::ChannelRef {
778 reference: format!("#/channels/{}", #channel_ref),
779 },
780 messages: None,
781 }
782 );
783 }
784 });
785
786 quote! {
787 {
788 let mut operations = asyncapi_rust::indexmap::IndexMap::new();
789 #(#operation_entries)*
790 Some(operations)
791 }
792 }
793 };
794
795 // Generate components with messages and hoisted shared schemas
796 let components_code = if spec_meta.message_types.is_empty() {
797 quote! { None }
798 } else {
799 let type_calls = spec_meta.message_types.iter().map(|type_name| {
800 quote! {
801 for msg in #type_name::asyncapi_messages() {
802 if let Some(ref name) = msg.name {
803 if messages.contains_key(name.as_str()) {
804 panic!(
805 "asyncapi-rust: message name collision for '{}' from {}. \
806 Use #[asyncapi(message_name = \"...\")] on one variant to disambiguate.",
807 name,
808 stringify!(#type_name)
809 );
810 }
811 messages.insert(name.clone(), msg.clone());
812 }
813 }
814 // Hoist shared $defs into components.schemas (first writer wins on name collision)
815 for (name, schema) in #type_name::asyncapi_schemas() {
816 schemas.entry(name).or_insert(schema);
817 }
818 }
819 });
820
821 quote! {
822 {
823 let mut messages = asyncapi_rust::indexmap::IndexMap::new();
824 let mut schemas = asyncapi_rust::indexmap::IndexMap::new();
825 #(#type_calls)*
826 Some(asyncapi_rust::Components {
827 messages: if messages.is_empty() { None } else { Some(messages) },
828 schemas: if schemas.is_empty() { None } else { Some(schemas) },
829 })
830 }
831 }
832 };
833
834 let expanded = quote! {
835 impl #name {
836 /// Generate the AsyncAPI specification
837 ///
838 /// Returns an AsyncApiSpec with Info, Servers, Channels, and Operations
839 /// sections populated from attributes.
840 pub fn asyncapi_spec() -> asyncapi_rust::AsyncApiSpec {
841 asyncapi_rust::AsyncApiSpec {
842 asyncapi: "3.0.0".to_string(),
843 info: asyncapi_rust::Info {
844 title: #title.to_string(),
845 version: #version.to_string(),
846 description: #description,
847 },
848 servers: #servers_code,
849 channels: #channels_code,
850 operations: #operations_code,
851 components: #components_code,
852 }
853 }
854 }
855 };
856
857 TokenStream::from(expanded)
858}
859
860#[cfg(test)]
861mod tests {
862 #[test]
863 fn test_placeholder() {
864 // Macro expansion tests will go here
865 }
866}