1use std::{
2 error::Error,
3 fmt, fs,
4 path::{Path, PathBuf},
5 sync::mpsc,
6 thread,
7 time::Duration,
8};
9
10use httpgenerator_core::{GeneratorSettings, HttpFile, generate_http_files};
11use httpgenerator_openapi::{
12 OpenApiInspection, OpenApiSpecificationVersion, inspect_document,
13 load_and_normalize_document_with_options,
14};
15
16use crate::{args::CliArgs, auth::try_get_access_token};
17
18pub mod args;
19mod auth;
20pub mod telemetry;
21
22pub use telemetry::{NoopTelemetrySink, TelemetryRecorder};
23
24pub trait ExecutionObserver {
25 fn validation_started(&mut self) {}
26
27 fn validation_succeeded(&mut self, _inspection: &OpenApiInspection) {}
28
29 fn azure_auth_started(&mut self) {}
30
31 fn azure_auth_finished(&mut self, _status: &AzureAuthStatus) {}
32
33 fn file_writing_started(&mut self, _file_count: usize) {}
34
35 fn files_written(&mut self, _paths: &[PathBuf]) {}
36}
37
38#[derive(Default)]
39struct NoopExecutionObserver;
40
41impl ExecutionObserver for NoopExecutionObserver {}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct ExecutionSummary {
45 pub output_folder: PathBuf,
46 pub files: Vec<PathBuf>,
47 pub validation: Option<OpenApiInspection>,
48 pub azure_auth: AzureAuthStatus,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum AzureAuthStatus {
53 NotRequested,
54 Acquired,
55 Failed { reason: String },
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum CliError {
60 MissingInput,
61 InspectOpenApi(String),
62 LoadOpenApi(String),
63 UnsupportedValidationVersion {
64 version: OpenApiSpecificationVersion,
65 },
66 CreateOutputDirectory {
67 path: PathBuf,
68 reason: String,
69 },
70 WriteFiles {
71 path: PathBuf,
72 reason: String,
73 },
74 WriteTimeout {
75 seconds: u64,
76 },
77 WriteChannelClosed,
78}
79
80impl fmt::Display for CliError {
81 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
82 match self {
83 Self::MissingInput => write!(
84 formatter,
85 "missing OpenAPI input path or URL; run with --help for usage"
86 ),
87 Self::InspectOpenApi(reason) => write!(formatter, "{reason}"),
88 Self::LoadOpenApi(reason) => write!(formatter, "{reason}"),
89 Self::UnsupportedValidationVersion { version } => write!(
90 formatter,
91 "{version} documents are not supported by CLI validation yet; retry with --skip-validation"
92 ),
93 Self::CreateOutputDirectory { path, reason } => write!(
94 formatter,
95 "failed to create output directory '{}': {reason}",
96 path.display()
97 ),
98 Self::WriteFiles { path, reason } => write!(
99 formatter,
100 "failed to write generated file '{}': {reason}",
101 path.display()
102 ),
103 Self::WriteTimeout { seconds } => write!(
104 formatter,
105 "timed out after {seconds} second(s) while writing generated files"
106 ),
107 Self::WriteChannelClosed => write!(
108 formatter,
109 "file writing worker stopped before reporting a result"
110 ),
111 }
112 }
113}
114
115impl Error for CliError {}
116
117impl CliError {
118 pub const fn telemetry_name(&self) -> &'static str {
119 match self {
120 Self::MissingInput => "MissingInput",
121 Self::InspectOpenApi(_) => "InspectOpenApi",
122 Self::LoadOpenApi(_) => "LoadOpenApi",
123 Self::UnsupportedValidationVersion { .. } => "UnsupportedValidationVersion",
124 Self::CreateOutputDirectory { .. } => "CreateOutputDirectory",
125 Self::WriteFiles { .. } => "WriteFiles",
126 Self::WriteTimeout { .. } => "WriteTimeout",
127 Self::WriteChannelClosed => "WriteChannelClosed",
128 }
129 }
130}
131
132pub fn execute(args: CliArgs) -> Result<ExecutionSummary, CliError> {
133 let mut observer = NoopExecutionObserver;
134 execute_with(args, &mut observer, try_get_access_token)
135}
136
137pub fn execute_with_observer<O>(
138 args: CliArgs,
139 observer: &mut O,
140) -> Result<ExecutionSummary, CliError>
141where
142 O: ExecutionObserver,
143{
144 execute_with(args, observer, try_get_access_token)
145}
146
147pub fn should_attempt_azure_auth(args: &CliArgs) -> bool {
148 if args
149 .authorization_header
150 .as_deref()
151 .map(str::trim)
152 .is_some_and(|header| !header.is_empty())
153 {
154 return false;
155 }
156
157 args.azure_scope
158 .as_deref()
159 .map(str::trim)
160 .is_some_and(|scope| !scope.is_empty())
161 || args
162 .azure_tenant_id
163 .as_deref()
164 .map(str::trim)
165 .is_some_and(|tenant_id| !tenant_id.is_empty())
166}
167
168fn execute_with<F, O>(
169 args: CliArgs,
170 observer: &mut O,
171 acquire_token: F,
172) -> Result<ExecutionSummary, CliError>
173where
174 F: Fn(Option<&str>, &str) -> Result<Option<String>, String>,
175 O: ExecutionObserver,
176{
177 let open_api_path = args.open_api_path.clone().ok_or(CliError::MissingInput)?;
178 if !args.skip_validation {
179 observer.validation_started();
180 }
181
182 let validation = validate_openapi_document(&open_api_path, args.skip_validation)?;
183 if let Some(inspection) = &validation {
184 observer.validation_succeeded(inspection);
185 }
186
187 let should_attempt_azure_auth = should_attempt_azure_auth(&args);
188 if should_attempt_azure_auth {
189 observer.azure_auth_started();
190 }
191
192 let (authorization_header, azure_auth) = resolve_authorization_header(&args, acquire_token);
193 if should_attempt_azure_auth {
194 observer.azure_auth_finished(&azure_auth);
195 }
196
197 let document = load_and_normalize_document_with_options(&open_api_path, args.skip_validation)
198 .map_err(|error| CliError::LoadOpenApi(error.to_string()))?;
199 let settings = GeneratorSettings {
200 open_api_path: open_api_path.clone(),
201 authorization_header,
202 authorization_header_from_environment_variable: args
203 .authorization_header_from_environment_variable,
204 authorization_header_variable_name: args.authorization_header_variable_name.clone(),
205 content_type: args.content_type.clone(),
206 base_url: args.base_url.clone(),
207 output_type: args.output_type.into(),
208 timeout: args.timeout,
209 generate_intellij_tests: args.generate_intellij_tests,
210 custom_headers: args.custom_headers.clone(),
211 skip_headers: args.skip_headers,
212 };
213 let result = generate_http_files(&settings, &document);
214 observer.file_writing_started(result.files.len());
215 let output_folder = PathBuf::from(&args.output_folder);
216 let files = write_files(&output_folder, result.files, args.timeout)?;
217 observer.files_written(&files);
218
219 Ok(ExecutionSummary {
220 output_folder,
221 files,
222 validation,
223 azure_auth,
224 })
225}
226
227fn validate_openapi_document(
228 open_api_path: &str,
229 skip_validation: bool,
230) -> Result<Option<OpenApiInspection>, CliError> {
231 if skip_validation {
232 return Ok(None);
233 }
234
235 let inspection = inspect_document(open_api_path)
236 .map_err(|error| CliError::InspectOpenApi(error.to_string()))?;
237
238 if inspection.specification_version == OpenApiSpecificationVersion::OpenApi31 {
239 return Err(CliError::UnsupportedValidationVersion {
240 version: inspection.specification_version,
241 });
242 }
243
244 Ok(Some(inspection))
245}
246
247fn resolve_authorization_header<F>(
248 args: &CliArgs,
249 acquire_token: F,
250) -> (Option<String>, AzureAuthStatus)
251where
252 F: Fn(Option<&str>, &str) -> Result<Option<String>, String>,
253{
254 if let Some(authorization_header) = args
255 .authorization_header
256 .as_deref()
257 .map(str::trim)
258 .filter(|header| !header.is_empty())
259 {
260 return (
261 Some(authorization_header.to_string()),
262 AzureAuthStatus::NotRequested,
263 );
264 }
265
266 let tenant_id = args
267 .azure_tenant_id
268 .as_deref()
269 .map(str::trim)
270 .filter(|tenant_id| !tenant_id.is_empty());
271 let Some(scope) = args
272 .azure_scope
273 .as_deref()
274 .map(str::trim)
275 .filter(|scope| !scope.is_empty())
276 else {
277 return if tenant_id.is_some() {
278 (
279 None,
280 AzureAuthStatus::Failed {
281 reason: "Azure Entra ID scope is required to acquire an authorization header."
282 .to_string(),
283 },
284 )
285 } else {
286 (None, AzureAuthStatus::NotRequested)
287 };
288 };
289
290 match acquire_token(tenant_id, scope) {
291 Ok(Some(token)) if !token.trim().is_empty() => (
292 Some(format!("Bearer {}", token.trim())),
293 AzureAuthStatus::Acquired,
294 ),
295 Ok(Some(_)) => (
296 None,
297 AzureAuthStatus::Failed {
298 reason: "Azure Entra ID returned an empty access token.".to_string(),
299 },
300 ),
301 Ok(None) => (
302 None,
303 AzureAuthStatus::Failed {
304 reason: "Azure Entra ID did not return an access token.".to_string(),
305 },
306 ),
307 Err(reason) => (None, AzureAuthStatus::Failed { reason }),
308 }
309}
310
311fn write_files(
312 output_folder: &Path,
313 files: Vec<HttpFile>,
314 timeout_seconds: u64,
315) -> Result<Vec<PathBuf>, CliError> {
316 if !output_folder.exists() {
317 fs::create_dir_all(output_folder).map_err(|error| CliError::CreateOutputDirectory {
318 path: output_folder.to_path_buf(),
319 reason: error.to_string(),
320 })?;
321 }
322
323 let output_folder = output_folder.to_path_buf();
324 let (sender, receiver) = mpsc::channel();
325
326 thread::spawn({
327 let output_folder = output_folder.clone();
328 move || {
329 let result = write_files_worker(&output_folder, files);
330 let _ = sender.send(result);
331 }
332 });
333
334 receiver
335 .recv_timeout(Duration::from_secs(timeout_seconds))
336 .map_err(|error| match error {
337 mpsc::RecvTimeoutError::Timeout => CliError::WriteTimeout {
338 seconds: timeout_seconds,
339 },
340 mpsc::RecvTimeoutError::Disconnected => CliError::WriteChannelClosed,
341 })?
342}
343
344fn write_files_worker(
345 output_folder: &Path,
346 files: Vec<HttpFile>,
347) -> Result<Vec<PathBuf>, CliError> {
348 let mut written_paths = Vec::with_capacity(files.len());
349
350 for file in files {
351 let path = output_folder.join(&file.filename);
352 fs::write(&path, file.content).map_err(|error| CliError::WriteFiles {
353 path: path.clone(),
354 reason: error.to_string(),
355 })?;
356 written_paths.push(path);
357 }
358
359 Ok(written_paths)
360}
361
362#[cfg(test)]
363mod tests {
364 use std::{
365 fs,
366 path::PathBuf,
367 time::{SystemTime, UNIX_EPOCH},
368 };
369
370 use crate::{
371 AzureAuthStatus, CliError, ExecutionObserver, ExecutionSummary, args::CliArgs, execute,
372 execute_with, should_attempt_azure_auth,
373 };
374
375 fn temp_output_dir(name: &str) -> PathBuf {
376 std::env::temp_dir().join(format!(
377 "httpgenerator-rust-cli-tests-{name}-{}",
378 SystemTime::now()
379 .duration_since(UNIX_EPOCH)
380 .unwrap()
381 .as_nanos()
382 ))
383 }
384
385 fn petstore_args(output_folder: PathBuf) -> CliArgs {
386 CliArgs {
387 open_api_path: Some(
388 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
389 .join("..")
390 .join("..")
391 .join("..")
392 .join("test")
393 .join("OpenAPI")
394 .join("v3.0")
395 .join("petstore.json")
396 .to_string_lossy()
397 .into_owned(),
398 ),
399 output_folder: output_folder.to_string_lossy().into_owned(),
400 ..CliArgs::default()
401 }
402 }
403
404 fn webhook31_args(output_folder: PathBuf) -> CliArgs {
405 CliArgs {
406 open_api_path: Some(
407 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
408 .join("..")
409 .join("..")
410 .join("..")
411 .join("test")
412 .join("OpenAPI")
413 .join("v3.1")
414 .join("webhook-example.json")
415 .to_string_lossy()
416 .into_owned(),
417 ),
418 output_folder: output_folder.to_string_lossy().into_owned(),
419 ..CliArgs::default()
420 }
421 }
422
423 fn non_oauth31_args(output_folder: PathBuf) -> CliArgs {
424 CliArgs {
425 open_api_path: Some(
426 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
427 .join("..")
428 .join("..")
429 .join("..")
430 .join("test")
431 .join("OpenAPI")
432 .join("v3.1")
433 .join("non-oauth-scopes.json")
434 .to_string_lossy()
435 .into_owned(),
436 ),
437 output_folder: output_folder.to_string_lossy().into_owned(),
438 ..CliArgs::default()
439 }
440 }
441
442 fn cleanup(summary: &ExecutionSummary) {
443 let _ = fs::remove_dir_all(&summary.output_folder);
444 }
445
446 #[test]
447 fn execute_writes_petstore_files() {
448 let output_folder = temp_output_dir("petstore");
449 let summary = execute(petstore_args(output_folder)).unwrap();
450
451 assert_eq!(summary.files.len(), 19);
452 assert!(
453 summary
454 .validation
455 .as_ref()
456 .is_some_and(|inspection| inspection.stats.path_item_count > 0)
457 );
458 assert_eq!(summary.azure_auth, AzureAuthStatus::NotRequested);
459 assert!(
460 summary
461 .files
462 .iter()
463 .any(|path| path.ends_with("PutUpdatePet.http"))
464 );
465 assert!(
466 summary
467 .files
468 .iter()
469 .any(|path| path.ends_with("GetLoginUser.http"))
470 );
471
472 cleanup(&summary);
473 }
474
475 #[derive(Default)]
476 struct RecordingObserver {
477 events: Vec<String>,
478 }
479
480 impl ExecutionObserver for RecordingObserver {
481 fn validation_started(&mut self) {
482 self.events.push("validation_started".to_string());
483 }
484
485 fn validation_succeeded(&mut self, inspection: &httpgenerator_openapi::OpenApiInspection) {
486 self.events.push(format!(
487 "validation_succeeded:{}",
488 inspection.specification_version
489 ));
490 }
491
492 fn file_writing_started(&mut self, file_count: usize) {
493 self.events
494 .push(format!("file_writing_started:{file_count}"));
495 }
496
497 fn files_written(&mut self, paths: &[PathBuf]) {
498 self.events.push(format!("files_written:{}", paths.len()));
499 }
500 }
501
502 #[test]
503 fn execute_notifies_observer_in_cli_lifecycle_order() {
504 let output_folder = temp_output_dir("observer-order");
505 let mut observer = RecordingObserver::default();
506 let summary = execute_with(
507 petstore_args(output_folder),
508 &mut observer,
509 |_tenant_id, _scope| Ok(None),
510 )
511 .unwrap();
512
513 assert_eq!(
514 observer.events,
515 vec![
516 "validation_started".to_string(),
517 "validation_succeeded:OpenAPI 3.0.x".to_string(),
518 "file_writing_started:19".to_string(),
519 "files_written:19".to_string(),
520 ]
521 );
522
523 cleanup(&summary);
524 }
525
526 #[test]
527 fn execute_respects_one_file_mode_and_custom_headers() {
528 let output_folder = temp_output_dir("onefile");
529 let summary = execute(CliArgs {
530 output_type: super::args::OutputTypeArg::OneFile,
531 generate_intellij_tests: true,
532 custom_headers: vec!["X-API-Key: test123".to_string()],
533 ..petstore_args(output_folder)
534 })
535 .unwrap();
536
537 assert_eq!(summary.files.len(), 1);
538 assert!(summary.validation.is_some());
539 assert_eq!(summary.azure_auth, AzureAuthStatus::NotRequested);
540 let content = fs::read_to_string(&summary.files[0]).unwrap();
541 assert!(content.contains("X-API-Key: test123"));
542 assert!(content.contains("> {%"));
543 assert!(content.contains("### Request: PUT /pet"));
544
545 cleanup(&summary);
546 }
547
548 #[test]
549 fn execute_rejects_openapi31_without_skip_validation() {
550 let output_folder = temp_output_dir("openapi31-validation");
551 let error = execute(webhook31_args(output_folder)).unwrap_err();
552
553 assert_eq!(
554 error,
555 CliError::UnsupportedValidationVersion {
556 version: httpgenerator_openapi::OpenApiSpecificationVersion::OpenApi31,
557 }
558 );
559 }
560
561 #[test]
562 fn execute_allows_openapi31_with_skip_validation() {
563 let output_folder = temp_output_dir("openapi31-skip");
564 let summary = execute(CliArgs {
565 skip_validation: true,
566 ..webhook31_args(output_folder)
567 })
568 .unwrap();
569
570 assert!(summary.validation.is_none());
571 assert_eq!(summary.azure_auth, AzureAuthStatus::NotRequested);
572 assert!(summary.files.is_empty());
573
574 cleanup(&summary);
575 }
576
577 #[test]
578 fn execute_allows_invalid_openapi31_with_skip_validation() {
579 let output_folder = temp_output_dir("openapi31-invalid-skip");
580 let summary = execute(CliArgs {
581 skip_validation: true,
582 ..non_oauth31_args(output_folder)
583 })
584 .unwrap();
585
586 assert!(summary.validation.is_none());
587 assert_eq!(summary.azure_auth, AzureAuthStatus::NotRequested);
588 assert_eq!(summary.files.len(), 1);
589 let content = fs::read_to_string(&summary.files[0]).unwrap();
590 assert!(content.contains("### Request: GET /users"));
591
592 cleanup(&summary);
593 }
594
595 #[test]
596 fn execute_uses_acquired_azure_token_as_authorization_header() {
597 let output_folder = temp_output_dir("azure-auth");
598 let mut observer = super::NoopExecutionObserver;
599 let summary = execute_with(
600 CliArgs {
601 azure_scope: Some("api://example/.default".to_string()),
602 azure_tenant_id: Some("tenant-id".to_string()),
603 ..petstore_args(output_folder)
604 },
605 &mut observer,
606 |tenant_id, scope| {
607 assert_eq!(tenant_id, Some("tenant-id"));
608 assert_eq!(scope, "api://example/.default");
609 Ok(Some("test-token".to_string()))
610 },
611 )
612 .unwrap();
613
614 assert_eq!(summary.azure_auth, AzureAuthStatus::Acquired);
615 let content = fs::read_to_string(&summary.files[0]).unwrap();
616 assert!(content.contains("@authorization = Bearer test-token"));
617 assert!(content.contains("Authorization: {{authorization}}"));
618
619 cleanup(&summary);
620 }
621
622 #[test]
623 fn execute_continues_when_azure_token_lookup_fails() {
624 let output_folder = temp_output_dir("azure-auth-failure");
625 let mut observer = super::NoopExecutionObserver;
626 let summary = execute_with(
627 CliArgs {
628 azure_scope: Some("api://example/.default".to_string()),
629 azure_tenant_id: Some("tenant-id".to_string()),
630 ..petstore_args(output_folder)
631 },
632 &mut observer,
633 |tenant_id, scope| {
634 assert_eq!(tenant_id, Some("tenant-id"));
635 assert_eq!(scope, "api://example/.default");
636 Err("Azure CLI credential failed: not logged in".to_string())
637 },
638 )
639 .unwrap();
640
641 assert_eq!(
642 summary.azure_auth,
643 AzureAuthStatus::Failed {
644 reason: "Azure CLI credential failed: not logged in".to_string(),
645 }
646 );
647 let content = fs::read_to_string(&summary.files[0]).unwrap();
648 assert!(!content.contains("@authorization ="));
649 assert!(!content.contains("Authorization: {{authorization}}"));
650
651 cleanup(&summary);
652 }
653
654 #[test]
655 fn execute_continues_when_azure_scope_is_missing() {
656 let output_folder = temp_output_dir("azure-auth-missing-scope");
657 let mut observer = super::NoopExecutionObserver;
658 let summary = execute_with(
659 CliArgs {
660 azure_tenant_id: Some("tenant-id".to_string()),
661 ..petstore_args(output_folder)
662 },
663 &mut observer,
664 |_tenant_id, _scope| panic!("token provider should not run without a scope"),
665 )
666 .unwrap();
667
668 assert_eq!(
669 summary.azure_auth,
670 AzureAuthStatus::Failed {
671 reason: "Azure Entra ID scope is required to acquire an authorization header."
672 .to_string(),
673 }
674 );
675
676 cleanup(&summary);
677 }
678
679 #[test]
680 fn should_attempt_azure_auth_only_when_scope_or_tenant_is_present_without_header() {
681 let mut args = CliArgs {
682 open_api_path: None,
683 output_folder: "./".to_string(),
684 no_logging: false,
685 skip_validation: false,
686 authorization_header: None,
687 authorization_header_from_environment_variable: false,
688 authorization_header_variable_name: "authorization".to_string(),
689 content_type: "application/json".to_string(),
690 base_url: None,
691 output_type: super::args::OutputTypeArg::OneRequestPerFile,
692 azure_scope: None,
693 azure_tenant_id: None,
694 timeout: 120,
695 generate_intellij_tests: false,
696 custom_headers: Vec::new(),
697 skip_headers: false,
698 };
699
700 assert!(!should_attempt_azure_auth(&args));
701
702 args.azure_scope = Some("api://example/.default".to_string());
703 assert!(should_attempt_azure_auth(&args));
704
705 args.authorization_header = Some("Bearer token".to_string());
706 assert!(!should_attempt_azure_auth(&args));
707 }
708}