Skip to main content

api_testing_core/grpc/
schema.rs

1use 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}