Skip to main content

httpgenerator_cli/
lib.rs

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}