httpgenerator 1.1.0

Generate .http files from OpenAPI specifications
Documentation
use std::path::{Path, PathBuf};

use httpgenerator_core::OutputType;

mod runner;
use runner::DifferentialRunPlan;

pub use runner::{DotnetOracleRunner, RustCliRunner, execute_differential_plan};

const SMOKE_FIXTURE_NAMES: &[&str] = &[
    "petstore",
    "petstore-expanded",
    "petstore-minimal",
    "petstore-simple",
    "petstore-with-external-docs",
    "api-with-examples",
    "callback-example",
    "link-example",
    "uber",
    "uspto",
    "hubspot-events",
    "hubspot-webhooks",
    "non-oauth-scopes",
    "webhook-example",
];

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompatibilityScenario {
    pub name: String,
    pub input_path: PathBuf,
    pub output_type: OutputType,
    pub no_logging: bool,
    pub skip_validation: bool,
    pub authorization_header: Option<String>,
    pub authorization_header_from_environment_variable: bool,
    pub authorization_header_variable_name: String,
    pub content_type: String,
    pub base_url: Option<String>,
    pub generate_intellij_tests: bool,
    pub custom_headers: Vec<String>,
    pub skip_headers: bool,
}

impl CompatibilityScenario {
    pub fn cli_arguments(&self, output_dir: &Path) -> Vec<String> {
        let mut arguments = vec![
            self.input_path.to_string_lossy().into_owned(),
            "--output".to_string(),
            output_dir.to_string_lossy().into_owned(),
        ];

        if self.output_type != OutputType::OneRequestPerFile {
            arguments.push("--output-type".to_string());
            arguments.push(output_type_name(self.output_type).to_string());
        }

        if self.no_logging {
            arguments.push("--no-logging".to_string());
        }

        if self.skip_validation {
            arguments.push("--skip-validation".to_string());
        }

        if let Some(authorization_header) = &self.authorization_header {
            arguments.push("--authorization-header".to_string());
            arguments.push(authorization_header.clone());
        }

        if self.authorization_header_from_environment_variable {
            arguments.push("--load-authorization-header-from-environment".to_string());
            arguments.push("--authorization-header-variable-name".to_string());
            arguments.push(self.authorization_header_variable_name.clone());
        }

        if self.content_type != "application/json" {
            arguments.push("--content-type".to_string());
            arguments.push(self.content_type.clone());
        }

        if let Some(base_url) = &self.base_url {
            arguments.push("--base-url".to_string());
            arguments.push(base_url.clone());
        }

        if self.generate_intellij_tests {
            arguments.push("--generate-intellij-tests".to_string());
        }

        for custom_header in &self.custom_headers {
            arguments.push("--custom-header".to_string());
            arguments.push(custom_header.clone());
        }

        if self.skip_headers {
            arguments.push("--skip-headers".to_string());
        }

        arguments
    }

    pub fn dotnet_arguments(&self, output_dir: &Path) -> Vec<String> {
        self.cli_arguments(output_dir)
    }

    pub fn differential_plan(&self, artifacts_root: impl AsRef<Path>) -> DifferentialRunPlan {
        DifferentialRunPlan::for_scenario(artifacts_root, self.clone())
    }
}

