1//! Thin HTTP client for the control plane, used by the CLI and the daemon.23use anyhow::{bail, Context, Result};4use serde::de::DeserializeOwned;5use serde::Serialize;678pub struct ControlPlane {9 base: String,10 token: Option<String>,11 http: reqwest::Client,12}1314impl ControlPlane {15 pub fn new(base: &str, token: Option<String>) -> Self {16 Self {17 base: base.trim_end_matches('/').to_string(),18 token,19 http: reqwest::Client::new(),20 }21 }2223 fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {24 let builder = self.http.request(method, format!("{}{path}", self.base));25 match &self.token {26 Some(token) => builder.bearer_auth(token),27 None => builder,28 }29 }3031 pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {32 let response = self33 .request(reqwest::Method::GET, path)34 .send()35 .await36 .with_context(|| format!("GET {path}"))?;37 decode(response, path).await38 }3940 pub async fn get_query<Q: Serialize, T: DeserializeOwned>(41 &self,42 path: &str,43 query: &Q,44 ) -> Result<T> {45 let response = self46 .request(reqwest::Method::GET, path)47 .query(query)48 .send()49 .await50 .with_context(|| format!("GET {path}"))?;51 decode(response, path).await52 }5354 pub async fn post<B: Serialize, T: DeserializeOwned>(&self, path: &str, body: &B) -> Result<T> {55 let response = self56 .request(reqwest::Method::POST, path)57 .json(body)58 .send()59 .await60 .with_context(|| format!("POST {path}"))?;61 decode(response, path).await62 }6364 pub async fn put<B: Serialize, T: DeserializeOwned>(&self, path: &str, body: &B) -> Result<T> {65 let response = self66 .request(reqwest::Method::PUT, path)67 .json(body)68 .send()69 .await70 .with_context(|| format!("PUT {path}"))?;71 decode(response, path).await72 }7374 /// DELETE, where a 2xx with no body is the expected success.75 pub async fn delete(&self, path: &str) -> Result<()> {76 let response = self77 .request(reqwest::Method::DELETE, path)78 .send()79 .await80 .with_context(|| format!("DELETE {path}"))?;81 if !response.status().is_success() {82 bail!("{path} failed: {}", error_text(response).await);83 }84 Ok(())85 }86}8788async fn decode<T: DeserializeOwned>(response: reqwest::Response, path: &str) -> Result<T> {89 if !response.status().is_success() {90 bail!("{path} failed: {}", error_text(response).await);91 }92 response93 .json()94 .await95 .with_context(|| format!("parsing the response to {path}"))96}9798/// Surface the server's `error` field when there is one, so the CLI shows a real message99/// rather than a bare status code.100async fn error_text(response: reqwest::Response) -> String {101 let status = response.status();102 match response.json::<serde_json::Value>().await {103 Ok(value) => value104 .get("error")105 .and_then(|e| e.as_str())106 .map(|e| format!("{status}: {e}"))107 .unwrap_or_else(|| format!("{status}")),108 Err(_) => format!("{status}"),109 }110}