1use 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
16const USER_AGENT: &str = concat!("cellctl/", env!("CARGO_PKG_VERSION"));
18
19pub 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 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 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 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 assert_eq!(formation_path(""), "/v1/formations/by-name/");
80 }
81
82 #[test]
83 fn uppercase_uuid_input_uses_uuid_route() {
84 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 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 bearer: Option<String>,
116}
117
118impl CellosClient {
119 pub fn new(cfg: &Config) -> CtlResult<Self> {
120 let base = cfg.effective_server();
121 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 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 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 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 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 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 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 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}