1use anyhow::{Context, Result, anyhow};
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11#[derive(Clone)]
12pub struct RelayClient {
13 base_url: String,
14 client: reqwest::blocking::Client,
15}
16
17#[derive(Debug, Serialize, Deserialize)]
18pub struct AllocateResponse {
19 pub slot_id: String,
20 pub slot_token: String,
21}
22
23#[derive(Debug, Deserialize)]
24pub struct PostEventResponse {
25 pub event_id: Option<String>,
26 pub status: String,
27}
28
29impl RelayClient {
30 pub fn new(base_url: &str) -> Self {
31 let client = reqwest::blocking::Client::builder()
32 .timeout(std::time::Duration::from_secs(30))
33 .build()
34 .expect("reqwest client construction is infallible with default config");
35 Self {
36 base_url: base_url.trim_end_matches('/').to_string(),
37 client,
38 }
39 }
40
41 pub fn allocate_slot(&self, handle_hint: Option<&str>) -> Result<AllocateResponse> {
45 let body = serde_json::json!({"handle": handle_hint});
46 let resp = self
47 .client
48 .post(format!("{}/v1/slot/allocate", self.base_url))
49 .json(&body)
50 .send()
51 .with_context(|| format!("POST {}/v1/slot/allocate", self.base_url))?;
52 let status = resp.status();
53 if !status.is_success() {
54 let detail = resp.text().unwrap_or_default();
55 return Err(anyhow!("allocate failed: {status}: {detail}"));
56 }
57 Ok(resp.json()?)
58 }
59
60 pub fn post_event(
64 &self,
65 slot_id: &str,
66 slot_token: &str,
67 event: &Value,
68 ) -> Result<PostEventResponse> {
69 let body = serde_json::json!({"event": event});
70 let resp = self
71 .client
72 .post(format!("{}/v1/events/{slot_id}", self.base_url))
73 .bearer_auth(slot_token)
74 .json(&body)
75 .send()
76 .with_context(|| format!("POST {}/v1/events/{slot_id}", self.base_url))?;
77 let status = resp.status();
78 if !status.is_success() {
79 let detail = resp.text().unwrap_or_default();
80 return Err(anyhow!("post_event failed: {status}: {detail}"));
81 }
82 Ok(resp.json()?)
83 }
84
85 pub fn list_events(
88 &self,
89 slot_id: &str,
90 slot_token: &str,
91 since: Option<&str>,
92 limit: Option<usize>,
93 ) -> Result<Vec<Value>> {
94 let mut url = format!("{}/v1/events/{slot_id}", self.base_url);
95 let mut sep = '?';
96 if let Some(s) = since {
97 url.push(sep);
98 url.push_str(&format!("since={s}"));
99 sep = '&';
100 }
101 if let Some(n) = limit {
102 url.push(sep);
103 url.push_str(&format!("limit={n}"));
104 }
105 let resp = self
106 .client
107 .get(&url)
108 .bearer_auth(slot_token)
109 .send()
110 .with_context(|| format!("GET {url}"))?;
111 let status = resp.status();
112 if !status.is_success() {
113 let detail = resp.text().unwrap_or_default();
114 return Err(anyhow!("list_events failed: {status}: {detail}"));
115 }
116 Ok(resp.json()?)
117 }
118
119 pub fn slot_state(&self, slot_id: &str, slot_token: &str) -> Result<(usize, Option<u64>)> {
125 let url = format!("{}/v1/slot/{slot_id}/state", self.base_url);
126 let resp = match self.client.get(&url).bearer_auth(slot_token).send() {
127 Ok(r) => r,
128 Err(_) => return Ok((0, None)),
129 };
130 if !resp.status().is_success() {
131 return Ok((0, None));
132 }
133 let v: Value = resp.json().unwrap_or(Value::Null);
134 let count = v.get("event_count").and_then(Value::as_u64).unwrap_or(0) as usize;
135 let last = v.get("last_pull_at_unix").and_then(Value::as_u64);
136 Ok((count, last))
137 }
138
139 pub fn responder_health_set(
140 &self,
141 slot_id: &str,
142 slot_token: &str,
143 record: &Value,
144 ) -> Result<Value> {
145 let resp = self
146 .client
147 .post(format!(
148 "{}/v1/slot/{slot_id}/responder-health",
149 self.base_url
150 ))
151 .bearer_auth(slot_token)
152 .json(record)
153 .send()
154 .with_context(|| {
155 format!("POST {}/v1/slot/{slot_id}/responder-health", self.base_url)
156 })?;
157 let status = resp.status();
158 if !status.is_success() {
159 let detail = resp.text().unwrap_or_default();
160 return Err(anyhow!("responder_health_set failed: {status}: {detail}"));
161 }
162 Ok(resp.json()?)
163 }
164
165 pub fn responder_health_get(&self, slot_id: &str, slot_token: &str) -> Result<Value> {
166 let resp = self
167 .client
168 .get(format!("{}/v1/slot/{slot_id}/state", self.base_url))
169 .bearer_auth(slot_token)
170 .send()
171 .with_context(|| format!("GET {}/v1/slot/{slot_id}/state", self.base_url))?;
172 let status = resp.status();
173 if !status.is_success() {
174 let detail = resp.text().unwrap_or_default();
175 return Err(anyhow!("responder_health_get failed: {status}: {detail}"));
176 }
177 let state: Value = resp.json()?;
178 Ok(state
179 .get("responder_health")
180 .cloned()
181 .unwrap_or(Value::Null))
182 }
183
184 pub fn healthz(&self) -> Result<bool> {
185 let resp = self
186 .client
187 .get(format!("{}/healthz", self.base_url))
188 .send()?;
189 Ok(resp.status().is_success())
190 }
191
192 pub fn check_healthz(&self) -> anyhow::Result<()> {
197 match self.healthz() {
198 Ok(true) => Ok(()),
199 Ok(false) => anyhow::bail!(
200 "phyllis: silent line — {}/healthz returned non-200.\n\
201 the host is reachable but the relay isn't returning ok. test:\n \
202 curl -v {}/healthz",
203 self.base_url,
204 self.base_url
205 ),
206 Err(e) => anyhow::bail!(
207 "phyllis: silent line — couldn't reach {}/healthz: {e:#}.\n\
208 test reachability from this machine:\n curl -v {}/healthz\n\
209 if curl also fails, a sandbox / proxy / firewall is the usual cause.\n\
210 (OpenShell sandbox? run `curl -fsSL https://wireup.net/openshell-policy.sh | bash -s <sandbox-name>` on the host first.)",
211 self.base_url,
212 self.base_url
213 ),
214 }
215 }
216
217 pub fn pair_open(&self, code_hash: &str, msg_b64: &str, role: &str) -> Result<String> {
221 let body = serde_json::json!({"code_hash": code_hash, "msg": msg_b64, "role": role});
222 let resp = self
223 .client
224 .post(format!("{}/v1/pair", self.base_url))
225 .json(&body)
226 .send()?;
227 let status = resp.status();
228 if !status.is_success() {
229 let detail = resp.text().unwrap_or_default();
230 return Err(anyhow!("pair_open failed: {status}: {detail}"));
231 }
232 let v: Value = resp.json()?;
233 v.get("pair_id")
234 .and_then(Value::as_str)
235 .map(str::to_string)
236 .ok_or_else(|| anyhow!("pair_open response missing pair_id"))
237 }
238
239 pub fn pair_abandon(&self, code_hash: &str) -> Result<()> {
244 let body = serde_json::json!({"code_hash": code_hash});
245 let resp = self
246 .client
247 .post(format!("{}/v1/pair/abandon", self.base_url))
248 .json(&body)
249 .send()?;
250 let status = resp.status();
251 if !status.is_success() {
252 let detail = resp.text().unwrap_or_default();
253 return Err(anyhow!("pair_abandon failed: {status}: {detail}"));
254 }
255 Ok(())
256 }
257
258 pub fn pair_get(
260 &self,
261 pair_id: &str,
262 as_role: &str,
263 ) -> Result<(Option<String>, Option<String>)> {
264 let resp = self
265 .client
266 .get(format!(
267 "{}/v1/pair/{pair_id}?as_role={as_role}",
268 self.base_url
269 ))
270 .send()?;
271 let status = resp.status();
272 if !status.is_success() {
273 let detail = resp.text().unwrap_or_default();
274 return Err(anyhow!("pair_get failed: {status}: {detail}"));
275 }
276 let v: Value = resp.json()?;
277 let peer_msg = v
278 .get("peer_msg")
279 .and_then(Value::as_str)
280 .map(str::to_string);
281 let peer_bootstrap = v
282 .get("peer_bootstrap")
283 .and_then(Value::as_str)
284 .map(str::to_string);
285 Ok((peer_msg, peer_bootstrap))
286 }
287
288 pub fn pair_bootstrap(&self, pair_id: &str, role: &str, sealed_b64: &str) -> Result<()> {
290 let body = serde_json::json!({"role": role, "sealed": sealed_b64});
291 let resp = self
292 .client
293 .post(format!("{}/v1/pair/{pair_id}/bootstrap", self.base_url))
294 .json(&body)
295 .send()?;
296 if !resp.status().is_success() {
297 let s = resp.status();
298 let detail = resp.text().unwrap_or_default();
299 return Err(anyhow!("pair_bootstrap failed: {s}: {detail}"));
300 }
301 Ok(())
302 }
303
304 pub fn handle_claim(
307 &self,
308 nick: &str,
309 slot_id: &str,
310 slot_token: &str,
311 relay_url: Option<&str>,
312 card: &Value,
313 ) -> Result<Value> {
314 let body = serde_json::json!({
315 "nick": nick,
316 "slot_id": slot_id,
317 "relay_url": relay_url,
318 "card": card,
319 });
320 let resp = self
321 .client
322 .post(format!("{}/v1/handle/claim", self.base_url))
323 .bearer_auth(slot_token)
324 .json(&body)
325 .send()
326 .with_context(|| format!("POST {}/v1/handle/claim", self.base_url))?;
327 let status = resp.status();
328 if !status.is_success() {
329 let detail = resp.text().unwrap_or_default();
330 return Err(anyhow!("handle_claim failed: {status}: {detail}"));
331 }
332 Ok(resp.json()?)
333 }
334
335 pub fn handle_intro(&self, nick: &str, event: &Value) -> Result<Value> {
339 let body = serde_json::json!({"event": event});
340 let resp = self
341 .client
342 .post(format!("{}/v1/handle/intro/{nick}", self.base_url))
343 .json(&body)
344 .send()
345 .with_context(|| format!("POST {}/v1/handle/intro/{nick}", self.base_url))?;
346 let status = resp.status();
347 if !status.is_success() {
348 let detail = resp.text().unwrap_or_default();
349 return Err(anyhow!("handle_intro failed: {status}: {detail}"));
350 }
351 Ok(resp.json()?)
352 }
353
354 pub fn well_known_agent_card_a2a(&self, handle: &str) -> Result<Value> {
360 let resp = self
361 .client
362 .get(format!("{}/.well-known/agent-card.json", self.base_url))
363 .query(&[("handle", handle)])
364 .send()
365 .with_context(|| {
366 format!(
367 "GET {}/.well-known/agent-card.json?handle={handle}",
368 self.base_url
369 )
370 })?;
371 let status = resp.status();
372 if !status.is_success() {
373 let detail = resp.text().unwrap_or_default();
374 return Err(anyhow!(
375 "well_known_agent_card_a2a failed: {status}: {detail}"
376 ));
377 }
378 Ok(resp.json()?)
379 }
380
381 pub fn well_known_agent(&self, handle: &str) -> Result<Value> {
385 let resp = self
386 .client
387 .get(format!("{}/.well-known/wire/agent", self.base_url))
388 .query(&[("handle", handle)])
389 .send()
390 .with_context(|| {
391 format!(
392 "GET {}/.well-known/wire/agent?handle={handle}",
393 self.base_url
394 )
395 })?;
396 let status = resp.status();
397 if !status.is_success() {
398 let detail = resp.text().unwrap_or_default();
399 return Err(anyhow!("well_known_agent failed: {status}: {detail}"));
400 }
401 Ok(resp.json()?)
402 }
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 #[test]
410 fn url_normalization_trims_trailing_slash() {
411 let c = RelayClient::new("http://example.com/");
412 assert_eq!(c.base_url, "http://example.com");
413 let c = RelayClient::new("http://example.com");
414 assert_eq!(c.base_url, "http://example.com");
415 }
416}