pub fn local_smoke_scenarios(repo_root: &Path) -> Vec<CompatibilityScenario> {
    let mut scenarios = Vec::new();

    for version in ["v2.0", "v3.0", "v3.1"] {
        for format in ["json", "yaml"] {
            for fixture_name in SMOKE_FIXTURE_NAMES {
                let input_path = repo_root
                    .join("test")
                    .join("OpenAPI")
                    .join(version)
                    .join(format!("{fixture_name}.{format}"));

                if !input_path.is_file() {
                    continue;
                }

                let skip_validation = version == "v3.1";
                let mut base = CompatibilityScenario {
                    name: format!(
                        "{fixture_name}-{version}-{format}-{}",
                        output_type_slug(OutputType::OneRequestPerFile)
                    ),
                    input_path: input_path.clone(),
                    output_type: OutputType::OneRequestPerFile,
                    no_logging: true,
                    skip_validation,
                    authorization_header: None,
                    authorization_header_from_environment_variable: false,
                    authorization_header_variable_name: "authorization".to_string(),
                    content_type: "application/json".to_string(),
                    base_url: Some("https://api.example.io/".to_string()),
                    generate_intellij_tests: true,
                    custom_headers: vec!["X-Custom-Header: 1234".to_string()],
                    skip_headers: false,
                };

                scenarios.push(base.clone());

                base.output_type = OutputType::OneFile;
                base.name = format!(
                    "{fixture_name}-{version}-{format}-{}",
                    output_type_slug(base.output_type)
                );
                scenarios.push(base.clone());

                base.output_type = OutputType::OneFilePerTag;
                base.name = format!(
                    "{fixture_name}-{version}-{format}-{}",
                    output_type_slug(base.output_type)
                );
                scenarios.push(base.clone());

                if fixture_name == &"petstore" && !skip_validation {
                    scenarios.push(CompatibilityScenario {
                        name: format!("{fixture_name}-{version}-{format}-auth-header"),
                        input_path: input_path.clone(),
                        output_type: OutputType::OneFile,
                        no_logging: true,
                        skip_validation: false,
                        authorization_header: Some("Bearer test-token-123".to_string()),
                        authorization_header_from_environment_variable: false,
                        authorization_header_variable_name: "authorization".to_string(),
                        content_type: "application/json".to_string(),
                        base_url: None,
                        generate_intellij_tests: false,
                        custom_headers: Vec::new(),
                        skip_headers: false,
                    });

                    scenarios.push(CompatibilityScenario {
                        name: format!("{fixture_name}-{version}-{format}-auth-env"),
                        input_path: input_path.clone(),
                        output_type: OutputType::OneFile,
                        no_logging: true,
                        skip_validation: false,
                        authorization_header: None,
                        authorization_header_from_environment_variable: true,
                        authorization_header_variable_name: "my_token".to_string(),
                        content_type: "application/json".to_string(),
                        base_url: None,
                        generate_intellij_tests: false,
                        custom_headers: Vec::new(),
                        skip_headers: false,
                    });

                    scenarios.push(CompatibilityScenario {
                        name: format!("{fixture_name}-{version}-{format}-skip-headers"),
                        input_path: input_path.clone(),
                        output_type: OutputType::OneFile,
                        no_logging: true,
                        skip_validation: false,
                        authorization_header: None,
                        authorization_header_from_environment_variable: false,
                        authorization_header_variable_name: "authorization".to_string(),
                        content_type: "application/json".to_string(),
                        base_url: None,
                        generate_intellij_tests: false,
                        custom_headers: Vec::new(),
                        skip_headers: true,
                    });

                    scenarios.push(CompatibilityScenario {
                        name: format!("{fixture_name}-{version}-{format}-xml"),
                        input_path: input_path.clone(),
                        output_type: OutputType::OneFile,
                        no_logging: true,
                        skip_validation: false,
                        authorization_header: None,
                        authorization_header_from_environment_variable: false,
                        authorization_header_variable_name: "authorization".to_string(),
                        content_type: "application/xml".to_string(),
                        base_url: None,
                        generate_intellij_tests: false,
                        custom_headers: Vec::new(),
                        skip_headers: false,
                    });

                    scenarios.push(CompatibilityScenario {
                        name: format!("{fixture_name}-{version}-{format}-env-baseurl"),
                        input_path: input_path.clone(),
                        output_type: OutputType::OneFile,
                        no_logging: true,
                        skip_validation: false,
                        authorization_header: None,
                        authorization_header_from_environment_variable: false,
                        authorization_header_variable_name: "authorization".to_string(),
                        content_type: "application/json".to_string(),
                        base_url: Some("{{MY_BASE_URL}}".to_string()),
                        generate_intellij_tests: false,
                        custom_headers: Vec::new(),
                        skip_headers: false,
                    });
                }
            }
        }
    }

    scenarios
}

fn output_type_name(output_type: OutputType) -> &'static str {
    match output_type {
        OutputType::OneRequestPerFile => "OneRequestPerFile",
        OutputType::OneFile => "OneFile",
        OutputType::OneFilePerTag => "OneFilePerTag",
    }
}

fn output_type_slug(output_type: OutputType) -> &'static str {
    match output_type {
        OutputType::OneRequestPerFile => "one-request-per-file",
        OutputType::OneFile => "one-file",
        OutputType::OneFilePerTag => "one-file-per-tag",
    }
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use httpgenerator_core::OutputType;

    use super::local_smoke_scenarios;

    fn repo_root() -> PathBuf {
        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .and_then(|path| path.parent())
            .and_then(|path| path.parent())
            .expect("workspace root should exist")
            .to_path_buf()
    }

    #[test]
    fn includes_v31_scenarios_with_skip_validation() {
        let scenarios = local_smoke_scenarios(&repo_root());

        let scenario = scenarios
            .iter()
            .find(|scenario| scenario.name == "webhook-example-v3.1-json-one-request-per-file")
            .expect("expected webhook v3.1 json scenario");

        assert!(scenario.skip_validation);
        assert_eq!(scenario.output_type, OutputType::OneRequestPerFile);
        assert_eq!(scenario.custom_headers, vec!["X-Custom-Header: 1234"]);
        assert_eq!(
            scenario.base_url.as_deref(),
            Some("https://api.example.io/")
        );
        assert!(
            scenarios
                .iter()
                .any(|scenario| scenario.name == "non-oauth-scopes-v3.1-json-one-file")
        );
    }

    #[test]
    fn includes_petstore_variations_for_non_v31_specs() {
        let scenarios = local_smoke_scenarios(&repo_root());

        assert!(
            scenarios
                .iter()
                .any(|scenario| scenario.name == "petstore-v3.0-json-auth-header")
        );
        assert!(
            scenarios
                .iter()
                .any(|scenario| scenario.name == "petstore-v3.0-json-auth-env")
        );
        assert!(
            scenarios
                .iter()
                .any(|scenario| scenario.name == "petstore-v3.0-json-skip-headers")
        );
        assert!(
            scenarios
                .iter()
                .any(|scenario| scenario.name == "petstore-v3.0-json-xml")
        );
        assert!(
            scenarios
                .iter()
                .any(|scenario| scenario.name == "petstore-v3.0-json-env-baseurl")
        );
    }

    #[test]
    fn default_output_type_omits_explicit_output_flag() {
        let scenarios = local_smoke_scenarios(&repo_root());
        let scenario = scenarios
            .iter()
            .find(|scenario| scenario.name == "petstore-v2.0-json-one-request-per-file")
            .expect("expected petstore v2.0 json scenario");

        let arguments = scenario.dotnet_arguments(PathBuf::from("Generated").as_path());

        assert!(
            arguments
                .windows(2)
                .all(|window| window != ["--output-type", "OneRequestPerFile"])
        );
        assert!(arguments.contains(&"--no-logging".to_string()));
        assert!(arguments.contains(&"--generate-intellij-tests".to_string()));
    }
}