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 percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
9use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
10use reqwest::StatusCode;
11use serde::de::DeserializeOwned;
12use serde::Serialize;
13
14use crate::config::Config;
15use crate::exit::{CtlError, CtlResult};
16
17/// User-Agent string sent to the server — helps the projector identify CLI traffic.
18const USER_AGENT: &str = concat!("cellctl/", env!("CARGO_PKG_VERSION"));
19
20/// HIGH-CTL-002-B-1: strict percent-encoding set for path segments.
21///
22/// We encode EVERY non-alphanumeric byte. RFC 3986 §3.3 permits a wider
23/// "unreserved" set (alphanumerics plus `-` `.` `_` `~`) inside a path
24/// segment, and the small set of "sub-delims" (`!$&'()*+,;=`) plus `:`
25/// and `@`. But cellctl already filters its by-name input through the
26/// server's admission allow-set (`[A-Za-z0-9._-]`), so for any name
27/// that round-trips the only characters this set will actually escape
28/// are the ones the server rejects — and the encoding is correct for
29/// those too. The conservative choice keeps the encoder future-proof
30/// against an admission rule change and removes any chance of a
31/// reserved-character round-trip surprise.
32///
33/// Critically: `b' '` (space) IS in `NON_ALPHANUMERIC` so it encodes
34/// as `%20`, NOT `+`. This is the actual HIGH-CTL-002-B-1 fix —
35/// `url::form_urlencoded::byte_serialize` (the previous encoder)
36/// emits `+` for space because it implements `application/x-www-form-
37/// urlencoded`, not path-segment encoding. axum's `Path<String>`
38/// extractor decodes per RFC 3986, which does NOT recognise `+` as
39/// space inside a path segment. The previous behaviour silently broke
40/// `cellctl describe formation 'hello world'` with a 404. The strict
41/// set fixes the asymmetry by emitting `%20` on the cellctl side.
42const PATH_SEGMENT: &AsciiSet = NON_ALPHANUMERIC;
43
44/// Build the right `/v1/formations/...` path for a user-supplied
45/// identifier. CTL-002 (E2E report): the server's `/v1/formations/{id}`
46/// route uses `Path<Uuid>` and rejects names with HTTP 400. We detect
47/// the shape locally and route to the parallel `/v1/formations/by-name/{name}`
48/// route when the input is not a UUID.
49///
50/// Detection is `Uuid::parse_str(input).is_ok()` — UUID-shaped strings
51/// always go to the UUID route (preserves existing operator workflows),
52/// everything else routes to by-name. We percent-encode the name with
53/// a strict path-segment set so an operator-supplied identifier
54/// containing reserved characters does not silently truncate or break
55/// path parsing on the server (`/`, `?`, `#`) and so that a literal
56/// space encodes as `%20` rather than `+` (HIGH-CTL-002-B-1).
57pub fn formation_path(ident: &str) -> String {
58    if uuid::Uuid::parse_str(ident).is_ok() {
59        format!("/v1/formations/{ident}")
60    } else {
61        format!(
62            "/v1/formations/by-name/{}",
63            utf8_percent_encode(ident, PATH_SEGMENT)
64        )
65    }
66}
67
68#[cfg(test)]
69mod path_tests {
70    use super::formation_path;
71
72    #[test]
73    fn uuid_input_uses_uuid_route() {
74        // Canonical hyphenated v4.
75        let id = "550e8400-e29b-41d4-a716-446655440000";
76        assert_eq!(formation_path(id), format!("/v1/formations/{id}"));
77    }
78
79    #[test]
80    fn simple_name_uses_by_name_route() {
81        assert_eq!(formation_path("demo"), "/v1/formations/by-name/demo");
82    }
83
84    #[test]
85    fn name_with_reserved_chars_is_url_encoded() {
86        // `/` in a name MUST NOT escape the path segment.
87        let got = formation_path("ns/team");
88        assert!(
89            got.starts_with("/v1/formations/by-name/") && !got.contains("ns/team"),
90            "expected encoded segment, got {got}"
91        );
92        // HIGH-CTL-002-B-1: round-trip via the percent_encoding crate's
93        // decoder (RFC 3986 path-segment semantics), NOT form-urlencoded,
94        // because the server uses axum's `Path<String>` which is RFC
95        // 3986-shaped. Form-urlencoded decode would mistranslate `+`
96        // back to space and miss the bug.
97        let tail = got.trim_start_matches("/v1/formations/by-name/");
98        let decoded = percent_encoding::percent_decode_str(tail)
99            .decode_utf8()
100            .expect("encoded segment is valid utf8")
101            .into_owned();
102        assert_eq!(decoded, "ns/team");
103    }
104
105    /// HIGH-CTL-002-B-1 regression: space MUST encode as `%20`, NOT
106    /// `+`. The pre-fix encoder (`form_urlencoded::byte_serialize`)
107    /// emitted `+` for space, and axum's `Path<String>` extractor —
108    /// which uses RFC 3986 path-segment decoding — does NOT translate
109    /// `+` back to space inside a path. Result: a formation named
110    /// `hello world` was unreachable via cellctl's by-name path.
111    ///
112    /// This is the single most important behavioural pin in this
113    /// file. If a future encoder change re-introduces `+` for space,
114    /// this test fails immediately and points the reader at the bug.
115    #[test]
116    fn space_encodes_as_percent20_not_plus() {
117        let got = formation_path("hello world");
118        assert!(got.contains("%20"), "space must encode as %20; got {got}",);
119        assert!(
120            !got.contains('+'),
121            "space must NOT encode as `+` (axum Path<String> would not decode it); got {got}",
122        );
123        // Full expected encoding so future readers see the contract.
124        assert_eq!(got, "/v1/formations/by-name/hello%20world");
125    }
126
127    /// HIGH-CTL-002-B-1 round-trip: every byte the strict encoder
128    /// produces must round-trip through `percent_decode_str` back to
129    /// the operator-typed name. This proves the encode/decode pair is
130    /// symmetric across the URL-reserved character classes the server
131    /// might one day accept (`/`, `?`, `#`, `%`, non-ASCII).
132    #[test]
133    fn percent_encoder_round_trips_reserved_chars() {
134        for (input, label) in [
135            ("ns/team", "slash"),
136            ("100%-clean", "percent"),
137            ("why?-team", "question"),
138            ("v1#main", "hash"),
139            ("hello world", "space"),
140            ("café-team", "non-ascii"),
141        ] {
142            let got = formation_path(input);
143            let tail = got.trim_start_matches("/v1/formations/by-name/");
144            // Plus is form-urlencoded space — it MUST NOT appear in
145            // path-segment encoding. We assert at the tail level so a
146            // future encoder regression on any character class is
147            // caught.
148            assert!(
149                !tail.contains('+'),
150                "[{label}] encoded segment must not contain `+`; got {tail}",
151            );
152            let decoded = percent_encoding::percent_decode_str(tail)
153                .decode_utf8()
154                .unwrap_or_else(|e| panic!("[{label}] decode utf8: {e}"))
155                .into_owned();
156            assert_eq!(
157                decoded, input,
158                "[{label}] encode-then-decode must be the identity",
159            );
160        }
161    }
162
163    #[test]
164    fn empty_string_routes_to_by_name() {
165        // Empty is not a UUID; the server will surface a 404 (or a
166        // route-level miss). Either way, cellctl does not silently
167        // mis-route to the UUID path.
168        assert_eq!(formation_path(""), "/v1/formations/by-name/");
169    }
170
171    #[test]
172    fn uppercase_uuid_input_uses_uuid_route() {
173        // RFC 4122 §3 explicitly allows uppercase hex; `Uuid::parse_str`
174        // accepts it. The server's `Path<Uuid>` extractor does too.
175        let id = "550E8400-E29B-41D4-A716-446655440000";
176        assert_eq!(formation_path(id), format!("/v1/formations/{id}"));
177    }
178
179    #[test]
180    fn near_uuid_shaped_name_does_not_collide() {
181        // A 32-char hex string without hyphens parses as a UUID per
182        // RFC 4122 §3 "simple" form — that is intentional and the
183        // server's Uuid extractor accepts it. This pin documents the
184        // boundary: if an operator ever names a formation as 32 hex
185        // chars, cellctl WILL route it to the UUID path. That is the
186        // known ambiguity Option B accepts (Option C would have made
187        // it worse). The test exists so the choice is auditable.
188        let s = "550e8400e29b41d4a716446655440000";
189        assert_eq!(formation_path(s), format!("/v1/formations/{s}"));
190    }
191}
192
193#[derive(Clone)]
194pub struct CellosClient {
195    base: String,
196    http: reqwest::Client,
197    /// Raw bearer token if configured. Held separately so paths that
198    /// don't go through reqwest (the WebSocket upgrade in
199    /// `cmd::events`) can still authenticate without parsing it back
200    /// out of `default_headers`. EVT-002 (E2E report): cellctl's WS
201    /// upgrade needs to set `Authorization: Bearer <token>` on the
202    /// `http::Request`; before this field it had no clean way to
203    /// reach the configured token.
204    bearer: Option<String>,
205}
206
207impl CellosClient {
208    pub fn new(cfg: &Config) -> CtlResult<Self> {
209        let base = cfg.effective_server();
210        // Normalize: strip trailing slash so we can confidently concatenate paths.
211        let base = base.trim_end_matches('/').to_string();
212
213        let bearer = cfg.effective_token();
214        let mut headers = HeaderMap::new();
215        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
216        if let Some(tok) = bearer.as_deref() {
217            let v = HeaderValue::from_str(&format!("Bearer {tok}"))
218                .map_err(|e| CtlError::usage(format!("bad token: {e}")))?;
219            headers.insert(AUTHORIZATION, v);
220        }
221
222        let http = reqwest::Client::builder()
223            .user_agent(USER_AGENT)
224            .default_headers(headers)
225            .build()
226            .map_err(|e| CtlError::api(format!("init http client: {e}")))?;
227
228        Ok(Self { base, http, bearer })
229    }
230
231    pub fn base_url(&self) -> &str {
232        &self.base
233    }
234
235    /// Raw bearer token, if one is configured. Used by the WebSocket
236    /// upgrade path (`cmd::events::follow_ws`) where the auth header
237    /// has to be installed on a `tokio_tungstenite` `http::Request`
238    /// rather than a reqwest call.
239    pub fn bearer_token(&self) -> Option<&str> {
240        self.bearer.as_deref()
241    }
242
243    fn url(&self, path: &str) -> String {
244        if path.starts_with('/') {
245            format!("{}{}", self.base, path)
246        } else {
247            format!("{}/{}", self.base, path)
248        }
249    }
250
251    /// GET <path> → T
252    pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> CtlResult<T> {
253        let resp = self.http.get(self.url(path)).send().await?;
254        decode_json(resp).await
255    }
256
257    /// POST <path> with JSON body → T
258    pub async fn post_json<B: Serialize, T: DeserializeOwned>(
259        &self,
260        path: &str,
261        body: &B,
262    ) -> CtlResult<T> {
263        let resp = self.http.post(self.url(path)).json(body).send().await?;
264        decode_json(resp).await
265    }
266
267    /// DELETE <path> — body ignored, only status checked.
268    pub async fn delete(&self, path: &str) -> CtlResult<()> {
269        let resp = self.http.delete(self.url(path)).send().await?;
270        check_status(resp).await.map(|_| ())
271    }
272
273    /// Stream Server-Sent-Events-ish JSON lines (newline-delimited) from a GET endpoint.
274    /// Used by `logs --follow` over HTTP chunked transfer.
275    pub async fn get_stream(&self, path: &str) -> CtlResult<reqwest::Response> {
276        let resp = self.http.get(self.url(path)).send().await?;
277        check_status(resp).await
278    }
279
280    /// Build a WebSocket URL aligned to the same base. http→ws, https→wss.
281    pub fn ws_url(&self, path: &str) -> CtlResult<String> {
282        let mut u = url::Url::parse(&self.url(path))
283            .map_err(|e| CtlError::usage(format!("bad url: {e}")))?;
284        match u.scheme() {
285            "http" => u
286                .set_scheme("ws")
287                .map_err(|_| CtlError::usage("set ws scheme"))?,
288            "https" => u
289                .set_scheme("wss")
290                .map_err(|_| CtlError::usage("set wss scheme"))?,
291            "ws" | "wss" => {}
292            other => return Err(CtlError::usage(format!("unsupported scheme: {other}"))),
293        }
294        Ok(u.to_string())
295    }
296}
297
298async fn check_status(resp: reqwest::Response) -> CtlResult<reqwest::Response> {
299    let status = resp.status();
300    if status.is_success() {
301        return Ok(resp);
302    }
303    // Best-effort: include server-provided body in the error message.
304    let body = resp.text().await.unwrap_or_default();
305    let body_trim = body.trim();
306    let detail = if body_trim.is_empty() {
307        format!("server returned {status}")
308    } else {
309        format!("server returned {status}: {body_trim}")
310    };
311    let code = status.as_u16();
312    Err(match status {
313        StatusCode::BAD_REQUEST | StatusCode::UNPROCESSABLE_ENTITY => {
314            CtlError::validation(detail).with_status(code)
315        }
316        _ => CtlError::api(detail).with_status(code),
317    })
318}
319
320async fn decode_json<T: DeserializeOwned>(resp: reqwest::Response) -> CtlResult<T> {
321    let resp = check_status(resp).await?;
322    let bytes = resp.bytes().await?;
323    serde_json::from_slice(&bytes).map_err(|e| CtlError::api(format!("decode json: {e}")))
324}