Skip to main content

spikard_cli/codegen/
engine.rs

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/// Code generation schema families supported by the CLI
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum SchemaKind {
37    OpenApi,
38    AsyncApi,
39    OpenRpc,
40    GraphQL,
41    Protobuf,
42    /// Annotated SQL queries (consumed via scythe + the `spikard_codegen::sql` module)
43    Sql,
44}
45
46/// Type of artifact to generate for a schema
47#[derive(Clone)]
48pub enum CodegenTargetKind {
49    /// Generate server handler code for a language (`OpenAPI` today)
50    Server {
51        language: TargetLanguage,
52        output: Option<PathBuf>,
53    },
54    /// Generate `AsyncAPI` fixtures (SSE/WebSocket)
55    AsyncFixtures { output: PathBuf },
56    /// Generate `AsyncAPI` test application for a language
57    AsyncTestApp { language: TargetLanguage, output: PathBuf },
58    /// Generate `AsyncAPI` handler scaffolding for a language
59    AsyncHandlers { language: TargetLanguage, output: PathBuf },
60    /// Generate fixtures + test applications for all `AsyncAPI` languages
61    AsyncAll { output: PathBuf },
62    /// Generate JSON-RPC handler scaffolding for a language
63    JsonRpcHandlers { language: TargetLanguage, output: PathBuf },
64    /// Generate GraphQL types, resolvers, or schema for a language
65    GraphQL {
66        language: TargetLanguage,
67        output: PathBuf,
68        target: String,
69    },
70    /// Generate Protobuf messages and gRPC services
71    Protobuf {
72        language: TargetLanguage,
73        output: PathBuf,
74        target: String,
75        include_paths: Vec<PathBuf>,
76    },
77    /// Generate routes + OpenAPI + sidecar from annotated SQL queries
78    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/// Request executed by the code generation engine
92#[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/// Represents an asset emitted by the code generation engine
101#[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/// Output of the engine run
113#[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    /// Generated code that should be printed to stdout (no file requested)
121    InMemory(String),
122    /// Files that were written to disk
123    Files(Vec<GeneratedAsset>),
124}
125
126/// Code generation runtime orchestrating schema parsing and artifact generation
127pub 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}