Skip to main content

api_testing_core/grpc/
runner.rs

1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4use anyhow::Context;
5use nils_common::env as shared_env;
6
7use crate::Result;
8use crate::grpc::schema::GrpcRequestFile;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct GrpcExecutedRequest {
12    pub target: String,
13    pub method: String,
14    pub grpc_status: i32,
15    pub response_body: Vec<u8>,
16    pub stderr: String,
17}
18
19fn resolve_local_path(request_file: &Path, raw: &str) -> PathBuf {
20    let p = Path::new(raw);
21    if p.is_absolute() {
22        p.to_path_buf()
23    } else {
24        request_file
25            .parent()
26            .unwrap_or_else(|| Path::new("."))
27            .join(p)
28    }
29}
30
31fn looks_like_authorization_metadata(metadata: &[(String, String)]) -> bool {
32    metadata
33        .iter()
34        .any(|(k, _)| k.trim().eq_ignore_ascii_case("authorization"))
35}
36
37pub fn execute_grpc_request(
38    request_file: &GrpcRequestFile,
39    target: &str,
40    bearer_token: Option<&str>,
41) -> Result<GrpcExecutedRequest> {
42    let target = target.trim();
43    if target.is_empty() {
44        anyhow::bail!("gRPC target URL/endpoint is empty");
45    }
46
47    let grpcurl_bin =
48        shared_env::env_non_empty("GRPCURL_BIN").unwrap_or_else(|| "grpcurl".to_string());
49
50    let mut cmd = Command::new(&grpcurl_bin);
51    cmd.arg("-format").arg("json");
52
53    if request_file.request.plaintext {
54        cmd.arg("-plaintext");
55    }
56
57    if let Some(authority) = request_file.request.authority.as_deref() {
58        cmd.arg("-authority").arg(authority);
59    }
60    if let Some(timeout) = request_file.request.timeout_seconds {
61        cmd.arg("-max-time").arg(timeout.to_string());
62    }
63    for p in &request_file.request.import_paths {
64        let path = resolve_local_path(&request_file.path, p);
65        cmd.arg("-import-path").arg(path);
66    }
67    if let Some(proto) = request_file.request.proto.as_deref() {
68        let path = resolve_local_path(&request_file.path, proto);
69        cmd.arg("-proto").arg(path);
70    }
71
72    for (k, v) in &request_file.request.metadata {
73        cmd.arg("-H").arg(format!("{k}: {v}"));
74    }
75    if let Some(token) = bearer_token
76        && !looks_like_authorization_metadata(&request_file.request.metadata)
77    {
78        cmd.arg("-H").arg(format!("authorization: Bearer {token}"));
79    }
80
81    let body = serde_json::to_string(&request_file.request.body)
82        .context("failed to serialize gRPC request body")?;
83    cmd.arg("-d").arg(body);
84    cmd.arg(target);
85    cmd.arg(&request_file.request.method);
86
87    let output = cmd
88        .output()
89        .with_context(|| format!("failed to run gRPC transport command '{}'", grpcurl_bin))?;
90
91    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
92    if !output.status.success() {
93        let code = output.status.code().unwrap_or(1);
94        let detail = stderr.trim();
95        if detail.is_empty() {
96            anyhow::bail!("gRPC request failed (grpcurl exit={code})");
97        }
98        anyhow::bail!("gRPC request failed (grpcurl exit={code}): {detail}");
99    }
100
101    Ok(GrpcExecutedRequest {
102        target: target.to_string(),
103        method: request_file.request.method.clone(),
104        grpc_status: 0,
105        response_body: output.stdout,
106        stderr,
107    })
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::grpc::schema::GrpcRequestFile;
114    use nils_test_support::{EnvGuard, GlobalStateLock};
115    use pretty_assertions::assert_eq;
116    use tempfile::TempDir;
117
118    #[test]
119    fn grpc_runner_executes_mock_grpcurl_script() {
120        let tmp = TempDir::new().unwrap();
121        let script = tmp.path().join("grpcurl-mock.sh");
122        std::fs::write(&script, "#!/bin/sh\necho '{\"ok\":true}'\nexit 0\n").unwrap();
123        #[cfg(unix)]
124        {
125            use std::os::unix::fs::PermissionsExt;
126            let mut perms = std::fs::metadata(&script).unwrap().permissions();
127            perms.set_mode(0o755);
128            std::fs::set_permissions(&script, perms).unwrap();
129        }
130
131        let req_path = tmp.path().join("health.grpc.json");
132        std::fs::write(
133            &req_path,
134            serde_json::to_vec(&serde_json::json!({
135                "method":"health.HealthService/Check",
136                "body":{"ping":"pong"}
137            }))
138            .unwrap(),
139        )
140        .unwrap();
141        let req = GrpcRequestFile::load(&req_path).unwrap();
142
143        let lock = GlobalStateLock::new();
144        let script_str = script.to_string_lossy();
145        let _grpcurl_bin = EnvGuard::set(&lock, "GRPCURL_BIN", script_str.as_ref());
146        let executed = execute_grpc_request(&req, "127.0.0.1:50051", None).unwrap();
147
148        assert_eq!(executed.grpc_status, 0);
149        assert_eq!(
150            String::from_utf8_lossy(&executed.response_body).trim(),
151            "{\"ok\":true}"
152        );
153    }
154
155    #[test]
156    fn grpc_runner_surfaces_non_zero_exit() {
157        let tmp = TempDir::new().unwrap();
158        let script = tmp.path().join("grpcurl-fail.sh");
159        std::fs::write(&script, "#!/bin/sh\necho 'rpc error' 1>&2\nexit 7\n").unwrap();
160        #[cfg(unix)]
161        {
162            use std::os::unix::fs::PermissionsExt;
163            let mut perms = std::fs::metadata(&script).unwrap().permissions();
164            perms.set_mode(0o755);
165            std::fs::set_permissions(&script, perms).unwrap();
166        }
167        let req_path = tmp.path().join("health.grpc.json");
168        std::fs::write(
169            &req_path,
170            serde_json::to_vec(&serde_json::json!({
171                "method":"health.HealthService/Check",
172                "body":{}
173            }))
174            .unwrap(),
175        )
176        .unwrap();
177        let req = GrpcRequestFile::load(&req_path).unwrap();
178
179        let lock = GlobalStateLock::new();
180        let script_str = script.to_string_lossy();
181        let _grpcurl_bin = EnvGuard::set(&lock, "GRPCURL_BIN", script_str.as_ref());
182        let err = execute_grpc_request(&req, "127.0.0.1:50051", None).unwrap_err();
183
184        let msg = format!("{err:#}");
185        assert!(msg.contains("grpcurl exit=7"));
186        assert!(msg.contains("rpc error"));
187    }
188}