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