openapi-nexus 0.2.1

OpenAPI 3.x multi-language code generator
Documentation
use std::collections::HashMap;
use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

use openapi_nexus::generators::java::okhttp::JavaOkhttpCodeGenerator;
use openapi_nexus::generators::kotlin::okhttp::KotlinOkhttpCodeGenerator;
use openapi_nexus::test_utils::{generate_files, read_fixture};

fn empty_config() -> toml::value::Table {
    toml::value::Table::new()
}

struct TempDir {
    path: PathBuf,
}

impl TempDir {
    fn new(name: &str) -> Self {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("system clock should be after UNIX_EPOCH")
            .as_nanos();
        let path = std::env::temp_dir().join(format!(
            "openapi-nexus-{name}-{}-{nanos}",
            std::process::id()
        ));
        fs::create_dir_all(&path).expect("temporary project directory should be created");
        Self { path }
    }
}

impl Drop for TempDir {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.path);
    }
}

struct LockDir {
    path: PathBuf,
}

impl LockDir {
    fn acquire(name: &str) -> Self {
        let path = std::env::temp_dir().join(format!("openapi-nexus-{name}.lock"));
        let deadline = Instant::now() + Duration::from_secs(300);
        loop {
            match fs::create_dir(&path) {
                Ok(()) => return Self { path },
                Err(err) if err.kind() == ErrorKind::AlreadyExists && Instant::now() < deadline => {
                    thread::sleep(Duration::from_millis(250));
                }
                Err(err) => panic!("failed to acquire {name} lock at {}: {err}", path.display()),
            }
        }
    }
}

impl Drop for LockDir {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.path);
    }
}

fn write_generated_project(files: &HashMap<String, String>, root: &Path) {
    for (relative_path, contents) in files {
        let path = root.join(relative_path);
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).expect("generated parent directory should be created");
        }
        fs::write(path, contents).expect("generated file should be written");
    }
}

const CHARSET_SMOKE_JAVA: &str = r#"
import com.example.sdk.apis.CreateResourceServerError;
import com.example.sdk.apis.CreateResourceException;
import java.nio.charset.StandardCharsets;
import okhttp3.Headers;

public final class CharsetSmoke {
    public static void main(String[] args) {
        byte[] raw = "{\"message\":\"caf\u00e9\",\"retryable\":true}".getBytes(StandardCharsets.ISO_8859_1);
        Headers headers = Headers.of(
            "Content-Type", "application/json; charset=iso-8859-1",
            "Retry-After", "120"
        );
        CreateResourceServerError detail = new CreateResourceServerError(503, headers, raw);
        String message = detail.body().getMessage();
        if (!"caf\u00e9".equals(message)) {
            throw new AssertionError("expected charset-aware message, got: " + message);
        }
        CreateResourceException error = new CreateResourceException(
            503, "Service Unavailable", message, headers, raw, detail
        );
        if (!"120".equals(error.getRetryAfterHeader())) {
            throw new AssertionError("expected Retry-After header");
        }
    }
}
"#;

const CHARSET_SMOKE_KOTLIN_JAVA: &str = r#"
import com.example.sdk.apis.CreateResourceException;
import com.example.sdk.apis.CreateResourceServerError;
import java.nio.charset.StandardCharsets;
import okhttp3.Headers;

public final class CharsetSmoke {
    public static void main(String[] args) {
        byte[] raw = "{\"message\":\"caf\u00e9\",\"retryable\":true}".getBytes(StandardCharsets.ISO_8859_1);
        Headers headers = Headers.of(
            "Content-Type", "application/json; charset=iso-8859-1",
            "Retry-After", "120"
        );
        CreateResourceServerError detail = new CreateResourceServerError(503, headers, raw);
        String message = detail.body().getMessage();
        if (!"caf\u00e9".equals(message)) {
            throw new AssertionError("expected charset-aware message, got: " + message);
        }
        CreateResourceException error = new CreateResourceException(
            detail, 503, "Service Unavailable", message, headers, raw
        );
        if (!"120".equals(error.retryAfterHeader())) {
            throw new AssertionError("expected Retry-After header");
        }
    }
}
"#;

