Skip to main content

helm_sdk/
lib.rs

1//! HELM SDK — Rust client for the HELM kernel API.
2//! Minimal deps: reqwest + serde.
3
4use reqwest::blocking::Client;
5use std::time::Duration;
6
7pub mod client;
8pub mod types_gen;
9pub use types_gen::*;
10
11// ── Proto-generated types (available when compiled with `--features codegen`) ──
12#[cfg(feature = "codegen")]
13pub mod generated {
14    pub mod kernel {
15        include!("generated/helm.kernel.v1.rs");
16    }
17    pub mod authority {
18        include!("generated/helm.authority.v1.rs");
19    }
20    pub mod effects {
21        include!("generated/helm.effects.v1.rs");
22    }
23    pub mod intervention {
24        include!("generated/helm.intervention.v1.rs");
25    }
26    pub mod truth {
27        include!("generated/helm.truth.v1.rs");
28    }
29}
30
31/// Error returned by HELM API calls.
32#[derive(Debug)]
33pub struct HelmApiError {
34    pub status: u16,
35    pub message: String,
36    pub reason_code: ReasonCode,
37}
38
39impl std::fmt::Display for HelmApiError {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        write!(
42            f,
43            "HELM API {}: {} ({:?})",
44            self.status, self.message, self.reason_code
45        )
46    }
47}
48
49impl std::error::Error for HelmApiError {}
50
51/// Typed client for the HELM kernel API.
52pub struct HelmClient {
53    base_url: String,
54    client: Client,
55}
56
57impl HelmClient {
58    /// Create a new client.
59    pub fn new(base_url: &str) -> Self {
60        Self {
61            base_url: base_url.trim_end_matches('/').to_string(),
62            client: Client::builder()
63                .timeout(Duration::from_secs(30))
64                .build()
65                .expect("failed to build HTTP client"),
66        }
67    }
68
69    fn url(&self, path: &str) -> String {
70        format!("{}{}", self.base_url, path)
71    }
72
73    fn check(
74        &self,
75        resp: reqwest::blocking::Response,
76    ) -> Result<reqwest::blocking::Response, HelmApiError> {
77        if resp.status().is_success() {
78            return Ok(resp);
79        }
80        let status = resp.status().as_u16();
81        match resp.json::<HelmError>() {
82            Ok(e) => Err(HelmApiError {
83                status,
84                message: e.error.message,
85                reason_code: e.error.reason_code,
86            }),
87            Err(_) => Err(HelmApiError {
88                status,
89                message: "unknown error".into(),
90                reason_code: ReasonCode::ErrorInternal,
91            }),
92        }
93    }
94
95    /// POST /v1/chat/completions
96    pub fn chat_completions(
97        &self,
98        req: &ChatCompletionRequest,
99    ) -> Result<ChatCompletionResponse, HelmApiError> {
100        let resp = self
101            .client
102            .post(self.url("/v1/chat/completions"))
103            .json(req)
104            .send()
105            .map_err(|e| HelmApiError {
106                status: 0,
107                message: e.to_string(),
108                reason_code: ReasonCode::ErrorInternal,
109            })?;
110        let resp = self.check(resp)?;
111        resp.json().map_err(|e| HelmApiError {
112            status: 0,
113            message: e.to_string(),
114            reason_code: ReasonCode::ErrorInternal,
115        })
116    }
117
118    /// POST /api/v1/kernel/approve
119    pub fn approve_intent(&self, req: &ApprovalRequest) -> Result<Receipt, HelmApiError> {
120        let resp = self
121            .client
122            .post(self.url("/api/v1/kernel/approve"))
123            .json(req)
124            .send()
125            .map_err(|e| HelmApiError {
126                status: 0,
127                message: e.to_string(),
128                reason_code: ReasonCode::ErrorInternal,
129            })?;
130        let resp = self.check(resp)?;
131        resp.json().map_err(|e| HelmApiError {
132            status: 0,
133            message: e.to_string(),
134            reason_code: ReasonCode::ErrorInternal,
135        })
136    }
137
138    /// GET /api/v1/proofgraph/sessions
139    pub fn list_sessions(&self) -> Result<Vec<Session>, HelmApiError> {
140        let resp = self
141            .client
142            .get(self.url("/api/v1/proofgraph/sessions"))
143            .send()
144            .map_err(|e| HelmApiError {
145                status: 0,
146                message: e.to_string(),
147                reason_code: ReasonCode::ErrorInternal,
148            })?;
149        let resp = self.check(resp)?;
150        resp.json().map_err(|e| HelmApiError {
151            status: 0,
152            message: e.to_string(),
153            reason_code: ReasonCode::ErrorInternal,
154        })
155    }
156
157    /// GET /api/v1/proofgraph/sessions/{id}/receipts
158    pub fn get_receipts(&self, session_id: &str) -> Result<Vec<Receipt>, HelmApiError> {
159        let resp = self
160            .client
161            .get(self.url(&format!(
162                "/api/v1/proofgraph/sessions/{}/receipts",
163                session_id
164            )))
165            .send()
166            .map_err(|e| HelmApiError {
167                status: 0,
168                message: e.to_string(),
169                reason_code: ReasonCode::ErrorInternal,
170            })?;
171        let resp = self.check(resp)?;
172        resp.json().map_err(|e| HelmApiError {
173            status: 0,
174            message: e.to_string(),
175            reason_code: ReasonCode::ErrorInternal,
176        })
177    }
178
179    /// POST /api/v1/evidence/export — returns raw bytes
180    pub fn export_evidence(&self, session_id: Option<&str>) -> Result<Vec<u8>, HelmApiError> {
181        let body = serde_json::json!({
182            "session_id": session_id,
183            "format": "tar.gz"
184        });
185        let resp = self
186            .client
187            .post(self.url("/api/v1/evidence/export"))
188            .json(&body)
189            .send()
190            .map_err(|e| HelmApiError {
191                status: 0,
192                message: e.to_string(),
193                reason_code: ReasonCode::ErrorInternal,
194            })?;
195        let resp = self.check(resp)?;
196        resp.bytes()
197            .map(|b| b.to_vec())
198            .map_err(|e| HelmApiError {
199                status: 0,
200                message: e.to_string(),
201                reason_code: ReasonCode::ErrorInternal,
202            })
203    }
204
205    /// POST /api/v1/evidence/verify
206    pub fn verify_evidence(&self, bundle: &[u8]) -> Result<VerificationResult, HelmApiError> {
207        let form = reqwest::blocking::multipart::Form::new().part(
208            "bundle",
209            reqwest::blocking::multipart::Part::bytes(bundle.to_vec())
210                .file_name("pack.tar.gz")
211                .mime_str("application/octet-stream")
212                .unwrap(),
213        );
214        let resp = self
215            .client
216            .post(self.url("/api/v1/evidence/verify"))
217            .multipart(form)
218            .send()
219            .map_err(|e| HelmApiError {
220                status: 0,
221                message: e.to_string(),
222                reason_code: ReasonCode::ErrorInternal,
223            })?;
224        let resp = self.check(resp)?;
225        resp.json().map_err(|e| HelmApiError {
226            status: 0,
227            message: e.to_string(),
228            reason_code: ReasonCode::ErrorInternal,
229        })
230    }
231
232    /// POST /api/v1/replay/verify
233    pub fn replay_verify(&self, bundle: &[u8]) -> Result<VerificationResult, HelmApiError> {
234        let form = reqwest::blocking::multipart::Form::new().part(
235            "bundle",
236            reqwest::blocking::multipart::Part::bytes(bundle.to_vec())
237                .file_name("pack.tar.gz")
238                .mime_str("application/octet-stream")
239                .unwrap(),
240        );
241        let resp = self
242            .client
243            .post(self.url("/api/v1/replay/verify"))
244            .multipart(form)
245            .send()
246            .map_err(|e| HelmApiError {
247                status: 0,
248                message: e.to_string(),
249                reason_code: ReasonCode::ErrorInternal,
250            })?;
251        let resp = self.check(resp)?;
252        resp.json().map_err(|e| HelmApiError {
253            status: 0,
254            message: e.to_string(),
255            reason_code: ReasonCode::ErrorInternal,
256        })
257    }
258
259    /// GET /api/v1/proofgraph/receipts/{hash}
260    pub fn get_receipt(&self, receipt_hash: &str) -> Result<Receipt, HelmApiError> {
261        let resp = self
262            .client
263            .get(self.url(&format!(
264                "/api/v1/proofgraph/receipts/{}",
265                receipt_hash
266            )))
267            .send()
268            .map_err(|e| HelmApiError {
269                status: 0,
270                message: e.to_string(),
271                reason_code: ReasonCode::ErrorInternal,
272            })?;
273        let resp = self.check(resp)?;
274        resp.json().map_err(|e| HelmApiError {
275            status: 0,
276            message: e.to_string(),
277            reason_code: ReasonCode::ErrorInternal,
278        })
279    }
280
281    /// POST /api/v1/conformance/run
282    pub fn conformance_run(
283        &self,
284        req: &ConformanceRequest,
285    ) -> Result<ConformanceResult, HelmApiError> {
286        let resp = self
287            .client
288            .post(self.url("/api/v1/conformance/run"))
289            .json(req)
290            .send()
291            .map_err(|e| HelmApiError {
292                status: 0,
293                message: e.to_string(),
294                reason_code: ReasonCode::ErrorInternal,
295            })?;
296        let resp = self.check(resp)?;
297        resp.json().map_err(|e| HelmApiError {
298            status: 0,
299            message: e.to_string(),
300            reason_code: ReasonCode::ErrorInternal,
301        })
302    }
303
304    /// GET /api/v1/conformance/reports/{id}
305    pub fn get_conformance_report(
306        &self,
307        report_id: &str,
308    ) -> Result<ConformanceResult, HelmApiError> {
309        let resp = self
310            .client
311            .get(self.url(&format!(
312                "/api/v1/conformance/reports/{}",
313                report_id
314            )))
315            .send()
316            .map_err(|e| HelmApiError {
317                status: 0,
318                message: e.to_string(),
319                reason_code: ReasonCode::ErrorInternal,
320            })?;
321        let resp = self.check(resp)?;
322        resp.json().map_err(|e| HelmApiError {
323            status: 0,
324            message: e.to_string(),
325            reason_code: ReasonCode::ErrorInternal,
326        })
327    }
328
329    /// GET /healthz
330    pub fn health(&self) -> Result<serde_json::Value, HelmApiError> {
331        let resp = self
332            .client
333            .get(self.url("/healthz"))
334            .send()
335            .map_err(|e| HelmApiError {
336                status: 0,
337                message: e.to_string(),
338                reason_code: ReasonCode::ErrorInternal,
339            })?;
340        let resp = self.check(resp)?;
341        resp.json().map_err(|e| HelmApiError {
342            status: 0,
343            message: e.to_string(),
344            reason_code: ReasonCode::ErrorInternal,
345        })
346    }
347
348    /// GET /version
349    pub fn version(&self) -> Result<VersionInfo, HelmApiError> {
350        let resp = self
351            .client
352            .get(self.url("/version"))
353            .send()
354            .map_err(|e| HelmApiError {
355                status: 0,
356                message: e.to_string(),
357                reason_code: ReasonCode::ErrorInternal,
358            })?;
359        let resp = self.check(resp)?;
360        resp.json().map_err(|e| HelmApiError {
361            status: 0,
362            message: e.to_string(),
363            reason_code: ReasonCode::ErrorInternal,
364        })
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn test_client_creation() {
374        let _client = HelmClient::new("http://localhost:8080");
375    }
376
377    #[test]
378    fn test_reason_code_serde() {
379        let code = ReasonCode::DenyToolNotFound;
380        let json = serde_json::to_string(&code).unwrap();
381        assert_eq!(json, "\"DENY_TOOL_NOT_FOUND\"");
382    }
383}