Skip to main content

api_testing_core/grpc/
runner.rs

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