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