api_testing_core/grpc/
runner.rs1use 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}