spikard_cli/codegen/asyncapi/
mod.rs1pub 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
27pub 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
91pub 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
154pub 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
167pub 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
180pub 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
193pub 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
206pub 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
219pub 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
232pub 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
245pub 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
258pub 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
271pub 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
284pub 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
297pub 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}