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::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/// Build the right `/v1/formations/...` path for a user-supplied
20/// identifier. CTL-002 (E2E report): the server's `/v1/formations/{id}`
21/// route uses `Path<Uuid>` and rejects names with HTTP 400. We detect
22/// the shape locally and route to the parallel `/v1/formations/by-name/{name}`
23/// route when the input is not a UUID.
24///
25/// Detection is `Uuid::parse_str(input).is_ok()` — UUID-shaped strings
26/// always go to the UUID route (preserves existing operator workflows),
27/// everything else routes to by-name. We URL-encode the name so an
28/// operator-supplied identifier containing `/` or other reserved
29/// characters does not silently truncate or break path parsing.
30pub fn formation_path(ident: &str) -> String {
31    if uuid::Uuid::parse_str(ident).is_ok() {
32        format!("/v1/formations/{ident}")
33    } else {
34        format!(
35            "/v1/formations/by-name/{}",
36            url::form_urlencoded::byte_serialize(ident.as_bytes()).collect::<String>()
37        )
38    }
39}
40
41#[cfg(test)]
42mod path_tests {
43    use super::formation_path;
44
45    #[test]
46    fn uuid_input_uses_uuid_route() {
47        // Canonical hyphenated v4.
48        let id = "550e8400-e29b-41d4-a716-446655440000";
49        assert_eq!(formation_path(id), format!("/v1/formations/{id}"));
50    }
51
52    #[test]
53    fn simple_name_uses_by_name_route() {
54        assert_eq!(formation_path("demo"), "/v1/formations/by-name/demo");
55    }
56
57    #[test]
58    fn name_with_reserved_chars_is_url_encoded() {
59        // `/` in a name MUST NOT escape the path segment.
60        let got = formation_path("ns/team");
61        assert!(
62            got.starts_with("/v1/formations/by-name/") && !got.contains("ns/team"),
63            "expected encoded segment, got {got}"
64        );
65        // Round-trip via percent decoder to confirm semantic preservation.
66        let tail = got.trim_start_matches("/v1/formations/by-name/");
67        let decoded: String = url::form_urlencoded::parse(format!("k={tail}").as_bytes())
68            .next()
69            .map(|(_, v)| v.into_owned())
70            .unwrap_or_default();
71        assert_eq!(decoded, "ns/team");
72    }
73
74    #[test]
75    fn empty_string_routes_to_by_name() {
76        // Empty is not a UUID; the server will surface a 404 (or a
77        // route-level miss). Either way, cellctl does not silently
78        // mis-route to the UUID path.
79        assert_eq!(formation_path(""), "/v1/formations/by-name/");
80    }
81
82    #[test]
83    fn uppercase_uuid_input_uses_uuid_route() {
84        // RFC 4122 §3 explicitly allows uppercase hex; `Uuid::parse_str`
85        // accepts it. The server's `Path<Uuid>` extractor does too.
86        let id = "550E8400-E29B-41D4-A716-446655440000";
87        assert_eq!(formation_path(id), format!("/v1/formations/{id}"));
88    }
89
90    #[test]
91    fn near_uuid_shaped_name_does_not_collide() {
92        // A 32-char hex string without hyphens parses as a UUID per
93        // RFC 4122 §3 "simple" form — that is intentional and the
94        // server's Uuid extractor accepts it. This pin documents the
95        // boundary: if an operator ever names a formation as 32 hex
96        // chars, cellctl WILL route it to the UUID path. That is the
97        // known ambiguity Option B accepts (Option C would have made
98        // it worse). The test exists so the choice is auditable.
99        let s = "550e8400e29b41d4a716446655440000";
100        assert_eq!(formation_path(s), format!("/v1/formations/{s}"));
101    }
102}
103
104#[derive(Clone)]
105pub struct CellosClient {
106    base: String,
107    http: reqwest::Client,
108    /// Raw bearer token if configured. Held separately so paths that
109    /// don't go through reqwest (the WebSocket upgrade in
110    /// `cmd::events`) can still authenticate without parsing it back
111    /// out of `default_headers`. EVT-002 (E2E report): cellctl's WS
112    /// upgrade needs to set `Authorization: Bearer <token>` on the
113    /// `http::Request`; before this field it had no clean way to
114    /// reach the configured token.
115    bearer: Option<String>,
116}
117
118impl CellosClient {
119    pub fn new(cfg: &Config) -> CtlResult<Self> {
120        let base = cfg.effective_server();
121        // Normalize: strip trailing slash so we can confidently concatenate paths.
122        let base = base.trim_end_matches('/').to_string();
123
124        let bearer = cfg.effective_token();
125        let mut headers = HeaderMap::new();
126        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
127        if let Some(tok) = bearer.as_deref() {
128            let v = HeaderValue::from_str(&format!("Bearer {tok}"))
129                .map_err(|e| CtlError::usage(format!("bad token: {e}")))?;
130            headers.insert(AUTHORIZATION, v);
131        }
132
133        let http = reqwest::Client::builder()
134            .user_agent(USER_AGENT)
135            .default_headers(headers)
136            .build()
137            .map_err(|e| CtlError::api(format!("init http client: {e}")))?;
138
139        Ok(Self { base, http, bearer })
140    }
141
142    pub fn base_url(&self) -> &str {
143        &self.base
144    }
145
146    /// Raw bearer token, if one is configured. Used by the WebSocket
147    /// upgrade path (`cmd::events::follow_ws`) where the auth header
148    /// has to be installed on a `tokio_tungstenite` `http::Request`
149    /// rather than a reqwest call.
150    pub fn bearer_token(&self) -> Option<&str> {
151        self.bearer.as_deref()
152    }
153
154    fn url(&self, path: &str) -> String {
155        if path.starts_with('/') {
156            format!("{}{}", self.base, path)
157        } else {
158            format!("{}/{}", self.base, path)
159        }
160    }
161
162    /// GET <path> → T
163    pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> CtlResult<T> {
164        let resp = self.http.get(self.url(path)).send().await?;
165        decode_json(resp).await
166    }
167
168    /// POST <path> with JSON body → T
169    pub async fn post_json<B: Serialize, T: DeserializeOwned>(
170        &self,
171        path: &str,
172        body: &B,
173    ) -> CtlResult<T> {
174        let resp = self.http.post(self.url(path)).json(body).send().await?;
175        decode_json(resp).await
176    }
177
178    /// DELETE <path> — body ignored, only status checked.
179    pub async fn delete(&self, path: &str) -> CtlResult<()> {
180        let resp = self.http.delete(self.url(path)).send().await?;
181        check_status(resp).await.map(|_| ())
182    }
183
184    /// Stream Server-Sent-Events-ish JSON lines (newline-delimited) from a GET endpoint.
185    /// Used by `logs --follow` over HTTP chunked transfer.
186    pub async fn get_stream(&self, path: &str) -> CtlResult<reqwest::Response> {
187        let resp = self.http.get(self.url(path)).send().await?;
188        check_status(resp).await
189    }
190
191    /// Build a WebSocket URL aligned to the same base. http→ws, https→wss.
192    pub fn ws_url(&self, path: &str) -> CtlResult<String> {
193        let mut u = url::Url::parse(&self.url(path))
194            .map_err(|e| CtlError::usage(format!("bad url: {e}")))?;
195        match u.scheme() {
196            "http" => u
197                .set_scheme("ws")
198                .map_err(|_| CtlError::usage("set ws scheme"))?,
199            "https" => u
200                .set_scheme("wss")
201                .map_err(|_| CtlError::usage("set wss scheme"))?,
202            "ws" | "wss" => {}
203            other => return Err(CtlError::usage(format!("unsupported scheme: {other}"))),
204        }
205        Ok(u.to_string())
206    }
207}
208
209async fn check_status(resp: reqwest::Response) -> CtlResult<reqwest::Response> {
210    let status = resp.status();
211    if status.is_success() {
212        return Ok(resp);
213    }
214    // Best-effort: include server-provided body in the error message.
215    let body = resp.text().await.unwrap_or_default();
216    let body_trim = body.trim();
217    let detail = if body_trim.is_empty() {
218        format!("server returned {status}")
219    } else {
220        format!("server returned {status}: {body_trim}")
221    };
222    let code = status.as_u16();
223    Err(match status {
224        StatusCode::BAD_REQUEST | StatusCode::UNPROCESSABLE_ENTITY => {
225            CtlError::validation(detail).with_status(code)
226        }
227        _ => CtlError::api(detail).with_status(code),
228    })
229}
230
231async fn decode_json<T: DeserializeOwned>(resp: reqwest::Response) -> CtlResult<T> {
232    let resp = check_status(resp).await?;
233    let bytes = resp.bytes().await?;
234    serde_json::from_slice(&bytes).map_err(|e| CtlError::api(format!("decode json: {e}")))
235}