1use super::asyncapi::{Protocol, parse_asyncapi_schema};
2use super::asyncapi::{
3 generate_elixir_handler_app, generate_elixir_test_app, generate_nodejs_handler_app, generate_nodejs_test_app,
4 generate_php_handler_app, generate_php_test_app, generate_python_handler_app, generate_python_test_app,
5 generate_ruby_handler_app, generate_ruby_test_app, generate_rust_handler_app, generate_rust_test_app,
6};
7use super::graphql::generators::GraphQLGenerator;
8use super::graphql::generators::elixir::ElixirGenerator;
9use super::graphql::generators::php::PhpGenerator;
10use super::graphql::generators::python::PythonGenerator;
11use super::graphql::generators::ruby::RubyGenerator;
12use super::graphql::generators::typescript::TypeScriptGenerator;
13use super::graphql::{RustGenerator, parse_graphql_schema};
14use super::openrpc::{
15 generate_elixir_handler_app as generate_openrpc_elixir_handler,
16 generate_php_handler_app as generate_openrpc_php_handler,
17 generate_python_handler_app as generate_openrpc_python_handler,
18 generate_ruby_handler_app as generate_openrpc_ruby_handler,
19 generate_rust_handler_app as generate_openrpc_rust_handler,
20 generate_typescript_handler_app as generate_openrpc_typescript_handler, parse_openrpc_schema,
21};
22use super::quality::QualityValidator;
23use super::sql::{SqlCodegenConfig, generate_from_sql_dir};
24use super::{DtoConfig, TargetLanguage, detect_primary_protocol, generate_fixtures};
25use crate::codegen::generate_from_openapi;
26use anyhow::{Context, Result, bail};
27use asyncapiv3::spec::AsyncApiV3Spec;
28use heck::ToKebabCase;
29use scythe_core::dialect::SqlDialect;
30use spikard_codegen::sql::DecimalMode;
31use std::fs;
32use std::path::{Path, PathBuf};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum SchemaKind {
37 OpenApi,
38 AsyncApi,
39 OpenRpc,
40 GraphQL,
41 Protobuf,
42 Sql,
44}
45
46#[derive(Clone)]
48pub enum CodegenTargetKind {
49 Server {
51 language: TargetLanguage,
52 output: Option<PathBuf>,
53 },
54 AsyncFixtures { output: PathBuf },
56 AsyncTestApp { language: TargetLanguage, output: PathBuf },
58 AsyncHandlers { language: TargetLanguage, output: PathBuf },
60 AsyncAll { output: PathBuf },
62 JsonRpcHandlers { language: TargetLanguage, output: PathBuf },
64 GraphQL {
66 language: TargetLanguage,
67 output: PathBuf,
68 target: String,
69 },
70 Protobuf {
72 language: TargetLanguage,
73 output: PathBuf,
74 target: String,
75 include_paths: Vec<PathBuf>,
76 },
77 SqlHandlers {
79 schema_paths: Vec<PathBuf>,
80 output: PathBuf,
81 dialect: SqlDialect,
82 languages: Vec<TargetLanguage>,
83 decimal_mode: DecimalMode,
84 strict: bool,
85 emit_openapi: bool,
86 api_title: String,
87 api_version: String,
88 },
89}
90
91#[derive(Debug, Clone)]
93pub struct CodegenRequest {
94 pub schema_path: PathBuf,
95 pub schema_kind: SchemaKind,
96 pub target: CodegenTargetKind,
97 pub dto: Option<DtoConfig>,
98}
99
100#[derive(Debug, Clone, serde::Serialize)]
102#[cfg_attr(
103 feature = "mcp",
104 derive(rmcp::schemars::JsonSchema),
105 schemars(crate = "rmcp::schemars")
106)]
107pub struct GeneratedAsset {
108 pub path: PathBuf,
109 pub description: String,
110}
111
112#[derive(Debug, Clone, serde::Serialize)]
114#[cfg_attr(
115 feature = "mcp",
116 derive(rmcp::schemars::JsonSchema),
117 schemars(crate = "rmcp::schemars")
118)]
119pub enum CodegenOutcome {
120 InMemory(String),
122 Files(Vec<GeneratedAsset>),
124}
125
126pub struct CodegenEngine;
128
129impl CodegenEngine {
130 pub fn execute(request: CodegenRequest) -> Result<CodegenOutcome> {
131 Self::execute_impl(request, false)
132 }
133
134 pub fn execute_validated(request: CodegenRequest) -> Result<CodegenOutcome> {
135 Self::execute_impl(request, true)
136 }
137
138 fn execute_impl(request: CodegenRequest, validate: bool) -> Result<CodegenOutcome> {
139 match (&request.schema_kind, &request.target) {
140 (SchemaKind::OpenApi, CodegenTargetKind::Server { language, output }) => {
141 let dto = request.dto.clone().unwrap_or_default();
142 let code = generate_from_openapi(&request.schema_path, *language, &dto)?;
143 if validate {
144 Self::validate_generated_code(*language, &code)?;
145 }
146
147 if let Some(path) = output {
148 Ok(CodegenOutcome::Files(vec![Self::write_asset(
149 path,
150 format!("{} server handlers", language_name(*language)),
151 &code,
152 )?]))
153 } else {
154 Ok(CodegenOutcome::InMemory(code))
155 }
156 }
157 (SchemaKind::AsyncApi, CodegenTargetKind::AsyncFixtures { output }) => {
158 let spec = parse_asyncapi_schema(&request.schema_path)
159 .context("Failed to parse AsyncAPI schema for fixture generation")?;
160 let protocol = detect_primary_protocol(&spec)?;
161 let paths = Self::generate_asyncapi_fixtures(&spec, protocol, output)?;
162 Ok(CodegenOutcome::Files(paths))
163 }
164 (SchemaKind::AsyncApi, CodegenTargetKind::AsyncTestApp { language, output }) => {
165 let spec = parse_asyncapi_schema(&request.schema_path)
166 .context("Failed to parse AsyncAPI schema for test app generation")?;
167 let protocol = detect_primary_protocol(&spec)?;
168 let asset = Self::generate_asyncapi_app(&spec, protocol, *language, output, validate)?;
169 Ok(CodegenOutcome::Files(vec![asset]))
170 }
171 (SchemaKind::AsyncApi, CodegenTargetKind::AsyncHandlers { language, output }) => {
172 let spec = parse_asyncapi_schema(&request.schema_path)
173 .context("Failed to parse AsyncAPI schema for handler generation")?;
174 let protocol = detect_primary_protocol(&spec)?;
175 let asset = Self::generate_asyncapi_handler(&spec, protocol, *language, output, validate)?;
176 Ok(CodegenOutcome::Files(vec![asset]))
177 }
178 (SchemaKind::AsyncApi, CodegenTargetKind::AsyncAll { output }) => {
179 let spec = parse_asyncapi_schema(&request.schema_path)
180 .context("Failed to parse AsyncAPI schema for all-assets generation")?;
181 let protocol = detect_primary_protocol(&spec)?;
182 let assets = Self::generate_asyncapi_bundle(&spec, protocol, output, validate)?;
183 Ok(CodegenOutcome::Files(assets))
184 }
185 (SchemaKind::OpenRpc, CodegenTargetKind::JsonRpcHandlers { language, output }) => {
186 let spec = parse_openrpc_schema(&request.schema_path)
187 .context("Failed to parse OpenRPC schema for handler generation")?;
188 let asset = Self::generate_openrpc_handler(&spec, *language, output, validate)?;
189 Ok(CodegenOutcome::Files(vec![asset]))
190 }
191 (
192 SchemaKind::GraphQL,
193 CodegenTargetKind::GraphQL {
194 language,
195 output,
196 target,
197 },
198 ) => {
199 let assets = Self::generate_graphql_code(&request.schema_path, *language, output, target, validate)
200 .context("Failed to generate code from GraphQL schema")?;
201 Ok(CodegenOutcome::Files(assets))
202 }
203 (
204 SchemaKind::Protobuf,
205 CodegenTargetKind::Protobuf {
206 language,
207 output,
208 target,
209 include_paths,
210 },
211 ) => {
212 let schema = super::protobuf::parse_proto_schema_with_includes(&request.schema_path, include_paths)?;
213
214 let proto_target = match target.as_str() {
215 "all" => super::protobuf::generators::ProtobufTarget::All,
216 "messages" => super::protobuf::generators::ProtobufTarget::Messages,
217 "services" => super::protobuf::generators::ProtobufTarget::Services,
218 _ => bail!("Invalid protobuf target: {target}. Use 'all', 'messages', or 'services'"),
219 };
220
221 let code = match language {
222 TargetLanguage::Python => super::protobuf::generate_python_protobuf(&schema, &proto_target)?,
223 TargetLanguage::TypeScript => {
224 super::protobuf::generate_typescript_protobuf(&schema, &proto_target)?
225 }
226 TargetLanguage::Ruby => super::protobuf::generate_ruby_protobuf(&schema, &proto_target)?,
227 TargetLanguage::Php => super::protobuf::generate_php_protobuf(&schema, &proto_target)?,
228 TargetLanguage::Rust => super::protobuf::generate_rust_protobuf(&schema, &proto_target)?,
229 TargetLanguage::Elixir => super::protobuf::generate_elixir_protobuf(&schema, &proto_target)?,
230 };
231 if validate {
232 Self::validate_generated_code(*language, &code)?;
233 }
234
235 Ok(CodegenOutcome::Files(vec![Self::write_asset(
236 output,
237 format!("{} Protobuf code", language_name(*language)),
238 &code,
239 )?]))
240 }
241 (
242 SchemaKind::Sql,
243 CodegenTargetKind::SqlHandlers {
244 schema_paths,
245 output,
246 dialect,
247 languages,
248 decimal_mode,
249 strict,
250 emit_openapi,
251 api_title,
252 api_version,
253 },
254 ) => {
255 let config = SqlCodegenConfig {
256 schema_paths: schema_paths.clone(),
257 queries_dir: request.schema_path.clone(),
258 output_dir: output.clone(),
259 dialect: *dialect,
260 languages: languages.clone(),
261 decimal_mode: *decimal_mode,
262 strict: *strict,
263 emit_openapi: *emit_openapi,
264 api_title: api_title.clone(),
265 api_version: api_version.clone(),
266 };
267 let output = generate_from_sql_dir(config).context("Failed to generate handlers from annotated SQL")?;
268 Ok(CodegenOutcome::Files(output.assets))
269 }
270 _ => bail!(
271 "Unsupported schema/target combination: {:?} -> {:?}",
272 request.schema_kind,
273 request.target
274 ),
275 }
276 }
277
278 fn generate_asyncapi_fixtures(
279 spec: &AsyncApiV3Spec,
280 protocol: Protocol,
281 output: &Path,
282 ) -> Result<Vec<GeneratedAsset>> {
283 let fixture_paths = generate_fixtures(spec, output, protocol)?;
284
285 Ok(fixture_paths
286 .into_iter()
287 .map(|path| GeneratedAsset {
288 description: format!("{} fixture", protocol.as_str()),
289 path,
290 })
291 .collect())
292 }
293
294 fn generate_asyncapi_app(
295 spec: &AsyncApiV3Spec,
296 protocol: Protocol,
297 language: TargetLanguage,
298 output: &Path,
299 validate: bool,
300 ) -> Result<GeneratedAsset> {
301 let code = match language {
302 TargetLanguage::Python => generate_python_test_app(spec, protocol)?,
303 TargetLanguage::TypeScript => generate_nodejs_test_app(spec, protocol)?,
304 TargetLanguage::Rust => generate_rust_test_app(spec, protocol)?,
305 TargetLanguage::Ruby => generate_ruby_test_app(spec, protocol)?,
306 TargetLanguage::Php => generate_php_test_app(spec, protocol)?,
307 TargetLanguage::Elixir => generate_elixir_test_app(spec, protocol)?,
308 };
309 if validate {
310 Self::validate_generated_code(language, &code)?;
311 }
312
313 Self::write_asset(output, format!("{} AsyncAPI test app", language_name(language)), code)
314 }
315
316 fn generate_asyncapi_handler(
317 spec: &AsyncApiV3Spec,
318 protocol: Protocol,
319 language: TargetLanguage,
320 output: &Path,
321 validate: bool,
322 ) -> Result<GeneratedAsset> {
323 let code = match language {
324 TargetLanguage::Python => generate_python_handler_app(spec, protocol)?,
325 TargetLanguage::TypeScript => generate_nodejs_handler_app(spec, protocol)?,
326 TargetLanguage::Ruby => generate_ruby_handler_app(spec, protocol)?,
327 TargetLanguage::Rust => generate_rust_handler_app(spec, protocol)?,
328 TargetLanguage::Php => generate_php_handler_app(spec, protocol)?,
329 TargetLanguage::Elixir => generate_elixir_handler_app(spec, protocol)?,
330 };
331 if validate {
332 Self::validate_generated_code(language, &code)?;
333 }
334
335 Self::write_asset(output, format!("{} AsyncAPI handler", language_name(language)), code)
336 }
337
338 fn generate_asyncapi_bundle(
339 spec: &AsyncApiV3Spec,
340 protocol: Protocol,
341 output: &Path,
342 validate: bool,
343 ) -> Result<Vec<GeneratedAsset>> {
344 let mut assets = Vec::new();
345
346 let fixtures_dir = output.join("testing_data");
347 assets.extend(Self::generate_asyncapi_fixtures(spec, protocol, &fixtures_dir)?);
348
349 let app_dir = output.join("apps");
350 fs::create_dir_all(&app_dir).with_context(|| format!("Failed to create {}", app_dir.display()))?;
351 let base_name = spec.info.title.to_kebab_case();
352
353 let python_asset = Self::generate_asyncapi_app(
354 spec,
355 protocol,
356 TargetLanguage::Python,
357 &app_dir.join(format!("{base_name}-asyncapi.py")),
358 validate,
359 )?;
360 assets.push(python_asset);
361
362 let node_asset = Self::generate_asyncapi_app(
363 spec,
364 protocol,
365 TargetLanguage::TypeScript,
366 &app_dir.join(format!("{base_name}-asyncapi.ts")),
367 validate,
368 )?;
369 assets.push(node_asset);
370
371 let rust_asset = Self::generate_asyncapi_app(
372 spec,
373 protocol,
374 TargetLanguage::Rust,
375 &app_dir.join(format!("{base_name}-asyncapi.rs")),
376 validate,
377 )?;
378 assets.push(rust_asset);
379
380 let ruby_asset = Self::generate_asyncapi_app(
381 spec,
382 protocol,
383 TargetLanguage::Ruby,
384 &app_dir.join(format!("{base_name}-asyncapi.rb")),
385 validate,
386 )?;
387 assets.push(ruby_asset);
388
389 let php_asset = Self::generate_asyncapi_app(
390 spec,
391 protocol,
392 TargetLanguage::Php,
393 &app_dir.join(format!("{base_name}-asyncapi.php")),
394 validate,
395 )?;
396 assets.push(php_asset);
397
398 let elixir_asset = Self::generate_asyncapi_app(
399 spec,
400 protocol,
401 TargetLanguage::Elixir,
402 &app_dir.join(format!("{base_name}-asyncapi.ex")),
403 validate,
404 )?;
405 assets.push(elixir_asset);
406
407 Ok(assets)
408 }
409
410 fn generate_openrpc_handler(
411 spec: &super::openrpc::spec_parser::OpenRpcSpec,
412 language: TargetLanguage,
413 output: &Path,
414 validate: bool,
415 ) -> Result<GeneratedAsset> {
416 let code = match language {
417 TargetLanguage::Python => generate_openrpc_python_handler(spec)?,
418 TargetLanguage::TypeScript => generate_openrpc_typescript_handler(spec)?,
419 TargetLanguage::Rust => generate_openrpc_rust_handler(spec)?,
420 TargetLanguage::Ruby => generate_openrpc_ruby_handler(spec)?,
421 TargetLanguage::Php => generate_openrpc_php_handler(spec)?,
422 TargetLanguage::Elixir => generate_openrpc_elixir_handler(spec)?,
423 };
424 if validate {
425 Self::validate_generated_code(language, &code)?;
426 }
427
428 Self::write_asset(output, format!("{} JSON-RPC handlers", language_name(language)), code)
429 }
430
431 fn generate_graphql_code(
432 schema_path: &Path,
433 language: TargetLanguage,
434 output: &Path,
435 target: &str,
436 validate: bool,
437 ) -> Result<Vec<GeneratedAsset>> {
438 let parsed_schema =
439 parse_graphql_schema(schema_path).with_context(|| format!("Failed to parse {}", schema_path.display()))?;
440
441 let code = match language {
442 TargetLanguage::Python => {
443 let generator = PythonGenerator;
444 match target {
445 "types" => generator.generate_types(&parsed_schema)?,
446 "resolvers" => generator.generate_resolvers(&parsed_schema)?,
447 "schema" => generator.generate_schema_definition(&parsed_schema)?,
448 "all" => generator.generate_complete(&parsed_schema)?,
449 _ => generator.generate_complete(&parsed_schema)?,
450 }
451 }
452 TargetLanguage::TypeScript => {
453 let generator = TypeScriptGenerator;
454 match target {
455 "types" => generator.generate_types(&parsed_schema)?,
456 "resolvers" => generator.generate_resolvers(&parsed_schema)?,
457 "schema" => generator.generate_schema_definition(&parsed_schema)?,
458 "all" => generator.generate_complete(&parsed_schema)?,
459 _ => generator.generate_complete(&parsed_schema)?,
460 }
461 }
462 TargetLanguage::Rust => {
463 let generator = RustGenerator::new();
464 match target {
465 "types" => generator.generate_types(&parsed_schema)?,
466 "resolvers" => generator.generate_resolvers(&parsed_schema)?,
467 "schema" => generator.generate_schema_definition(&parsed_schema)?,
468 "all" => generator.generate_complete(&parsed_schema)?,
469 _ => generator.generate_complete(&parsed_schema)?,
470 }
471 }
472 TargetLanguage::Ruby => {
473 let generator = RubyGenerator;
474 match target {
475 "types" => generator.generate_types(&parsed_schema)?,
476 "resolvers" => generator.generate_resolvers(&parsed_schema)?,
477 "schema" => generator.generate_schema_definition(&parsed_schema)?,
478 "rbs" => generator.generate_type_signatures(&parsed_schema)?,
479 "all" => generator.generate_complete(&parsed_schema)?,
480 _ => generator.generate_complete(&parsed_schema)?,
481 }
482 }
483 TargetLanguage::Php => {
484 let generator = PhpGenerator;
485 match target {
486 "types" => generator.generate_types(&parsed_schema)?,
487 "resolvers" => generator.generate_resolvers(&parsed_schema)?,
488 "schema" => generator.generate_schema_definition(&parsed_schema)?,
489 "all" => generator.generate_complete(&parsed_schema)?,
490 _ => generator.generate_complete(&parsed_schema)?,
491 }
492 }
493 TargetLanguage::Elixir => {
494 let generator = ElixirGenerator;
495 match target {
496 "types" => generator.generate_types(&parsed_schema)?,
497 "resolvers" => generator.generate_resolvers(&parsed_schema)?,
498 "schema" => generator.generate_schema_definition(&parsed_schema)?,
499 "all" => generator.generate_complete(&parsed_schema)?,
500 _ => generator.generate_complete(&parsed_schema)?,
501 }
502 }
503 };
504 if validate {
505 Self::validate_generated_code(language, &code)?;
506 }
507
508 let mut assets = vec![Self::write_asset(
509 output,
510 format!("{} GraphQL code", language_name(language)),
511 &code,
512 )?];
513
514 if language == TargetLanguage::Ruby && (target == "all" || target == "types" || target == "schema") {
515 let generator = RubyGenerator;
516 let rbs_code = generator.generate_type_signatures(&parsed_schema)?;
517
518 let rbs_output = output.with_extension("rbs");
519
520 assets.push(Self::write_asset(
521 &rbs_output,
522 format!("{} GraphQL RBS types", language_name(language)),
523 &rbs_code,
524 )?);
525 }
526
527 Ok(assets)
528 }
529
530 fn validate_generated_code(language: TargetLanguage, code: &str) -> Result<()> {
531 let report = QualityValidator::new(language)
532 .validate_all(code)
533 .map_err(|err| anyhow::anyhow!("Failed to run quality validation: {err}"))?;
534
535 if report.is_valid() {
536 return Ok(());
537 }
538
539 bail!(
540 "{} generated code failed quality validation:\n{}",
541 language_name(language),
542 report
543 );
544 }
545
546 fn write_asset(path: &Path, description: impl Into<String>, content: impl AsRef<[u8]>) -> Result<GeneratedAsset> {
547 if let Some(parent) = path.parent()
548 && !parent.as_os_str().is_empty()
549 {
550 fs::create_dir_all(parent).with_context(|| format!("Failed to create {}", parent.display()))?;
551 }
552
553 fs::write(path, content).with_context(|| format!("Failed to write {}", path.display()))?;
554
555 Ok(GeneratedAsset {
556 path: path.to_path_buf(),
557 description: description.into(),
558 })
559 }
560}
561
562const fn language_name(language: TargetLanguage) -> &'static str {
563 match language {
564 TargetLanguage::Python => "Python",
565 TargetLanguage::TypeScript => "Node.js",
566 TargetLanguage::Rust => "Rust",
567 TargetLanguage::Ruby => "Ruby",
568 TargetLanguage::Php => "PHP",
569 TargetLanguage::Elixir => "Elixir",
570 }
571}
572
573impl std::fmt::Debug for CodegenTargetKind {
574 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
575 match self {
576 Self::Server { language, .. } => f
577 .debug_struct("Server")
578 .field("language", language)
579 .finish_non_exhaustive(),
580 Self::AsyncFixtures { output } => f.debug_struct("AsyncFixtures").field("output", output).finish(),
581 Self::AsyncTestApp { language, output } => f
582 .debug_struct("AsyncTestApp")
583 .field("language", language)
584 .field("output", output)
585 .finish(),
586 Self::AsyncHandlers { language, output } => f
587 .debug_struct("AsyncHandlers")
588 .field("language", language)
589 .field("output", output)
590 .finish(),
591 Self::AsyncAll { output } => f.debug_struct("AsyncAll").field("output", output).finish(),
592 Self::JsonRpcHandlers { language, output } => f
593 .debug_struct("JsonRpcHandlers")
594 .field("language", language)
595 .field("output", output)
596 .finish(),
597 Self::GraphQL {
598 language,
599 output,
600 target,
601 } => f
602 .debug_struct("GraphQL")
603 .field("language", language)
604 .field("output", output)
605 .field("target", target)
606 .finish(),
607 Self::Protobuf {
608 language,
609 output,
610 target,
611 include_paths,
612 } => f
613 .debug_struct("Protobuf")
614 .field("language", language)
615 .field("output", output)
616 .field("target", target)
617 .field("include_paths", include_paths)
618 .finish(),
619 Self::SqlHandlers {
620 schema_paths,
621 output,
622 dialect,
623 languages,
624 emit_openapi,
625 ..
626 } => f
627 .debug_struct("SqlHandlers")
628 .field("schema_paths", schema_paths)
629 .field("output", output)
630 .field("dialect", dialect)
631 .field("languages", languages)
632 .field("emit_openapi", emit_openapi)
633 .finish_non_exhaustive(),
634 }
635 }
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641 use tempfile::tempdir;
642
643 fn write_minimal_openapi_schema(path: &Path) {
644 let spec = r#"
645{
646 "openapi": "3.0.3",
647 "info": { "title": "Demo", "version": "1.0.0" },
648 "paths": {
649 "/ping": {
650 "get": {
651 "operationId": "ping",
652 "responses": {
653 "200": {
654 "description": "ok",
655 "content": {
656 "application/json": {
657 "schema": {
658 "type": "object",
659 "properties": { "message": { "type": "string" } },
660 "required": ["message"]
661 }
662 }
663 }
664 }
665 }
666 }
667 }
668 }
669}
670"#;
671 fs::write(path, spec).unwrap();
672 }
673
674 #[test]
675 fn generates_openapi_code_in_memory_when_no_output_path() {
676 let dir = tempdir().unwrap();
677 let schema_path = dir.path().join("openapi.json");
678 write_minimal_openapi_schema(&schema_path);
679
680 let outcome = CodegenEngine::execute(CodegenRequest {
681 schema_path,
682 schema_kind: SchemaKind::OpenApi,
683 target: CodegenTargetKind::Server {
684 language: TargetLanguage::Python,
685 output: None,
686 },
687 dto: None,
688 })
689 .unwrap();
690
691 match outcome {
692 CodegenOutcome::InMemory(code) => {
693 assert!(code.contains("Generated by Spikard OpenAPI code generator"));
694 assert!(code.contains("ping"));
695 }
696 other => panic!("expected in-memory output, got {other:?}"),
697 }
698 }
699
700 #[test]
701 fn generates_openapi_code_to_file_when_output_path_provided() {
702 let dir = tempdir().unwrap();
703 let schema_path = dir.path().join("openapi.json");
704 write_minimal_openapi_schema(&schema_path);
705
706 let output_path = dir.path().join("generated.py");
707 let outcome = CodegenEngine::execute(CodegenRequest {
708 schema_path,
709 schema_kind: SchemaKind::OpenApi,
710 target: CodegenTargetKind::Server {
711 language: TargetLanguage::Python,
712 output: Some(output_path.clone()),
713 },
714 dto: None,
715 })
716 .unwrap();
717
718 match outcome {
719 CodegenOutcome::Files(assets) => {
720 assert_eq!(assets.len(), 1);
721 assert_eq!(assets[0].path, output_path);
722 assert!(assets[0].description.contains("Python"));
723 assert!(
724 fs::read_to_string(&assets[0].path)
725 .unwrap()
726 .contains("Generated by Spikard OpenAPI code generator")
727 );
728 }
729 other => panic!("expected file output, got {other:?}"),
730 }
731 }
732
733 #[test]
734 fn rejects_unsupported_schema_target_combinations() {
735 let dir = tempdir().unwrap();
736 let schema_path = dir.path().join("openapi.json");
737 write_minimal_openapi_schema(&schema_path);
738
739 let err = CodegenEngine::execute(CodegenRequest {
740 schema_path,
741 schema_kind: SchemaKind::OpenApi,
742 target: CodegenTargetKind::AsyncFixtures {
743 output: dir.path().join("out"),
744 },
745 dto: None,
746 })
747 .unwrap_err();
748
749 assert!(err.to_string().contains("Unsupported schema/target combination"));
750 }
751
752 #[test]
753 fn generates_openrpc_handlers_to_file() {
754 let dir = tempdir().unwrap();
755 let schema_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
756 .join("../../testing_data/schemas/user-api.openrpc.json");
757
758 let output_path = dir.path().join("handlers.ts");
759 let outcome = CodegenEngine::execute(CodegenRequest {
760 schema_path,
761 schema_kind: SchemaKind::OpenRpc,
762 target: CodegenTargetKind::JsonRpcHandlers {
763 language: TargetLanguage::TypeScript,
764 output: output_path.clone(),
765 },
766 dto: None,
767 })
768 .unwrap();
769
770 match outcome {
771 CodegenOutcome::Files(assets) => {
772 assert_eq!(assets.len(), 1);
773 assert_eq!(assets[0].path, output_path);
774 let contents = fs::read_to_string(&assets[0].path).unwrap();
775 assert!(contents.contains("handleJsonRpcCall"));
776 }
777 other => panic!("expected file output, got {other:?}"),
778 }
779 }
780
781 #[test]
782 fn generates_protobuf_python_code_to_file() {
783 let dir = tempdir().unwrap();
784 let schema_path = dir.path().join("test.proto");
785
786 let proto_schema = r#"syntax = "proto3";
787
788package test;
789
790message TestMessage {
791 string id = 1;
792 string name = 2;
793}
794"#;
795 fs::write(&schema_path, proto_schema).unwrap();
796
797 let output_path = dir.path().join("test_pb.py");
798 let outcome = CodegenEngine::execute(CodegenRequest {
799 schema_path,
800 schema_kind: SchemaKind::Protobuf,
801 target: CodegenTargetKind::Protobuf {
802 language: TargetLanguage::Python,
803 output: output_path.clone(),
804 target: "all".to_string(),
805 include_paths: Vec::new(),
806 },
807 dto: None,
808 })
809 .unwrap();
810
811 match outcome {
812 CodegenOutcome::Files(assets) => {
813 assert_eq!(assets.len(), 1);
814 assert_eq!(assets[0].path, output_path);
815 let contents = fs::read_to_string(&assets[0].path).unwrap();
816 assert!(contents.contains("DO NOT EDIT - Auto-generated by Spikard CLI"));
817 assert!(contents.contains("from google.protobuf import message"));
818 assert!(contents.contains("PROTOBUF_PACKAGE = \"test\""));
819 }
820 other => panic!("expected file output, got {other:?}"),
821 }
822 }
823
824 #[test]
825 fn validates_generated_rust_protobuf_before_writing() {
826 let dir = tempdir().unwrap();
827 let schema_path = dir.path().join("service.proto");
828 fs::write(
829 &schema_path,
830 r#"syntax = "proto3";
831
832package example;
833
834message User {
835 string id = 1;
836 string name = 2;
837}
838
839service UserService {
840 rpc GetUser (User) returns (User);
841}
842"#,
843 )
844 .unwrap();
845
846 let output_path = dir.path().join("generated.rs");
847 let outcome = CodegenEngine::execute_validated(CodegenRequest {
848 schema_path,
849 schema_kind: SchemaKind::Protobuf,
850 target: CodegenTargetKind::Protobuf {
851 language: TargetLanguage::Rust,
852 output: output_path.clone(),
853 target: "all".to_string(),
854 include_paths: Vec::new(),
855 },
856 dto: None,
857 })
858 .unwrap();
859
860 match outcome {
861 CodegenOutcome::Files(assets) => {
862 assert_eq!(assets.len(), 1);
863 assert_eq!(assets[0].path, output_path);
864 assert!(
865 fs::read_to_string(&assets[0].path)
866 .unwrap()
867 .contains("pub trait UserService")
868 );
869 }
870 other => panic!("expected file output, got {other:?}"),
871 }
872 }
873
874 #[test]
875 fn validates_generated_rust_openrpc_before_writing() {
876 let dir = tempdir().unwrap();
877 let schema_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
878 .join("../../testing_data/schemas/user-api.openrpc.json");
879 let output_path = dir.path().join("openrpc.rs");
880
881 let outcome = CodegenEngine::execute_validated(CodegenRequest {
882 schema_path,
883 schema_kind: SchemaKind::OpenRpc,
884 target: CodegenTargetKind::JsonRpcHandlers {
885 language: TargetLanguage::Rust,
886 output: output_path.clone(),
887 },
888 dto: None,
889 })
890 .unwrap();
891
892 match outcome {
893 CodegenOutcome::Files(assets) => {
894 assert_eq!(assets.len(), 1);
895 assert_eq!(assets[0].path, output_path);
896 let contents = fs::read_to_string(&assets[0].path).unwrap();
897 assert!(contents.contains("pub async fn handle_jsonrpc_call"));
898 assert!(contents.contains("pub fn register_jsonrpc_route"));
899 }
900 other => panic!("expected file output, got {other:?}"),
901 }
902 }
903
904 #[test]
905 fn validates_generated_rust_asyncapi_before_writing() {
906 let dir = tempdir().unwrap();
907 let schema_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
908 .join("../../testing_data/schemas/chat-service.asyncapi.yaml");
909 let output_path = dir.path().join("asyncapi.rs");
910
911 let outcome = CodegenEngine::execute_validated(CodegenRequest {
912 schema_path,
913 schema_kind: SchemaKind::AsyncApi,
914 target: CodegenTargetKind::AsyncHandlers {
915 language: TargetLanguage::Rust,
916 output: output_path.clone(),
917 },
918 dto: None,
919 })
920 .unwrap();
921
922 match outcome {
923 CodegenOutcome::Files(assets) => {
924 assert_eq!(assets.len(), 1);
925 assert_eq!(assets[0].path, output_path);
926 let contents = fs::read_to_string(&assets[0].path).unwrap();
927 assert!(contents.contains("pub fn register_asyncapi_routes"));
928 assert!(contents.contains("pub fn build_app() -> App"));
929 }
930 other => panic!("expected file output, got {other:?}"),
931 }
932 }
933
934 #[test]
935 fn validates_generated_rust_openapi_before_writing() {
936 let dir = tempdir().unwrap();
937 let schema_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
938 .join("../../testing_data/schemas/todo-api.openapi.yaml");
939 let output_path = dir.path().join("openapi.rs");
940
941 let outcome = CodegenEngine::execute_validated(CodegenRequest {
942 schema_path,
943 schema_kind: SchemaKind::OpenApi,
944 target: CodegenTargetKind::Server {
945 language: TargetLanguage::Rust,
946 output: Some(output_path.clone()),
947 },
948 dto: None,
949 })
950 .unwrap();
951
952 match outcome {
953 CodegenOutcome::Files(assets) => {
954 assert_eq!(assets.len(), 1);
955 assert_eq!(assets[0].path, output_path);
956 let contents = fs::read_to_string(&assets[0].path).unwrap();
957 assert!(contents.contains("pub fn build_app() -> Result<App, AppError>"));
958 assert!(contents.contains("pub struct AuthErrorResponse"));
959 }
960 other => panic!("expected file output, got {other:?}"),
961 }
962 }
963}