api_testing_core/grpc/
schema.rs1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use anyhow::Context;
5
6use crate::Result;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct GrpcExpect {
10 pub status: Option<i32>,
11 pub jq: Option<String>,
12}
13
14#[derive(Debug, Clone, PartialEq)]
15pub struct GrpcRequest {
16 pub method: String,
17 pub body: serde_json::Value,
18 pub metadata: Vec<(String, String)>,
19 pub proto: Option<String>,
20 pub import_paths: Vec<String>,
21 pub plaintext: bool,
22 pub authority: Option<String>,
23 pub timeout_seconds: Option<u64>,
24 pub expect: Option<GrpcExpect>,
25 pub raw: serde_json::Value,
26}
27
28#[derive(Debug, Clone, PartialEq)]
29pub struct GrpcRequestFile {
30 pub path: PathBuf,
31 pub request: GrpcRequest,
32}
33
34impl GrpcRequestFile {
35 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
36 let path = path.as_ref();
37 let bytes = std::fs::read(path)
38 .with_context(|| format!("read gRPC request file: {}", path.display()))?;
39 let raw: serde_json::Value = serde_json::from_slice(&bytes).map_err(|_| {
40 anyhow::anyhow!("gRPC request file is not valid JSON: {}", path.display())
41 })?;
42 let request = parse_grpc_request_json(raw)?;
43 Ok(Self {
44 path: std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()),
45 request,
46 })
47 }
48}
49
50fn scalar_to_string(value: &serde_json::Value) -> Result<String> {
51 match value {
52 serde_json::Value::String(s) => Ok(s.clone()),
53 serde_json::Value::Number(n) => Ok(n.to_string()),
54 serde_json::Value::Bool(b) => Ok(b.to_string()),
55 serde_json::Value::Null => Ok(String::new()),
56 serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
57 anyhow::bail!("metadata values must be scalar")
58 }
59 }
60}
61
62fn parse_status(raw: &serde_json::Value) -> Result<Option<i32>> {
63 match raw {
64 serde_json::Value::Null => Ok(None),
65 serde_json::Value::Number(n) => {
66 let Some(i) = n.as_i64() else {
67 anyhow::bail!("expect.status must be an integer");
68 };
69 Ok(Some(i.try_into().unwrap_or(i32::MAX)))
70 }
71 serde_json::Value::String(s) => {
72 let s = s.trim();
73 if s.is_empty() {
74 return Ok(None);
75 }
76 let parsed: i32 = s
77 .parse()
78 .with_context(|| format!("expect.status is not an integer: {s}"))?;
79 Ok(Some(parsed))
80 }
81 _ => anyhow::bail!("expect.status must be an integer"),
82 }
83}
84
85pub fn parse_grpc_request_json(raw: serde_json::Value) -> Result<GrpcRequest> {
86 let obj = raw
87 .as_object()
88 .context("gRPC request file must be a JSON object")?;
89
90 let method = obj
91 .get("method")
92 .or_else(|| obj.get("rpc"))
93 .and_then(|v| v.as_str())
94 .unwrap_or_default()
95 .trim()
96 .to_string();
97 if method.is_empty() {
98 anyhow::bail!("gRPC request is missing required field: method");
99 }
100 if !method.contains('/') {
101 anyhow::bail!("Invalid gRPC method (expected service/method): {method}");
102 }
103
104 let body = obj
105 .get("body")
106 .cloned()
107 .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
108 let body = if body.is_null() {
109 serde_json::Value::Object(serde_json::Map::new())
110 } else {
111 body
112 };
113 if !matches!(body, serde_json::Value::Object(_)) {
114 anyhow::bail!("gRPC request .body must be a JSON object");
115 }
116
117 let mut metadata: Vec<(String, String)> = Vec::new();
118 if let Some(v) = obj.get("metadata")
119 && !v.is_null()
120 {
121 let m = v
122 .as_object()
123 .context("gRPC request .metadata must be an object")?;
124 let mut sorted: BTreeMap<String, String> = BTreeMap::new();
125 for (k, raw_v) in m {
126 let key = k.trim();
127 if key.is_empty() {
128 continue;
129 }
130 let value = scalar_to_string(raw_v)?;
131 if !value.trim().is_empty() {
132 sorted.insert(key.to_string(), value);
133 }
134 }
135 metadata.extend(sorted);
136 }
137
138 let proto = obj
139 .get("proto")
140 .and_then(|v| v.as_str())
141 .map(|s| s.trim().to_string())
142 .filter(|s| !s.is_empty());
143
144 let import_paths = match obj.get("importPaths") {
145 None | Some(serde_json::Value::Null) => Vec::new(),
146 Some(serde_json::Value::Array(arr)) => arr
147 .iter()
148 .filter_map(|v| v.as_str())
149 .map(str::trim)
150 .filter(|s| !s.is_empty())
151 .map(ToString::to_string)
152 .collect(),
153 Some(_) => anyhow::bail!("gRPC request .importPaths must be an array"),
154 };
155
156 let plaintext = match obj.get("plaintext") {
157 None | Some(serde_json::Value::Null) => true,
158 Some(serde_json::Value::Bool(v)) => *v,
159 Some(_) => anyhow::bail!("gRPC request .plaintext must be boolean"),
160 };
161
162 let authority = obj
163 .get("authority")
164 .and_then(|v| v.as_str())
165 .map(|s| s.trim().to_string())
166 .filter(|s| !s.is_empty());
167
168 let timeout_seconds =
169 match obj.get("timeoutSeconds") {
170 None | Some(serde_json::Value::Null) => None,
171 Some(serde_json::Value::Number(n)) => n.as_u64(),
172 Some(serde_json::Value::String(s)) => {
173 let s = s.trim();
174 if s.is_empty() {
175 None
176 } else {
177 Some(s.parse::<u64>().with_context(|| {
178 format!("timeoutSeconds is not a positive integer: {s}")
179 })?)
180 }
181 }
182 Some(_) => anyhow::bail!("gRPC request .timeoutSeconds must be integer"),
183 };
184
185 let expect = match obj.get("expect") {
186 None | Some(serde_json::Value::Null) => None,
187 Some(v) => {
188 let e = v
189 .as_object()
190 .context("gRPC request .expect must be an object")?;
191 let status = parse_status(e.get("status").unwrap_or(&serde_json::Value::Null))?;
192 let jq = e
193 .get("jq")
194 .and_then(|v| v.as_str())
195 .map(str::trim)
196 .filter(|s| !s.is_empty())
197 .map(ToString::to_string);
198 Some(GrpcExpect { status, jq })
199 }
200 };
201
202 Ok(GrpcRequest {
203 method,
204 body,
205 metadata,
206 proto,
207 import_paths,
208 plaintext,
209 authority,
210 timeout_seconds,
211 expect,
212 raw,
213 })
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use pretty_assertions::assert_eq;
220 use tempfile::TempDir;
221
222 #[test]
223 fn grpc_schema_parses_minimal_request() {
224 let req = parse_grpc_request_json(serde_json::json!({
225 "method": "health.HealthService/Check",
226 "body": {}
227 }))
228 .unwrap();
229
230 assert_eq!(req.method, "health.HealthService/Check");
231 assert_eq!(req.body, serde_json::json!({}));
232 assert!(req.metadata.is_empty());
233 assert!(req.plaintext);
234 }
235
236 #[test]
237 fn grpc_schema_rejects_missing_method() {
238 let err = parse_grpc_request_json(serde_json::json!({"body":{}})).unwrap_err();
239 assert!(format!("{err:#}").contains("missing required field: method"));
240 }
241
242 #[test]
243 fn grpc_schema_load_reads_file() {
244 let tmp = TempDir::new().unwrap();
245 let path = tmp.path().join("health.grpc.json");
246 std::fs::write(
247 &path,
248 serde_json::to_vec_pretty(&serde_json::json!({
249 "method": "health.HealthService/Check",
250 "body": {"ping":"pong"},
251 "metadata": {"x-trace-id":"abc"},
252 "expect": {"status": 0, "jq": ".ok == true"}
253 }))
254 .unwrap(),
255 )
256 .unwrap();
257
258 let loaded = GrpcRequestFile::load(&path).unwrap();
259 assert_eq!(loaded.request.method, "health.HealthService/Check");
260 assert_eq!(loaded.request.metadata.len(), 1);
261 assert_eq!(loaded.request.expect.as_ref().unwrap().status, Some(0));
262 }
263}