artur 0.2.0

Universal config-driven Rust HTTP gateway and package orchestrator
Documentation
use reqwest::StatusCode;
use serde_json::Value;
use std::{
    fs,
    net::TcpListener,
    path::{Path, PathBuf},
    process::{Child, Command, Stdio},
    time::Duration,
};
use tempfile::TempDir;

struct RunningArtur {
    child: Child,
    base_url: String,
    _temp_dir: TempDir,
}

impl Drop for RunningArtur {
    fn drop(&mut self) {
        let _ = self.child.kill();
        let _ = self.child.wait();
    }
}

#[tokio::test]
async fn cli_serves_js_npx_and_rust_processes_end_to_end() {
    require_command("node");
    require_command("npx");
    require_command("rustc");

    let temp_dir = TempDir::new().unwrap();
    let rust_helper = compile_rust_helper(temp_dir.path());
    let npx_dir = create_local_npx_helper(temp_dir.path());
    let port = unused_port();
    let config_path = write_e2e_config(temp_dir.path(), port, &rust_helper, &npx_dir);
    let server = spawn_artur(&config_path, port, temp_dir).await;
    let client = reqwest::Client::new();

    let hello: Value = client
        .get(format!("{}/v1/hello", server.base_url))
        .send()
        .await
        .unwrap()
        .error_for_status()
        .unwrap()
        .json_like()
        .await;
    assert_eq!(hello["ok"], true);

    let js: Value = client
        .post(format!(
            "{}/v1/process/js/alice?source=e2e",
            server.base_url
        ))
        .header("content-type", "application/json")
        .body(r#"{"message":"hello from js"}"#)
        .send()
        .await
        .unwrap()
        .error_for_status()
        .unwrap()
        .json_like()
        .await;
    assert_eq!(js["ok"], true);
    assert_eq!(js["json"]["runtime"], "node");
    assert_eq!(js["json"]["name"], "alice");
    assert_eq!(js["json"]["source"], "e2e");
    assert_eq!(js["json"]["body"]["message"], "hello from js");

    let npx: Value = client
        .post(format!("{}/v1/process/npx?value=package", server.base_url))
        .send()
        .await
        .unwrap()
        .error_for_status()
        .unwrap()
        .json_like()
        .await;
    assert_eq!(npx["ok"], true);
    assert_eq!(npx["json"]["runtime"], "npx");
    assert_eq!(npx["json"]["value"], "package");

    let rust: Value = client
        .post(format!("{}/v1/process/rust/42", server.base_url))
        .header("content-type", "application/json")
        .body(r#"{"language":"rust"}"#)
        .send()
        .await
        .unwrap()
        .error_for_status()
        .unwrap()
        .json_like()
        .await;
    assert_eq!(rust["ok"], true);
    assert_eq!(rust["json"]["runtime"], "rust");
    assert_eq!(rust["json"]["id"], "42");
    assert_eq!(rust["json"]["stdin"], r#"{"language":"rust"}"#);
}

async fn spawn_artur(config_path: &Path, port: u16, temp_dir: TempDir) -> RunningArtur {
    let mut child = Command::new(env!("CARGO_BIN_EXE_artur"))
        .arg("--config")
        .arg(config_path)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .unwrap();
    let base_url = format!("http://127.0.0.1:{port}");
    let client = reqwest::Client::new();
    for _ in 0..100 {
        if let Ok(Some(status)) = child.try_wait() {
            panic!("artur exited before becoming ready with status {status}");
        }
        if let Ok(response) = client.get(format!("{base_url}/healthz")).send().await
            && response.status() == StatusCode::OK
        {
            return RunningArtur {
                child,
                base_url,
                _temp_dir: temp_dir,
            };
        }
        tokio::time::sleep(Duration::from_millis(50)).await;
    }
    let _ = child.kill();
    panic!("artur did not become ready on port {port}");
}

fn write_e2e_config(temp_dir: &Path, port: u16, rust_helper: &Path, npx_dir: &Path) -> PathBuf {
    let config_path = temp_dir.join("e2e.toml");
    let js = r#"
const fs = require('fs');
const request = JSON.parse(fs.readFileSync(0, 'utf8'));
console.log(JSON.stringify({runtime:'node', name:process.argv[1], source:request.query.source, body:request.body_json}));
"#;
    let config = format!(
        r#"
version = 1

[artur.server]
bind = "127.0.0.1"
port = {port}
body_limit_bytes = 1048576

[[artur.endpoints]]
name = "hello"
method = "GET"
path = "/v1/hello"
action = "respond.static"

[artur.endpoints.response]
status = 200
body = {{ ok = true, service = "artur-e2e" }}

[[artur.endpoints]]
name = "js"
method = "POST"
path = "/v1/process/js/{{name}}"
action = "task.run"
task = "js_helper"

[[artur.tasks]]
name = "js_helper"
mode = "sync"
command = "node"
args = ["-e", "{js}", "{{{{param.name}}}}"]
timeout_ms = 10000
stdout_format = "json"

[artur.tasks.stdin]
type = "request_json"

[[artur.endpoints]]
name = "npx"
method = "POST"
path = "/v1/process/npx"
action = "task.run"
task = "npx_helper"

[[artur.tasks]]
name = "npx_helper"
mode = "sync"
command = "npx"
args = ["--no-install", "artur-npx-helper", "{{{{query.value}}}}"]
working_dir = "{npx_dir}"
timeout_ms = 10000
stdout_format = "json"

[[artur.endpoints]]
name = "rust"
method = "POST"
path = "/v1/process/rust/{{id}}"
action = "task.run"
task = "rust_helper"

[[artur.tasks]]
name = "rust_helper"
mode = "sync"
command = "{rust_helper}"
args = ["{{{{param.id}}}}"]
timeout_ms = 10000
stdout_format = "json"

[artur.tasks.stdin]
type = "body"
"#,
        js = toml_escape(js),
        npx_dir = path_for_toml(npx_dir),
        rust_helper = path_for_toml(rust_helper),
    );
    fs::write(&config_path, config).unwrap();
    config_path
}

fn create_local_npx_helper(temp_dir: &Path) -> PathBuf {
    let package_dir = temp_dir.join("npx-package");
    let bin_dir = package_dir.join("node_modules/.bin");
    fs::create_dir_all(&bin_dir).unwrap();
    fs::write(package_dir.join("package.json"), r#"{"private":true}"#).unwrap();
    let script = bin_dir.join("artur-npx-helper");
    fs::write(
        &script,
        "#!/usr/bin/env node\nconsole.log(JSON.stringify({runtime:'npx', value:process.argv[2]}));\n",
    )
    .unwrap();
    make_executable(&script);
    package_dir
}

fn compile_rust_helper(temp_dir: &Path) -> PathBuf {
    let source = temp_dir.join("rust_helper.rs");
    let binary = temp_dir.join(format!("rust-helper{}", std::env::consts::EXE_SUFFIX));
    fs::write(
        &source,
        r#"
use std::io::{self, Read};

fn main() {
    let id = std::env::args().nth(1).unwrap_or_default();
    let mut stdin = String::new();
    io::stdin().read_to_string(&mut stdin).unwrap();
    let escaped = stdin.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
    println!("{{\"runtime\":\"rust\",\"id\":\"{}\",\"stdin\":\"{}\"}}", id, escaped);
}
"#,
    )
    .unwrap();
    let status = Command::new("rustc")
        .arg(&source)
        .arg("-o")
        .arg(&binary)
        .status()
        .unwrap();
    assert!(status.success(), "failed to compile Rust e2e helper");
    binary
}

fn unused_port() -> u16 {
    TcpListener::bind("127.0.0.1:0")
        .unwrap()
        .local_addr()
        .unwrap()
        .port()
}

fn require_command(command: &str) {
    let status = Command::new(command)
        .arg("--version")
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .unwrap_or_else(|err| panic!("{command} is required for e2e tests: {err}"));
    assert!(status.success(), "{command} --version failed");
}

fn toml_escape(value: &str) -> String {
    value
        .replace('\\', "\\\\")
        .replace('"', "\\\"")
        .replace('\n', "\\n")
}

fn path_for_toml(path: &Path) -> String {
    toml_escape(&path.to_string_lossy())
}

fn make_executable(path: &Path) {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut permissions = fs::metadata(path).unwrap().permissions();
        permissions.set_mode(0o755);
        fs::set_permissions(path, permissions).unwrap();
    }
}

trait JsonLike {
    async fn json_like(self) -> Value;
}

impl JsonLike for reqwest::Response {
    async fn json_like(self) -> Value {
        let text = self.text().await.unwrap();
        serde_json::from_str(&text)
            .unwrap_or_else(|err| panic!("invalid JSON response {text:?}: {err}"))
    }
}