fn add_java_charset_smoke(root: &Path) {
    let smoke_dir = root.join("smoke");
    fs::create_dir_all(&smoke_dir).expect("smoke source directory should be created");
    fs::write(smoke_dir.join("CharsetSmoke.java"), CHARSET_SMOKE_JAVA)
        .expect("Java smoke test should be written");

    let build_gradle = root.join("build.gradle");
    let mut build = fs::read_to_string(&build_gradle).expect("build.gradle should be readable");
    build.push_str(
        r#"

sourceSets {
    smoke {
        java {
            srcDirs("smoke")
        }
        compileClasspath += sourceSets.main.output + sourceSets.main.compileClasspath
        runtimeClasspath += output + compileClasspath
    }
}

tasks.register("charsetSmoke", JavaExec) {
    dependsOn("compileSmokeJava")
    classpath = sourceSets.smoke.runtimeClasspath
    mainClass = "CharsetSmoke"
}
"#,
    );
    fs::write(build_gradle, build).expect("build.gradle should be updated");
}

fn add_kotlin_charset_smoke(root: &Path) {
    let smoke_dir = root.join("smoke");
    fs::create_dir_all(&smoke_dir).expect("smoke source directory should be created");
    fs::write(
        smoke_dir.join("CharsetSmoke.java"),
        CHARSET_SMOKE_KOTLIN_JAVA,
    )
    .expect("Kotlin smoke test should be written");

    let build_gradle = root.join("build.gradle.kts");
    let mut build = fs::read_to_string(&build_gradle).expect("build.gradle.kts should be readable");
    build.push_str(
        r#"

sourceSets {
    create("smoke") {
        java.srcDirs("smoke")
        compileClasspath += sourceSets["main"].output + sourceSets["main"].compileClasspath
        runtimeClasspath += output + compileClasspath
    }
}

tasks.register<JavaExec>("charsetSmoke") {
    dependsOn("compileSmokeJava")
    classpath = sourceSets["smoke"].runtimeClasspath
    mainClass.set("CharsetSmoke")
}
"#,
    );
    fs::write(build_gradle, build).expect("build.gradle.kts should be updated");
}

fn gradle_charset_smoke(root: &Path) -> std::process::Output {
    let _lock = LockDir::acquire("gradle-charset-smoke");
    let mut cmd = Command::new("gradle");
    let homebrew_jdk21 =
        Path::new("/opt/homebrew/Cellar/openjdk@21/21.0.11/libexec/openjdk.jdk/Contents/Home");
    if homebrew_jdk21.exists() {
        cmd.env("JAVA_HOME", homebrew_jdk21);
    }
    cmd.arg("--no-daemon")
        .arg("--max-workers=1")
        .arg("--quiet")
        .arg("charsetSmoke")
        .env("GRADLE_OPTS", "-Dorg.gradle.daemon=false")
        .current_dir(root)
        .output()
        .expect("generated JVM charset smoke should run")
}

#[test]
fn java_error_detail_body_honors_response_charset() {
    let fixture = read_fixture("valid/typed-error-responses.yaml");
    let files = generate_files(&JavaOkhttpCodeGenerator::new(empty_config()), &fixture)
        .expect("typed error fixture should generate Java");
    let temp = TempDir::new("typed-http-error-java");
    write_generated_project(&files, &temp.path);
    add_java_charset_smoke(&temp.path);

    let output = gradle_charset_smoke(&temp.path);
    assert!(
        output.status.success(),
        "generated Java charset smoke failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}",
        output.status,
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
}

#[test]
fn kotlin_error_detail_body_honors_response_charset() {
    let fixture = read_fixture("valid/typed-error-responses.yaml");
    let files = generate_files(&KotlinOkhttpCodeGenerator::new(empty_config()), &fixture)
        .expect("typed error fixture should generate Kotlin");
    let temp = TempDir::new("typed-http-error-kotlin");
    write_generated_project(&files, &temp.path);
    add_kotlin_charset_smoke(&temp.path);

    let output = gradle_charset_smoke(&temp.path);
    assert!(
        output.status.success(),
        "generated Kotlin charset smoke failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}",
        output.status,
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
}