nils-api-testing-core 0.27.0

Library crate for nils-api-testing-core in the nils-cli workspace.
Documentation
use std::path::{Path, PathBuf};
use std::process::Command;

use anyhow::Context;
use nils_common::env as shared_env;

use crate::Result;
use crate::grpc::schema::GrpcRequestFile;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GrpcExecutedRequest {
    pub target: String,
    pub method: String,
    pub grpc_status: i32,
    pub response_body: Vec<u8>,
    pub stderr: String,
}

fn resolve_local_path(request_file: &Path, raw: &str) -> PathBuf {
    let p = Path::new(raw);
    if p.is_absolute() {
        p.to_path_buf()
    } else {
        request_file
            .parent()
            .unwrap_or_else(|| Path::new("."))
            .join(p)
    }
}

fn looks_like_authorization_metadata(metadata: &[(String, String)]) -> bool {
    metadata
        .iter()
        .any(|(k, _)| k.trim().eq_ignore_ascii_case("authorization"))
}

pub fn execute_grpc_request(
    request_file: &GrpcRequestFile,
    target: &str,
    bearer_token: Option<&str>,
) -> Result<GrpcExecutedRequest> {
    let target = target.trim();
    if target.is_empty() {
        anyhow::bail!("gRPC target URL/endpoint is empty");
    }

    let grpcurl_bin =
        shared_env::env_non_empty("GRPCURL_BIN").unwrap_or_else(|| "grpcurl".to_string());

    let mut cmd = Command::new(&grpcurl_bin);
    cmd.arg("-format").arg("json");

    if request_file.request.plaintext {
        cmd.arg("-plaintext");
    }

    if let Some(authority) = request_file.request.authority.as_deref() {
        cmd.arg("-authority").arg(authority);
    }
    if let Some(timeout) = request_file.request.timeout_seconds {
        cmd.arg("-max-time").arg(timeout.to_string());
    }
    for p in &request_file.request.import_paths {
        let path = resolve_local_path(&request_file.path, p);
        cmd.arg("-import-path").arg(path);
    }
    if let Some(proto) = request_file.request.proto.as_deref() {
        let path = resolve_local_path(&request_file.path, proto);
        cmd.arg("-proto").arg(path);
    }

    for (k, v) in &request_file.request.metadata {
        cmd.arg("-H").arg(format!("{k}: {v}"));
    }
    if let Some(token) = bearer_token
        && !looks_like_authorization_metadata(&request_file.request.metadata)
    {
        cmd.arg("-H").arg(format!("authorization: Bearer {token}"));
    }

    let body = serde_json::to_string(&request_file.request.body)
        .context("failed to serialize gRPC request body")?;
    cmd.arg("-d").arg(body);
    cmd.arg(target);
    cmd.arg(&request_file.request.method);

    let output = cmd
        .output()
        .with_context(|| format!("failed to run gRPC transport command '{}'", grpcurl_bin))?;

    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
    if !output.status.success() {
        let code = output.status.code().unwrap_or(1);
        let detail = stderr.trim();
        if detail.is_empty() {
            anyhow::bail!("gRPC request failed (grpcurl exit={code})");
        }
        anyhow::bail!("gRPC request failed (grpcurl exit={code}): {detail}");
    }

    Ok(GrpcExecutedRequest {
        target: target.to_string(),
        method: request_file.request.method.clone(),
        grpc_status: 0,
        response_body: output.stdout,
        stderr,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::grpc::schema::GrpcRequestFile;
    use nils_test_support::{EnvGuard, GlobalStateLock};
    use pretty_assertions::assert_eq;
    use tempfile::TempDir;

    #[test]
    fn grpc_runner_executes_mock_grpcurl_script() {
        let tmp = TempDir::new().unwrap();
        let script = tmp.path().join("grpcurl-mock.sh");
        std::fs::write(&script, "#!/bin/sh\necho '{\"ok\":true}'\nexit 0\n").unwrap();
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut perms = std::fs::metadata(&script).unwrap().permissions();
            perms.set_mode(0o755);
            std::fs::set_permissions(&script, perms).unwrap();
        }

        let req_path = tmp.path().join("health.grpc.json");
        std::fs::write(
            &req_path,
            serde_json::to_vec(&serde_json::json!({
                "method":"health.HealthService/Check",
                "body":{"ping":"pong"}
            }))
            .unwrap(),
        )
        .unwrap();
        let req = GrpcRequestFile::load(&req_path).unwrap();

        let lock = GlobalStateLock::new();
        let script_str = script.to_string_lossy();
        let _grpcurl_bin = EnvGuard::set(&lock, "GRPCURL_BIN", script_str.as_ref());
        let executed = execute_grpc_request(&req, "127.0.0.1:50051", None).unwrap();

        assert_eq!(executed.grpc_status, 0);
        assert_eq!(
            String::from_utf8_lossy(&executed.response_body).trim(),
            "{\"ok\":true}"
        );
    }

    #[test]
    fn grpc_runner_surfaces_non_zero_exit() {
        let tmp = TempDir::new().unwrap();
        let script = tmp.path().join("grpcurl-fail.sh");
        std::fs::write(&script, "#!/bin/sh\necho 'rpc error' 1>&2\nexit 7\n").unwrap();
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut perms = std::fs::metadata(&script).unwrap().permissions();
            perms.set_mode(0o755);
            std::fs::set_permissions(&script, perms).unwrap();
        }
        let req_path = tmp.path().join("health.grpc.json");
        std::fs::write(
            &req_path,
            serde_json::to_vec(&serde_json::json!({
                "method":"health.HealthService/Check",
                "body":{}
            }))
            .unwrap(),
        )
        .unwrap();
        let req = GrpcRequestFile::load(&req_path).unwrap();

        let lock = GlobalStateLock::new();
        let script_str = script.to_string_lossy();
        let _grpcurl_bin = EnvGuard::set(&lock, "GRPCURL_BIN", script_str.as_ref());
        let err = execute_grpc_request(&req, "127.0.0.1:50051", None).unwrap_err();

        let msg = format!("{err:#}");
        assert!(msg.contains("grpcurl exit=7"));
        assert!(msg.contains("rpc error"));
    }
}