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                            examples: definition
125                                .map(|definition| definition.examples.clone())
126                                .unwrap_or_default(),
127                        }
128                    })
129                    .collect();
130                let raw_path = channel.address.clone().unwrap_or_else(|| channel_path.clone());
131                let normalized_path = if raw_path.starts_with('/') {
132                    raw_path.clone()
133                } else {
134                    format!("/{raw_path}")
135                };
136                let _operations = operation_map.get(&normalized_path).cloned().unwrap_or_default();
137
138                channels.push(ChannelInfo {
139                    name: channel_path.trim_start_matches('/').replace('/', "_"),
140                    path: normalized_path,
141                    messages,
142                    message_definitions,
143                });
144            }
145            Either::Left(_reference) => {
146                tracing::debug!("Skipping channel reference: {}", channel_path);
147            }
148        }
149    }
150
151    Ok(channels)
152}
153
154/// Generate Python test application from `AsyncAPI` spec
155pub fn generate_python_test_app(spec: &AsyncApiV3Spec, protocol: Protocol) -> Result<String> {
156    let channels = extract_channel_info(spec)?;
157    let protocol_str = match protocol {
158        Protocol::WebSocket => "websocket",
159        Protocol::Sse => "sse",
160        other => bail!("Unsupported protocol for Python test app: {other:?}"),
161    };
162
163    let generator = PythonAsyncApiGenerator;
164    generator.generate_test_app(&channels, protocol_str)
165}
166
167/// Generate Node.js test application from `AsyncAPI` spec
168pub fn generate_nodejs_test_app(spec: &AsyncApiV3Spec, protocol: Protocol) -> Result<String> {
169    let channels = extract_channel_info(spec)?;
170    let protocol_str = match protocol {
171        Protocol::WebSocket => "websocket",
172        Protocol::Sse => "sse",
173        other => bail!("Unsupported protocol for TypeScript test app: {other:?}"),
174    };
175
176    let generator = TypeScriptAsyncApiGenerator;
177    generator.generate_test_app(&channels, protocol_str)
178}
179
180/// Generate Ruby test application from `AsyncAPI` spec
181pub fn generate_ruby_test_app(spec: &AsyncApiV3Spec, protocol: Protocol) -> Result<String> {
182    let channels = extract_channel_info(spec)?;
183    let protocol_str = match protocol {
184        Protocol::WebSocket => "websocket",
185        Protocol::Sse => "sse",
186        other => bail!("Unsupported protocol for Ruby test app: {other:?}"),
187    };
188
189    let generator = RubyAsyncApiGenerator;
190    generator.generate_test_app(&channels, protocol_str)
191}
192
193/// Generate PHP test application from `AsyncAPI` spec
194pub fn generate_php_test_app(spec: &AsyncApiV3Spec, protocol: Protocol) -> Result<String> {
195    let channels = extract_channel_info(spec)?;
196    let protocol_str = match protocol {
197        Protocol::WebSocket => "websocket",
198        Protocol::Sse => "sse",
199        other => bail!("Unsupported protocol for PHP test app: {other:?}"),
200    };
201
202    let generator = PhpAsyncApiGenerator;
203    generator.generate_test_app(&channels, protocol_str)
204}
205
206/// Generate Rust test application from `AsyncAPI` spec
207pub fn generate_rust_test_app(spec: &AsyncApiV3Spec, protocol: Protocol) -> Result<String> {
208    let channels = extract_channel_info(spec)?;
209    let protocol_str = match protocol {
210        Protocol::WebSocket => "websocket",
211        Protocol::Sse => "sse",
212        other => bail!("Unsupported protocol for Rust test app: {other:?}"),
213    };
214
215    let generator = RustAsyncApiGenerator;
216    generator.generate_test_app(&channels, protocol_str)
217}
218
219/// Generate Elixir test application from `AsyncAPI` spec
220pub fn generate_elixir_test_app(spec: &AsyncApiV3Spec, protocol: Protocol) -> Result<String> {
221    let channels = extract_channel_info(spec)?;
222    let protocol_str = match protocol {
223        Protocol::WebSocket => "websocket",
224        Protocol::Sse => "sse",
225        other => bail!("Unsupported protocol for Elixir test app: {other:?}"),
226    };
227
228    let generator = ElixirAsyncApiGenerator;
229    generator.generate_test_app(&channels, protocol_str)
230}
231
232/// Generate Python handler scaffolding from `AsyncAPI` spec
233pub fn generate_python_handler_app(spec: &AsyncApiV3Spec, protocol: Protocol) -> Result<String> {
234    let channels = extract_channel_info(spec)?;
235    let protocol_str = match protocol {
236        Protocol::WebSocket => "websocket",
237        Protocol::Sse => "sse",
238        other => bail!("Unsupported protocol for Python handler generation: {other:?}"),
239    };
240
241    let generator = PythonAsyncApiGenerator;
242    generator.generate_handler_app(&channels, protocol_str)
243}
244
245/// Generate Node.js handler scaffolding from `AsyncAPI` spec
246pub fn generate_nodejs_handler_app(spec: &AsyncApiV3Spec, protocol: Protocol) -> Result<String> {
247    let channels = extract_channel_info(spec)?;
248    let protocol_str = match protocol {
249        Protocol::WebSocket => "websocket",
250        Protocol::Sse => "sse",
251        other => bail!("Unsupported protocol for TypeScript handler generation: {other:?}"),
252    };
253
254    let generator = TypeScriptAsyncApiGenerator;
255    generator.generate_handler_app(&channels, protocol_str)
256}
257
258/// Generate Ruby handler scaffolding from `AsyncAPI` spec
259pub fn generate_ruby_handler_app(spec: &AsyncApiV3Spec, protocol: Protocol) -> Result<String> {
260    let channels = extract_channel_info(spec)?;
261    let protocol_str = match protocol {
262        Protocol::WebSocket => "websocket",
263        Protocol::Sse => "sse",
264        other => bail!("Unsupported protocol for Ruby handler generation: {other:?}"),
265    };
266
267    let generator = RubyAsyncApiGenerator;
268    generator.generate_handler_app(&channels, protocol_str)
269}
270
271/// Generate Rust handler scaffolding from `AsyncAPI` spec
272pub fn generate_rust_handler_app(spec: &AsyncApiV3Spec, protocol: Protocol) -> Result<String> {
273    let channels = extract_channel_info(spec)?;
274    let protocol_str = match protocol {
275        Protocol::WebSocket => "websocket",
276        Protocol::Sse => "sse",
277        other => bail!("Unsupported protocol for Rust handler generation: {other:?}"),
278    };
279
280    let generator = RustAsyncApiGenerator;
281    generator.generate_handler_app(&channels, protocol_str)
282}
283
284/// Generate PHP handler scaffolding from `AsyncAPI` spec
285pub fn generate_php_handler_app(spec: &AsyncApiV3Spec, protocol: Protocol) -> Result<String> {
286    let channels = extract_channel_info(spec)?;
287    let protocol_str = match protocol {
288        Protocol::WebSocket => "websocket",
289        Protocol::Sse => "sse",
290        other => bail!("Unsupported protocol for PHP handler generation: {other:?}"),
291    };
292
293    let generator = PhpAsyncApiGenerator;
294    generator.generate_handler_app(&channels, protocol_str)
295}
296
297/// Generate Elixir handler scaffolding from `AsyncAPI` spec
298pub fn generate_elixir_handler_app(spec: &AsyncApiV3Spec, protocol: Protocol) -> Result<String> {
299    let channels = extract_channel_info(spec)?;
300    let protocol_str = match protocol {
301        Protocol::WebSocket => "websocket",
302        Protocol::Sse => "sse",
303        other => bail!("Unsupported protocol for Elixir handler generation: {other:?}"),
304    };
305
306    let generator = ElixirAsyncApiGenerator;
307    generator.generate_handler_app(&channels, protocol_str)
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use asyncapiv3::spec::AsyncApiSpec;
314
315    #[test]
316    fn test_protocol_detection() {
317        assert_eq!(Protocol::from_protocol_string("ws"), Protocol::WebSocket);
318        assert_eq!(Protocol::from_protocol_string("wss"), Protocol::WebSocket);
319        assert_eq!(Protocol::from_protocol_string("websocket"), Protocol::WebSocket);
320        assert_eq!(Protocol::from_protocol_string("sse"), Protocol::Sse);
321        assert_eq!(Protocol::from_protocol_string("server-sent-events"), Protocol::Sse);
322        assert_eq!(Protocol::from_protocol_string("http"), Protocol::Http);
323        assert_eq!(Protocol::from_protocol_string("https"), Protocol::Http);
324        assert_eq!(Protocol::from_protocol_string("kafka"), Protocol::Kafka);
325        assert_eq!(Protocol::from_protocol_string("unknown"), Protocol::Other);
326    }
327
328    #[test]
329    fn test_extract_channel_info_includes_message_definitions() {
330        let spec_value = serde_json::json!({
331            "asyncapi": "3.0.0",
332            "info": { "title": "Chat API", "version": "1.0.0" },
333            "servers": {
334                "primary": { "host": "ws.example.com", "protocol": "ws" }
335            },
336            "channels": {
337                "chat": {
338                    "address": "/chat",
339                    "messages": {
340                        "chatEvent": {
341                            "payload": {
342                                "type": "object",
343                                "properties": {
344                                    "type": { "const": "chatEvent", "type": "string" },
345                                    "body": { "type": "string" }
346                                },
347                                "required": ["type", "body"]
348                            }
349                        }
350                    }
351                }
352            }
353        });
354
355        let spec = match serde_json::from_value::<AsyncApiSpec>(spec_value).expect("valid asyncapi spec") {
356            AsyncApiSpec::V3_0_0(v3) => v3,
357        };
358
359        let channels = extract_channel_info(&spec).expect("channel extraction should succeed");
360        assert_eq!(channels.len(), 1);
361        assert_eq!(channels[0].messages, vec!["chatEvent"]);
362        assert_eq!(channels[0].message_definitions.len(), 1);
363        assert_eq!(channels[0].message_definitions[0].name, "chatEvent");
364        assert!(channels[0].message_definitions[0].schema.is_some());
365    }
366}