api_testing_core/grpc/
runner.rs1use 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 unsafe { std::env::set_var("GRPCURL_BIN", &script) };
146 let executed = execute_grpc_request(&req, "127.0.0.1:50051", None).unwrap();
147 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 unsafe { std::env::set_var("GRPCURL_BIN", &script) };
183 let err = execute_grpc_request(&req, "127.0.0.1:50051", None).unwrap_err();
184 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}