camel-cli 0.43.0

Command-line interface for Apache Camel in Rust
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
//! `camel openapi generate <file>` — generate an OpenAPI 3.0.3 document
//! from `rest:` blocks in a YAML or JSON route file. `${env:}` placeholders in
//! `rest:` blocks resolve default-only at generate time: string positions
//! take the concrete default, int/bool positions fail per the tree-walk
//! canon, and ambient env is never read.

use std::path::Path;

use clap::{Args, Subcommand};
use serde_json::Value;

use camel_dsl::openapi::generate_openapi;

#[derive(Subcommand, Debug)]
pub enum OpenapiAction {
    /// Generate an OpenAPI 3.0 document from a route file
    Generate(OpenapiGenerateArgs),
}

#[derive(Args, Debug)]
pub struct OpenapiGenerateArgs {
    /// Path to YAML or JSON route file containing `rest:` blocks.
    pub file: String,

    /// API title for the OpenAPI `info` section.
    #[arg(long, default_value = "Generated API")]
    pub title: String,

    /// API version for the OpenAPI `info` section.
    #[arg(long, default_value = "1.0.0")]
    pub version: String,
}

/// Run the `openapi generate` subcommand logic.
///
/// Reads the file and extracts `rest:` blocks in one sibling call, which
/// also resolves `${env:}` placeholders default-only (string positions take
/// the concrete default; int/bool positions fail per the tree-walk canon;
/// ambient env is never read). Then validates via lowering, generates the
/// OpenAPI document, and returns it. Warnings are printed
/// to stderr by the caller.
pub fn run_generate(args: &OpenapiGenerateArgs) -> Result<Value, String> {
    let rest_blocks =
        camel_dsl::extract_rest_blocks_from_file_with_env(Path::new(&args.file), &|_| None)
            .map_err(|e| format!("route file error: {e}"))?;

    if rest_blocks.is_empty() {
        return Err("no 'rest:' blocks found in file".to_string());
    }

    // I3: validate via lowering + duplicate route ID check before generation
    let lowered = camel_dsl::rest::lower_all_rest_to_routes(&rest_blocks)
        .map_err(|e| format!("validation error: {e}"))?;
    camel_dsl::rest::check_duplicate_route_ids(&lowered)
        .map_err(|e| format!("validation error: {e}"))?;

    let result = generate_openapi(&rest_blocks, &args.title, &args.version);

    for warning in &result.warnings {
        eprintln!("warning: {warning}");
    }

    Ok(result.document)
}

