Skip to main content

camel_cli/commands/
openapi.rs

1//! `camel openapi generate <file>` — generate an OpenAPI 3.0.3 document
2//! from `rest:` blocks in a YAML route file.
3
4use std::path::Path;
5
6use clap::{Args, Subcommand};
7use serde_json::Value;
8
9use camel_dsl::openapi::generate_openapi;
10use camel_dsl::yaml::extract_rest_blocks;
11
12#[derive(Subcommand, Debug)]
13pub enum OpenapiAction {
14    /// Generate an OpenAPI 3.0 document from a route file
15    Generate(OpenapiGenerateArgs),
16}
17
18#[derive(Args, Debug)]
19pub struct OpenapiGenerateArgs {
20    /// Path to YAML route file containing `rest:` blocks.
21    pub file: String,
22
23    /// API title for the OpenAPI `info` section.
24    #[arg(long, default_value = "Generated API")]
25    pub title: String,
26
27    /// API version for the OpenAPI `info` section.
28    #[arg(long, default_value = "1.0.0")]
29    pub version: String,
30}
31
32/// Run the `openapi generate` subcommand logic.
33///
34/// Reads the file, extracts `rest:` blocks, validates them via lowering,
35/// generates the OpenAPI document, and returns it. Warnings are printed
36/// to stderr by the caller.
37pub fn run_generate(args: &OpenapiGenerateArgs) -> Result<Value, String> {
38    let content = std::fs::read_to_string(&args.file)
39        .map_err(|e| format!("failed to read '{}': {e}", args.file))?;
40
41    let extension = Path::new(&args.file)
42        .extension()
43        .and_then(|ext| ext.to_str())
44        .unwrap_or("");
45
46    let rest_blocks = match extension {
47        "yaml" | "yml" => {
48            extract_rest_blocks(&content).map_err(|e| format!("YAML parse error: {e}"))?
49        }
50        "json" => {
51            let dsl: camel_dsl::route_ast::RouteDslRoutes =
52                serde_json::from_str(&content).map_err(|e| format!("JSON parse error: {e}"))?;
53            dsl.rest
54        }
55        _ => extract_rest_blocks(&content).map_err(|e| format!("parse error (tried YAML): {e}"))?,
56    };
57
58    if rest_blocks.is_empty() {
59        return Err("no 'rest:' blocks found in file".to_string());
60    }
61
62    // I3: validate via lowering + duplicate route ID check before generation
63    let lowered = camel_dsl::rest::lower_all_rest_to_routes(&rest_blocks)
64        .map_err(|e| format!("validation error: {e}"))?;
65    camel_dsl::rest::check_duplicate_route_ids(&lowered)
66        .map_err(|e| format!("validation error: {e}"))?;
67
68    let result = generate_openapi(&rest_blocks, &args.title, &args.version);
69
70    for warning in &result.warnings {
71        eprintln!("warning: {warning}");
72    }
73
74    Ok(result.document)
75}
76
77/// M2: CLI entrypoint wrapper (consistent with other commands).
78pub fn run(action: OpenapiAction) {
79    match action {
80        OpenapiAction::Generate(args) => match run_generate(&args) {
81            Ok(doc) => {
82                let pretty =
83                    serde_json::to_string_pretty(&doc).unwrap_or_else(|_| "{}".to_string()); // allow-unwrap
84                println!("{pretty}");
85            }
86            Err(e) => {
87                eprintln!("error: {e}");
88                std::process::exit(1);
89            }
90        },
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use std::io::Write;
98
99    #[test]
100    fn generate_from_yaml_file() {
101        let yaml = r#"
102rest:
103  - host: 0.0.0.0
104    port: 9090
105    path: /api/users
106    operations:
107      - method: GET
108        operation_id: listUsers
109        to: direct:listUsers
110      - method: POST
111        operation_id: createUser
112        consumes: application/json
113        produces: application/json
114        success_status: 201
115        to: direct:createUser
116        request_schema:
117          type: object
118          properties:
119            name:
120              type: string
121          required: [name]
122"#;
123        let mut tmp = tempfile::NamedTempFile::new().unwrap(); // allow-unwrap
124        write!(tmp, "{yaml}").unwrap(); // allow-unwrap
125
126        let args = OpenapiGenerateArgs {
127            file: tmp.path().to_string_lossy().to_string(),
128            title: "Test API".to_string(),
129            version: "0.1.0".to_string(),
130        };
131
132        let doc = run_generate(&args).expect("generation should succeed");
133        assert_eq!(doc["openapi"], "3.0.3");
134        assert_eq!(doc["info"]["title"], "Test API");
135        assert_eq!(doc["info"]["version"], "0.1.0");
136
137        let paths = &doc["paths"];
138        assert!(paths["/api/users"].get("get").is_some());
139        assert!(paths["/api/users"].get("post").is_some());
140
141        // Post has explicit request_schema
142        let post_schema =
143            &paths["/api/users"]["post"]["requestBody"]["content"]["application/json"]["schema"];
144        assert_eq!(post_schema["properties"]["name"]["type"], "string");
145    }
146
147    #[test]
148    fn generate_from_json_file() {
149        let json = r#"{"rest":[{"host":"0.0.0.0","port":9090,"path":"/api/users","operations":[{"method":"GET","operation_id":"listUsers","to":"direct:listUsers"}]}]}"#;
150        let mut tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap(); // allow-unwrap
151        write!(tmp, "{json}").unwrap(); // allow-unwrap
152
153        let args = OpenapiGenerateArgs {
154            file: tmp.path().to_string_lossy().to_string(),
155            title: "Test API".to_string(),
156            version: "1.0.0".to_string(),
157        };
158
159        let doc = run_generate(&args).expect("generation should succeed");
160        assert_eq!(doc["openapi"], "3.0.3");
161        assert_eq!(
162            doc["paths"]["/api/users"]["get"]["operationId"],
163            "listUsers"
164        );
165    }
166
167    #[test]
168    fn error_when_no_rest_blocks() {
169        let yaml = r#"
170routes:
171  - id: myRoute
172    from: timer:tick
173    steps:
174      - to: log:info
175"#;
176        let mut tmp = tempfile::NamedTempFile::new().unwrap(); // allow-unwrap
177        write!(tmp, "{yaml}").unwrap(); // allow-unwrap
178
179        let args = OpenapiGenerateArgs {
180            file: tmp.path().to_string_lossy().to_string(),
181            title: "API".to_string(),
182            version: "1.0.0".to_string(),
183        };
184
185        let result = run_generate(&args);
186        assert!(result.is_err());
187        assert!(result.unwrap_err().contains("no 'rest:' blocks")); // allow-unwrap
188    }
189
190    #[test]
191    fn validation_error_on_duplicate_path_verb() {
192        let yaml = r#"
193rest:
194  - host: 0.0.0.0
195    port: 9090
196    path: /api/users
197    operations:
198      - method: GET
199        operation_id: listUsers
200        to: direct:listUsers
201  - host: 0.0.0.0
202    port: 9090
203    path: /api/users
204    operations:
205      - method: GET
206        operation_id: listUsers2
207        to: direct:listUsers
208"#;
209        let mut tmp = tempfile::NamedTempFile::new().unwrap(); // allow-unwrap
210        write!(tmp, "{yaml}").unwrap(); // allow-unwrap
211
212        let args = OpenapiGenerateArgs {
213            file: tmp.path().to_string_lossy().to_string(),
214            title: "API".to_string(),
215            version: "1.0.0".to_string(),
216        };
217
218        let result = run_generate(&args);
219        assert!(result.is_err());
220        assert!(result.unwrap_err().contains("validation error"));
221    }
222
223    #[test]
224    fn error_when_file_not_found() {
225        let args = OpenapiGenerateArgs {
226            file: "/nonexistent/file.yaml".to_string(),
227            title: "API".to_string(),
228            version: "1.0.0".to_string(),
229        };
230        let result = run_generate(&args);
231        assert!(result.is_err());
232    }
233}