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)]
102pub struct GeneratedAsset {
103    pub path: PathBuf,
104    pub description: String,
105}
106
107/// Output of the engine run
108#[derive(Debug, Clone, serde::Serialize)]
109pub enum CodegenOutcome {
110    /// Generated code that should be printed to stdout (no file requested)
111    InMemory(String),
112    /// Files that were written to disk
113    Files(Vec<GeneratedAsset>),
114}
115
116/// Code generation runtime orchestrating schema parsing and artifact generation
117pub 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                // Parse target string to ProtobufTarget enum
205                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        // Generate code based on language
433        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        // For Ruby, also generate RBS type signatures when appropriate
501        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            // Determine RBS output path (replace .rb extension with .rbs)
512            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        // Write a minimal proto3 schema
781        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}