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 }
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
151pub 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
164pub 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
177pub 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
190pub 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
203pub 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
216pub 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
229pub 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
242pub 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
255pub 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
268pub 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
281pub 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
294pub 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}