/// M2: CLI entrypoint wrapper (consistent with other commands).
pub fn run(action: OpenapiAction) {
    match action {
        OpenapiAction::Generate(args) => match run_generate(&args) {
            Ok(doc) => {
                let pretty =
                    serde_json::to_string_pretty(&doc).unwrap_or_else(|_| "{}".to_string()); // allow-unwrap
                println!("{pretty}");
            }
            Err(e) => {
                eprintln!("error: {e}");
                std::process::exit(1);
            }
        },
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::commands::run::tests::EnvVarGuard;
    use std::io::Write;

    #[test]
    fn generate_from_yaml_file() {
        let yaml = r#"
rest:
  - host: 0.0.0.0
    port: 9090
    path: /api/users
    operations:
      - method: GET
        operation_id: listUsers
        to: direct:listUsers
      - method: POST
        operation_id: createUser
        consumes: application/json
        produces: application/json
        success_status: 201
        to: direct:createUser
        request_schema:
          type: object
          properties:
            name:
              type: string
          required: [name]
"#;
        let mut tmp = tempfile::NamedTempFile::new().unwrap(); // allow-unwrap
        write!(tmp, "{yaml}").unwrap(); // allow-unwrap

        let args = OpenapiGenerateArgs {
            file: tmp.path().to_string_lossy().to_string(),
            title: "Test API".to_string(),
            version: "0.1.0".to_string(),
        };

        let doc = run_generate(&args).expect("generation should succeed");
        assert_eq!(doc["openapi"], "3.0.3");
        assert_eq!(doc["info"]["title"], "Test API");
        assert_eq!(doc["info"]["version"], "0.1.0");

        let paths = &doc["paths"];
        assert!(paths["/api/users"].get("get").is_some());
        assert!(paths["/api/users"].get("post").is_some());

        // Post has explicit request_schema
        let post_schema =
            &paths["/api/users"]["post"]["requestBody"]["content"]["application/json"]["schema"];
        assert_eq!(post_schema["properties"]["name"]["type"], "string");
    }

    #[test]
    fn generate_from_json_file() {
        let json = r#"{"rest":[{"host":"0.0.0.0","port":9090,"path":"/api/users","operations":[{"method":"GET","operation_id":"listUsers","to":"direct:listUsers"}]}]}"#;
        let mut tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap(); // allow-unwrap
        write!(tmp, "{json}").unwrap(); // allow-unwrap

        let args = OpenapiGenerateArgs {
            file: tmp.path().to_string_lossy().to_string(),
            title: "Test API".to_string(),
            version: "1.0.0".to_string(),
        };

        let doc = run_generate(&args).expect("generation should succeed");
        assert_eq!(doc["openapi"], "3.0.3");
        assert_eq!(
            doc["paths"]["/api/users"]["get"]["operationId"],
            "listUsers"
        );
    }

    #[test]
    fn error_when_no_rest_blocks() {
        let yaml = r#"
routes:
  - id: myRoute
    from: timer:tick
    steps:
      - to: log:info
"#;
        let mut tmp = tempfile::NamedTempFile::new().unwrap(); // allow-unwrap
        write!(tmp, "{yaml}").unwrap(); // allow-unwrap

        let args = OpenapiGenerateArgs {
            file: tmp.path().to_string_lossy().to_string(),
            title: "API".to_string(),
            version: "1.0.0".to_string(),
        };

        let result = run_generate(&args);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("no 'rest:' blocks")); // allow-unwrap
    }

    #[test]
    fn validation_error_on_duplicate_path_verb() {
        let yaml = r#"
rest:
  - host: 0.0.0.0
    port: 9090
    path: /api/users
    operations:
      - method: GET
        operation_id: listUsers
        to: direct:listUsers
  - host: 0.0.0.0
    port: 9090
    path: /api/users
    operations:
      - method: GET
        operation_id: listUsers2
        to: direct:listUsers
"#;
        let mut tmp = tempfile::NamedTempFile::new().unwrap(); // allow-unwrap
        write!(tmp, "{yaml}").unwrap(); // allow-unwrap

        let args = OpenapiGenerateArgs {
            file: tmp.path().to_string_lossy().to_string(),
            title: "API".to_string(),
            version: "1.0.0".to_string(),
        };

        let result = run_generate(&args);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("validation error"));
    }

    #[test]
    fn error_when_file_not_found() {
        let args = OpenapiGenerateArgs {
            file: "/nonexistent/file.yaml".to_string(),
            title: "API".to_string(),
            version: "1.0.0".to_string(),
        };
        let result = run_generate(&args);
        assert!(result.is_err());
    }

    #[test]
    fn generate_resolves_string_default_in_server_url() {
        let yaml = r#"
rest:
  - host: ${env:HOST:-0.0.0.0}
    port: 9090
    path: /api/users
    operations:
      - method: GET
        operation_id: listUsers
        to: direct:listUsers
"#;
        let mut tmp = tempfile::NamedTempFile::with_suffix(".yaml").unwrap(); // allow-unwrap
        write!(tmp, "{yaml}").unwrap(); // allow-unwrap

        let args = OpenapiGenerateArgs {
            file: tmp.path().to_string_lossy().to_string(),
            title: "Test API".to_string(),
            version: "0.1.0".to_string(),
        };

        let doc = run_generate(&args).expect("default-only env resolution should succeed");
        assert_eq!(doc["servers"][0]["url"], "http://0.0.0.0:9090");
        let serialized = serde_json::to_string(&doc).unwrap(); // allow-unwrap
        assert!(
            !serialized.contains("${env"),
            "document must not carry unresolved placeholders: {serialized}"
        );
    }

    #[test]
    fn generate_ambient_env_ignored() {
        let yaml = r#"
rest:
  - host: ${env:RC_GYKDS_CLI_VAR:-0.0.0.0}
    port: 9090
    path: /api/users
    operations:
      - method: GET
        operation_id: listUsers
        to: direct:listUsers
"#;
        let mut tmp = tempfile::NamedTempFile::with_suffix(".yaml").unwrap(); // allow-unwrap
        write!(tmp, "{yaml}").unwrap(); // allow-unwrap

        let args = OpenapiGenerateArgs {
            file: tmp.path().to_string_lossy().to_string(),
            title: "Test API".to_string(),
            version: "0.1.0".to_string(),
        };

        // The guard restores the prior value on drop, so a panicking
        // assertion cannot leak the ambient value into other tests.
        let _env = EnvVarGuard::set("RC_GYKDS_CLI_VAR", "evil");

        let doc = run_generate(&args).expect("ambient env must be ignored, default wins");
        assert_eq!(doc["servers"][0]["url"], "http://0.0.0.0:9090");
        let serialized = serde_json::to_string(&doc).unwrap(); // allow-unwrap
        assert!(
            !serialized.contains("${env"),
            "document must not carry unresolved placeholders: {serialized}"
        );
        assert!(
            !serialized.contains("evil"),
            "document must not surface the ambient value: {serialized}"
        );
    }

    #[test]
    fn generate_int_port_placeholder_errors() {
        let yaml = r#"
rest:
  - host: 127.0.0.1
    port: ${env:PORT:-8080}
    path: /api/users
    operations:
      - method: GET
        operation_id: listUsers
        to: direct:listUsers
"#;
        let mut tmp = tempfile::NamedTempFile::with_suffix(".yaml").unwrap(); // allow-unwrap
        write!(tmp, "{yaml}").unwrap(); // allow-unwrap

        let args = OpenapiGenerateArgs {
            file: tmp.path().to_string_lossy().to_string(),
            title: "API".to_string(),
            version: "1.0.0".to_string(),
        };

        let result = run_generate(&args);
        assert!(result.is_err());
        let err = result.unwrap_err(); // allow-unwrap
        assert!(
            err.contains("type mismatch")
                && err.contains("expected unsigned integer")
                && err.contains("found string"),
            "expected the type-mismatch wording, got: {err}"
        );
        assert!(
            !err.contains("not set"),
            "must not surface env wording: {err}"
        );
        assert!(!err.contains("PORT"), "must not name the variable: {err}");
    }

    #[test]
    fn generate_no_default_names_variable() {
        let yaml = r#"
rest:
  - host: ${env:REST_HOST}
    port: 9090
    path: /api/users
    operations:
      - method: GET
        operation_id: listUsers
        to: direct:listUsers
"#;
        let mut tmp = tempfile::NamedTempFile::with_suffix(".yaml").unwrap(); // allow-unwrap
        write!(tmp, "{yaml}").unwrap(); // allow-unwrap

        let args = OpenapiGenerateArgs {
            file: tmp.path().to_string_lossy().to_string(),
            title: "API".to_string(),
            version: "1.0.0".to_string(),
        };

        let result = run_generate(&args);
        assert!(result.is_err());
        let err = result.unwrap_err(); // allow-unwrap
        assert!(
            err.contains("REST_HOST") && err.contains("not set"),
            "expected the error to name REST_HOST as not set, got: {err}"
        );
    }

    #[test]
    fn generate_success_status_placeholder_errors() {
        let yaml = r#"
rest:
  - host: 127.0.0.1
    port: 9090
    path: /api/users
    operations:
      - method: POST
        operation_id: createUser
        consumes: application/json
        produces: application/json
        success_status: ${env:CODE:-201}
        to: direct:createUser
"#;
        let mut tmp = tempfile::NamedTempFile::with_suffix(".yaml").unwrap(); // allow-unwrap
        write!(tmp, "{yaml}").unwrap(); // allow-unwrap

        let args = OpenapiGenerateArgs {
            file: tmp.path().to_string_lossy().to_string(),
            title: "API".to_string(),
            version: "1.0.0".to_string(),
        };

        let result = run_generate(&args);
        assert!(result.is_err());
        let err = result.unwrap_err(); // allow-unwrap
        assert!(
            err.contains("type mismatch")
                && err.contains("expected unsigned integer")
                && err.contains("found string"),
            "expected the type-mismatch wording, got: {err}"
        );
        assert!(
            !err.contains("not set"),
            "must not surface env wording: {err}"
        );
        assert!(!err.contains("CODE"), "must not name the variable: {err}");
    }

    #[test]
    fn generate_free_value_schema_position_resolves() {
        let yaml = r#"
rest:
  - host: 127.0.0.1
    port: 9090
    path: /api/users
    operations:
      - method: POST
        operation_id: createUser
        consumes: application/json
        produces: application/json
        success_status: 201
        to: direct:createUser
        request_schema:
          type: object
          properties:
            name:
              type: ${env:T:-string}
"#;
        let mut tmp = tempfile::NamedTempFile::with_suffix(".yaml").unwrap(); // allow-unwrap
        write!(tmp, "{yaml}").unwrap(); // allow-unwrap

        let args = OpenapiGenerateArgs {
            file: tmp.path().to_string_lossy().to_string(),
            title: "Test API".to_string(),
            version: "0.1.0".to_string(),
        };

        let doc = run_generate(&args).expect("free-value schema position should resolve");
        let schema = &doc["paths"]["/api/users"]["post"]["requestBody"]["content"]["application/json"]
            ["schema"];
        assert_eq!(schema["properties"]["name"]["type"], "string");
        let serialized = serde_json::to_string(&doc).unwrap(); // allow-unwrap
        assert!(
            !serialized.contains("${env"),
            "document must not carry unresolved placeholders: {serialized}"
        );
    }

    #[test]
    fn generate_json_arm_parity() {
        // String-default host resolves to a concrete server URL.
        let resolves = r#"{"rest":[{"host":"${env:HOST:-0.0.0.0}","port":9090,"path":"/api/users","operations":[{"method":"GET","operation_id":"listUsers","to":"direct:listUsers"}]}]}"#;
        let mut tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap(); // allow-unwrap
        write!(tmp, "{resolves}").unwrap(); // allow-unwrap

        let args = OpenapiGenerateArgs {
            file: tmp.path().to_string_lossy().to_string(),
            title: "API".to_string(),
            version: "1.0.0".to_string(),
        };
        let doc = run_generate(&args).expect("json string default should resolve");
        assert_eq!(doc["servers"][0]["url"], "http://0.0.0.0:9090");
        let serialized = serde_json::to_string(&doc).unwrap(); // allow-unwrap
        assert!(
            !serialized.contains("${env"),
            "document must not carry unresolved placeholders: {serialized}"
        );

        // Port placeholder fails deserialization: the spliced leaf is a JSON
        // string where u16 is expected (verbatim serde_json wording).
        let int_port = r#"{"rest":[{"host":"127.0.0.1","port":"${env:PORT:-8080}","path":"/api/users","operations":[{"method":"GET","operation_id":"listUsers","to":"direct:listUsers"}]}]}"#;
        let mut tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap(); // allow-unwrap
        write!(tmp, "{int_port}").unwrap(); // allow-unwrap

        let args = OpenapiGenerateArgs {
            file: tmp.path().to_string_lossy().to_string(),
            title: "API".to_string(),
            version: "1.0.0".to_string(),
        };
        let result = run_generate(&args);
        assert!(result.is_err());
        let err = result.unwrap_err(); // allow-unwrap
        assert!(
            err.contains("invalid type"),
            "expected serde_json invalid-type wording, got: {err}"
        );
        assert!(
            !err.contains("not set"),
            "must not surface env wording: {err}"
        );
        assert!(!err.contains("PORT"), "must not name the variable: {err}");

        // No-default token fails naming the variable.
        let no_default = r#"{"rest":[{"host":"${env:REST_HOST}","port":9090,"path":"/api/users","operations":[{"method":"GET","operation_id":"listUsers","to":"direct:listUsers"}]}]}"#;
        let mut tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap(); // allow-unwrap
        write!(tmp, "{no_default}").unwrap(); // allow-unwrap

        let args = OpenapiGenerateArgs {
            file: tmp.path().to_string_lossy().to_string(),
            title: "API".to_string(),
            version: "1.0.0".to_string(),
        };
        let result = run_generate(&args);
        assert!(result.is_err());
        let err = result.unwrap_err(); // allow-unwrap
        assert!(
            err.contains("REST_HOST") && err.contains("not set"),
            "expected the error to name REST_HOST as not set, got: {err}"
        );
    }
}