Skip to main content

spikard_cli/codegen/asyncapi/
mod.rs

1//! `AsyncAPI` v3 specification parsing and code generation
2//!
3//! This module orchestrates `AsyncAPI` spec parsing and code generation across
4//! multiple languages. The actual generation logic is delegated to language-specific
5//! generators in the `generators/` module.
6//!
7//! `AsyncAPI` is the standard for describing event-driven APIs, similar to
8//! how `OpenAPI` describes REST APIs.
9
10pub mod generators;
11pub mod spec_parser;
12
13pub use generators::{
14    AsyncApiGenerator, ChannelInfo, ChannelMessage, ElixirAsyncApiGenerator, PhpAsyncApiGenerator,
15    PythonAsyncApiGenerator, RubyAsyncApiGenerator, RustAsyncApiGenerator, TypeScriptAsyncApiGenerator,
16};
17pub use spec_parser::{
18    Protocol, collect_channel_operations, collect_message_channels, collect_message_operations,
19    detect_primary_protocol, extract_message_schemas, parse_asyncapi_schema, resolve_message_from_ref,
20};
21
22use anyhow::{Context, Result, bail};
23use asyncapiv3::spec::AsyncApiV3Spec;
24use std::fs;
25use std::path::{Path, PathBuf};
26
27/// Generate fixture files from message schemas
28///
29/// Creates JSON fixture files in the output directory for each message type
30pub fn generate_fixtures(spec: &AsyncApiV3Spec, output_dir: &Path, protocol: Protocol) -> Result<Vec<PathBuf>> {
31    let schemas = extract_message_schemas(spec)?;
32    let (message_channels, alias_map) = collect_message_channels(spec);
33    let message_operations = collect_message_operations(spec, &alias_map);
34
35    if schemas.is_empty() {
36        tracing::warn!("No message schemas found in AsyncAPI spec");
37        return Ok(Vec::new());
38    }
39
40    let subdir = match protocol {
41        Protocol::WebSocket => "websockets",
42        Protocol::Sse => "sse",
43        Protocol::Http => "http",
44        _ => "asyncapi",
45    };
46
47    let target_dir = output_dir.join(subdir);
48    fs::create_dir_all(&target_dir).with_context(|| format!("Failed to create directory: {}", target_dir.display()))?;
49
50    let mut generated_paths = Vec::new();
51
52    for (message_name, definition) in &schemas {
53        let fixture_path = target_dir.join(format!("{message_name}.json"));
54
55        let channel = message_channels.get(message_name).cloned();
56        let operations = message_operations
57            .get(message_name)
58            .cloned()
59            .unwrap_or_default()
60            .into_iter()
61            .map(|meta| {
62                serde_json::json!({
63                    "name": meta.name,
64                    "action": meta.action,
65                    "replies": meta.replies,
66                })
67            })
68            .collect::<Vec<_>>();
69        let fixture = serde_json::json!({
70            "name": message_name,
71            "description": format!("Test fixture for {} message", message_name),
72            "protocol": protocol.as_str(),
73            "channel": channel,
74            "schema": definition.schema,
75            "examples": definition.examples,
76            "operations": operations,
77        });
78
79        let fixture_json = serde_json::to_string_pretty(&fixture).context("Failed to serialize fixture to JSON")?;
80
81        fs::write(&fixture_path, fixture_json)
82            .with_context(|| format!("Failed to write fixture: {}", fixture_path.display()))?;
83
84        println!("  Generated: {}", fixture_path.display());
85        generated_paths.push(fixture_path);
86    }
87
88    Ok(generated_paths)
89}
90
91/// Extract channel information from `AsyncAPI` spec for code generation
92pub fn extract_channel_info(spec: &AsyncApiV3Spec) -> Result<Vec<ChannelInfo>> {
93    use asyncapiv3::spec::common::Either;
94
95    let mut channels = Vec::new();
96    let operation_map = collect_channel_operations(spec);
97    let message_schemas = extract_message_schemas(spec)?;
98    let (_message_channels, alias_map) = collect_message_channels(spec);
99
100    for (channel_path, channel_ref_or) in &spec.channels {
101        match channel_ref_or {
102            Either::Right(channel) => {
103                let messages: Vec<String> = channel.messages.keys().cloned().collect();
104                let slug = channel_path.trim_start_matches('/').replace('/', "_");
105                let message_definitions = channel
106                    .messages
107                    .iter()
108                    .map(|(message_name, message_ref_or)| {
109                        let inline_key = format!("{slug}_{message_name}");
110                        let schema_name = match message_ref_or {
111                            Either::Right(_) => inline_key.clone(),
112                            Either::Left(reference) => alias_map
113                                .get(&inline_key)
114                                .cloned()
115                                .or_else(|| resolve_message_from_ref(&reference.reference))
116                                .unwrap_or_else(|| inline_key.clone()),
117                        };
118                        let definition = message_schemas.get(&schema_name);
119
120                        ChannelMessage {
121                            name: message_name.clone(),
122                            schema_name,
123                            schema: definition.map(|definition| definition.schema.clone()),
124                        }
125                    })
126                    .collect();
127                let raw_path = channel.address.clone().unwrap_or_else(|| channel_path.clone());
128                let normalized_path = if raw_path.starts_with('/') {
129                    raw_path.clone()
130                } else {
131                    format!("/{raw_path}")
132                };
133                let _operations = operation_map.get(&normalized_path).cloned().unwrap_or_default();
134
135                channels.push(ChannelInfo {
136                    name: channel_path.trim_start_matches('/').replace('/', "_"),
137                    path: normalized_path,
138                    messages,
139                    message_definitions,
140                });
141            }
142            Either::Left(_reference) => {
143                tracing::debug!("Skipping channel reference: {}", channel_path);
144            }
145        }
146    }
147
148    Ok(channels)
149}
150
151/// Generate Python test application from `AsyncAPI` spec
152pub fn generate_python_test_app(spec: &AsyncApiV3Spec, protocol: Protocol) -> Result<String> {
153    let channels = extract_channel_info(spec)?;
154    let protocol_str = match protocol {
155        Protocol::WebSocket => "websocket",
156        Protocol::Sse => "sse",
157        other => bail!("Unsupported protocol for Python test app: {other:?}"),
158    };
159
160    let generator = PythonAsyncApiGenerator;
161    generator.generate_test_app(&channels, protocol_str)
162}
163
164/// Generate Node.js test application from `AsyncAPI` spec
165pub fn generate_nodejs_test_app(spec: &AsyncApiV3Spec, protocol: Protocol) -> Result<String> {
166    let channels = extract_channel_info(spec)?;
167    let protocol_str = match protocol {
168        Protocol::WebSocket => "websocket",
169        Protocol::Sse => "sse",
170        other => bail!("Unsupported protocol for TypeScript test app: {other:?}"),
171    };
172
173    let generator = TypeScriptAsyncApiGenerator;
174    generator.generate_test_app(&channels, protocol_str)
175}
176
177/// Generate Ruby test application from `AsyncAPI` spec
178pub fn generate_ruby_test_app(spec: &AsyncApiV3Spec, protocol: Protocol) -> Result<String> {
179    let channels = extract_channel_info(spec)?;
180    let protocol_str = match protocol {
181        Protocol::WebSocket => "websocket",
182        Protocol::Sse => "sse",
183        other => bail!("Unsupported protocol for Ruby test app: {other:?}"),
184    };
185
186    let generator = RubyAsyncApiGenerator;
187    generator.generate_test_app(&channels, protocol_str)
188}
189
190/// Generate PHP test application from `AsyncAPI` spec
191pub fn generate_php_test_app(spec: &AsyncApiV3Spec, protocol: Protocol) -> Result<String> {
192    let channels = extract_channel_info(spec)?;
193    let protocol_str = match protocol {
194        Protocol::WebSocket => "websocket",
195        Protocol::Sse => "sse",
196        other => bail!("Unsupported protocol for PHP test app: {other:?}"),
197    };
198
199    let generator = PhpAsyncApiGenerator;
200    generator.generate_test_app(&channels, protocol_str)
201}
202
203/// Generate Rust test application from `AsyncAPI` spec
204pub fn generate_rust_test_app(spec: &AsyncApiV3Spec, protocol: Protocol) -> Result<String> {
205    let channels = extract_channel_info(spec)?;
206    let protocol_str = match protocol {
207        Protocol::WebSocket => "websocket",
208        Protocol::Sse => "sse",
209        other => bail!("Unsupported protocol for Rust test app: {other:?}"),
210    };
211
212    let generator = RustAsyncApiGenerator;
213    generator.generate_test_app(&channels, protocol_str)
214}
215
216/// Generate Elixir test application from `AsyncAPI` spec
217pub fn generate_elixir_test_app(spec: &AsyncApiV3Spec, protocol: Protocol) -> Result<String> {
218    let channels = extract_channel_info(spec)?;
219    let protocol_str = match protocol {
220        Protocol::WebSocket => "websocket",
221        Protocol::Sse => "sse",
222        other => bail!("Unsupported protocol for Elixir test app: {other:?}"),
223    };
224
225    let generator = ElixirAsyncApiGenerator;
226    generator.generate_test_app(&channels, protocol_str)
227}
228
229/// Generate Python handler scaffolding from `AsyncAPI` spec
230pub fn generate_python_handler_app(spec: &AsyncApiV3Spec, protocol: Protocol) -> Result<String> {
231    let channels = extract_channel_info(spec)?;
232    let protocol_str = match protocol {
233        Protocol::WebSocket => "websocket",
234        Protocol::Sse => "sse",
235        other => bail!("Unsupported protocol for Python handler generation: {other:?}"),
236    };
237
238    let generator = PythonAsyncApiGenerator;
239    generator.generate_handler_app(&channels, protocol_str)
240}
241
242/// Generate Node.js handler scaffolding from `AsyncAPI` spec
243pub fn generate_nodejs_handler_app(spec: &AsyncApiV3Spec, protocol: Protocol) -> Result<String> {
244    let channels = extract_channel_info(spec)?;
245    let protocol_str = match protocol {
246        Protocol::WebSocket => "websocket",
247        Protocol::Sse => "sse",
248        other => bail!("Unsupported protocol for TypeScript handler generation: {other:?}"),
249    };
250
251    let generator = TypeScriptAsyncApiGenerator;
252    generator.generate_handler_app(&channels, protocol_str)
253}
254
255/// Generate Ruby handler scaffolding from `AsyncAPI` spec
256pub fn generate_ruby_handler_app(spec: &AsyncApiV3Spec, protocol: Protocol) -> Result<String> {
257    let channels = extract_channel_info(spec)?;
258    let protocol_str = match protocol {
259        Protocol::WebSocket => "websocket",
260        Protocol::Sse => "sse",
261        other => bail!("Unsupported protocol for Ruby handler generation: {other:?}"),
262    };
263
264    let generator = RubyAsyncApiGenerator;
265    generator.generate_handler_app(&channels, protocol_str)
266}
267
268/// Generate Rust handler scaffolding from `AsyncAPI` spec
269pub fn generate_rust_handler_app(spec: &AsyncApiV3Spec, protocol: Protocol) -> Result<String> {
270    let channels = extract_channel_info(spec)?;
271    let protocol_str = match protocol {
272        Protocol::WebSocket => "websocket",
273        Protocol::Sse => "sse",
274        other => bail!("Unsupported protocol for Rust handler generation: {other:?}"),
275    };
276
277    let generator = RustAsyncApiGenerator;
278    generator.generate_handler_app(&channels, protocol_str)
279}
280
281/// Generate PHP handler scaffolding from `AsyncAPI` spec
282pub fn generate_php_handler_app(spec: &AsyncApiV3Spec, protocol: Protocol) -> Result<String> {
283    let channels = extract_channel_info(spec)?;
284    let protocol_str = match protocol {
285        Protocol::WebSocket => "websocket",
286        Protocol::Sse => "sse",
287        other => bail!("Unsupported protocol for PHP handler generation: {other:?}"),
288    };
289
290    let generator = PhpAsyncApiGenerator;
291    generator.generate_handler_app(&channels, protocol_str)
292}
293
294/// Generate Elixir handler scaffolding from `AsyncAPI` spec
295pub fn generate_elixir_handler_app(spec: &AsyncApiV3Spec, protocol: Protocol) -> Result<String> {
296    let channels = extract_channel_info(spec)?;
297    let protocol_str = match protocol {
298        Protocol::WebSocket => "websocket",
299        Protocol::Sse => "sse",
300        other => bail!("Unsupported protocol for Elixir handler generation: {other:?}"),
301    };
302
303    let generator = ElixirAsyncApiGenerator;
304    generator.generate_handler_app(&channels, protocol_str)
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use asyncapiv3::spec::AsyncApiSpec;
311
312    #[test]
313    fn test_protocol_detection() {
314        assert_eq!(Protocol::from_protocol_string("ws"), Protocol::WebSocket);
315        assert_eq!(Protocol::from_protocol_string("wss"), Protocol::WebSocket);
316        assert_eq!(Protocol::from_protocol_string("websocket"), Protocol::WebSocket);
317        assert_eq!(Protocol::from_protocol_string("sse"), Protocol::Sse);
318        assert_eq!(Protocol::from_protocol_string("server-sent-events"), Protocol::Sse);
319        assert_eq!(Protocol::from_protocol_string("http"), Protocol::Http);
320        assert_eq!(Protocol::from_protocol_string("https"), Protocol::Http);
321        assert_eq!(Protocol::from_protocol_string("kafka"), Protocol::Kafka);
322        assert_eq!(Protocol::from_protocol_string("unknown"), Protocol::Other);
323    }
324
325    #[test]
326    fn test_extract_channel_info_includes_message_definitions() {
327        let spec_value = serde_json::json!({
328            "asyncapi": "3.0.0",
329            "info": { "title": "Chat API", "version": "1.0.0" },
330            "servers": {
331                "primary": { "host": "ws.example.com", "protocol": "ws" }
332            },
333            "channels": {
334                "chat": {
335                    "address": "/chat",
336                    "messages": {
337                        "chatEvent": {
338                            "payload": {
339                                "type": "object",
340                                "properties": {
341                                    "type": { "const": "chatEvent", "type": "string" },
342                                    "body": { "type": "string" }
343                                },
344                                "required": ["type", "body"]
345                            }
346                        }
347                    }
348                }
349            }
350        });
351
352        let spec = match serde_json::from_value::<AsyncApiSpec>(spec_value).expect("valid asyncapi spec") {
353            AsyncApiSpec::V3_0_0(v3) => v3,
354        };
355
356        let channels = extract_channel_info(&spec).expect("channel extraction should succeed");
357        assert_eq!(channels.len(), 1);
358        assert_eq!(channels[0].messages, vec!["chatEvent"]);
359        assert_eq!(channels[0].message_definitions.len(), 1);
360        assert_eq!(channels[0].message_definitions[0].name, "chatEvent");
361        assert!(channels[0].message_definitions[0].schema.is_some());
362    }
363}