Skip to main content

cellos_ctl/
client.rs

1//! HTTP client wrapping reqwest for cellos-server.
2//!
3//! Per Feynman (CHATROOM Session 16): "cellctl is a thin client over cellos-server.
4//! Every command = exactly one API call." This module is that contract.
5//!
6//! All state queries hit the projector via HTTP. There is no client-side cache.
7
8use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
9use reqwest::{Method, StatusCode};
10use serde::de::DeserializeOwned;
11use serde::Serialize;
12
13use crate::config::Config;
14use crate::exit::{CtlError, CtlResult};
15
16/// User-Agent string sent to the server — helps the projector identify CLI traffic.
17const USER_AGENT: &str = concat!("cellctl/", env!("CARGO_PKG_VERSION"));
18
19#[derive(Clone)]
20pub struct CellosClient {
21    base: String,
22    http: reqwest::Client,
23}
24
25impl CellosClient {
26    pub fn new(cfg: &Config) -> CtlResult<Self> {
27        let base = cfg.effective_server();
28        // Normalize: strip trailing slash so we can confidently concatenate paths.
29        let base = base.trim_end_matches('/').to_string();
30
31        let mut headers = HeaderMap::new();
32        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
33        if let Some(tok) = cfg.effective_token() {
34            let v = HeaderValue::from_str(&format!("Bearer {tok}"))
35                .map_err(|e| CtlError::usage(format!("bad token: {e}")))?;
36            headers.insert(AUTHORIZATION, v);
37        }
38
39        let http = reqwest::Client::builder()
40            .user_agent(USER_AGENT)
41            .default_headers(headers)
42            .build()
43            .map_err(|e| CtlError::api(format!("init http client: {e}")))?;
44
45        Ok(Self { base, http })
46    }
47
48    pub fn base_url(&self) -> &str {
49        &self.base
50    }
51
52    fn url(&self, path: &str) -> String {
53        if path.starts_with('/') {
54            format!("{}{}", self.base, path)
55        } else {
56            format!("{}/{}", self.base, path)
57        }
58    }
59
60    /// GET <path> → T
61    pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> CtlResult<T> {
62        let resp = self.http.get(self.url(path)).send().await?;
63        decode_json(resp).await
64    }
65
66    /// POST <path> with JSON body → T
67    pub async fn post_json<B: Serialize, T: DeserializeOwned>(
68        &self,
69        path: &str,
70        body: &B,
71    ) -> CtlResult<T> {
72        let resp = self.http.post(self.url(path)).json(body).send().await?;
73        decode_json(resp).await
74    }
75
76    /// DELETE <path> — body ignored, only status checked.
77    pub async fn delete(&self, path: &str) -> CtlResult<()> {
78        let resp = self.http.delete(self.url(path)).send().await?;
79        check_status(resp).await.map(|_| ())
80    }
81
82    /// Stream Server-Sent-Events-ish JSON lines (newline-delimited) from a GET endpoint.
83    /// Used by `logs --follow` over HTTP chunked transfer.
84    pub async fn get_stream(&self, path: &str) -> CtlResult<reqwest::Response> {
85        let resp = self.http.get(self.url(path)).send().await?;
86        check_status(resp).await
87    }
88
89    /// Build a WebSocket URL aligned to the same base. http→ws, https→wss.
90    pub fn ws_url(&self, path: &str) -> CtlResult<String> {
91        let mut u = url::Url::parse(&self.url(path))
92            .map_err(|e| CtlError::usage(format!("bad url: {e}")))?;
93        match u.scheme() {
94            "http" => u
95                .set_scheme("ws")
96                .map_err(|_| CtlError::usage("set ws scheme"))?,
97            "https" => u
98                .set_scheme("wss")
99                .map_err(|_| CtlError::usage("set wss scheme"))?,
100            "ws" | "wss" => {}
101            other => return Err(CtlError::usage(format!("unsupported scheme: {other}"))),
102        }
103        Ok(u.to_string())
104    }
105
106    /// Auth header value for bolting onto a WebSocket request, if a token is configured.
107    ///
108    /// Currently unused — the WS path uses raw `connect_async(url)` and relies
109    /// on cookie / query-string auth on the server side. Kept here for the
110    /// Bearer-over-`Sec-WebSocket-Protocol` upgrade path described in
111    /// CHATROOM Session 16.
112    #[allow(dead_code)]
113    pub fn auth_header(&self) -> Option<String> {
114        self.http
115            .request(Method::GET, &self.base)
116            .build()
117            .ok()
118            .and_then(|req| req.headers().get(AUTHORIZATION).cloned())
119            .and_then(|v| v.to_str().ok().map(|s| s.to_string()))
120    }
121}
122
123async fn check_status(resp: reqwest::Response) -> CtlResult<reqwest::Response> {
124    let status = resp.status();
125    if status.is_success() {
126        return Ok(resp);
127    }
128    // Best-effort: include server-provided body in the error message.
129    let body = resp.text().await.unwrap_or_default();
130    let body_trim = body.trim();
131    let detail = if body_trim.is_empty() {
132        format!("server returned {status}")
133    } else {
134        format!("server returned {status}: {body_trim}")
135    };
136    Err(match status {
137        StatusCode::BAD_REQUEST | StatusCode::UNPROCESSABLE_ENTITY => CtlError::validation(detail),
138        _ => CtlError::api(detail),
139    })
140}
141
142async fn decode_json<T: DeserializeOwned>(resp: reqwest::Response) -> CtlResult<T> {
143    let resp = check_status(resp).await?;
144    let bytes = resp.bytes().await?;
145    serde_json::from_slice(&bytes).map_err(|e| CtlError::api(format!("decode json: {e}")))
146}