use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
use reqwest::StatusCode;
use serde::de::DeserializeOwned;
use serde::Serialize;
use crate::config::Config;
use crate::exit::{CtlError, CtlResult};
const USER_AGENT: &str = concat!("cellctl/", env!("CARGO_PKG_VERSION"));
pub fn formation_path(ident: &str) -> String {
if uuid::Uuid::parse_str(ident).is_ok() {
format!("/v1/formations/{ident}")
} else {
format!(
"/v1/formations/by-name/{}",
url::form_urlencoded::byte_serialize(ident.as_bytes()).collect::<String>()
)
}
}
#[cfg(test)]
mod path_tests {
use super::formation_path;
#[test]
fn uuid_input_uses_uuid_route() {
let id = "550e8400-e29b-41d4-a716-446655440000";
assert_eq!(formation_path(id), format!("/v1/formations/{id}"));
}
#[test]
fn simple_name_uses_by_name_route() {
assert_eq!(formation_path("demo"), "/v1/formations/by-name/demo");
}
#[test]
fn name_with_reserved_chars_is_url_encoded() {
let got = formation_path("ns/team");
assert!(
got.starts_with("/v1/formations/by-name/") && !got.contains("ns/team"),
"expected encoded segment, got {got}"
);
let tail = got.trim_start_matches("/v1/formations/by-name/");
let decoded: String = url::form_urlencoded::parse(format!("k={tail}").as_bytes())
.next()
.map(|(_, v)| v.into_owned())
.unwrap_or_default();
assert_eq!(decoded, "ns/team");
}
#[test]
fn empty_string_routes_to_by_name() {
assert_eq!(formation_path(""), "/v1/formations/by-name/");
}
#[test]
fn uppercase_uuid_input_uses_uuid_route() {
let id = "550E8400-E29B-41D4-A716-446655440000";
assert_eq!(formation_path(id), format!("/v1/formations/{id}"));
}
#[test]
fn near_uuid_shaped_name_does_not_collide() {
let s = "550e8400e29b41d4a716446655440000";
assert_eq!(formation_path(s), format!("/v1/formations/{s}"));
}
}
#[derive(Clone)]
pub struct CellosClient {
base: String,
http: reqwest::Client,
bearer: Option<String>,
}
impl CellosClient {
pub fn new(cfg: &Config) -> CtlResult<Self> {
let base = cfg.effective_server();
let base = base.trim_end_matches('/').to_string();
let bearer = cfg.effective_token();
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
if let Some(tok) = bearer.as_deref() {
let v = HeaderValue::from_str(&format!("Bearer {tok}"))
.map_err(|e| CtlError::usage(format!("bad token: {e}")))?;
headers.insert(AUTHORIZATION, v);
}
let http = reqwest::Client::builder()
.user_agent(USER_AGENT)
.default_headers(headers)
.build()
.map_err(|e| CtlError::api(format!("init http client: {e}")))?;
Ok(Self { base, http, bearer })
}
pub fn base_url(&self) -> &str {
&self.base
}
pub fn bearer_token(&self) -> Option<&str> {
self.bearer.as_deref()
}
fn url(&self, path: &str) -> String {
if path.starts_with('/') {
format!("{}{}", self.base, path)
} else {
format!("{}/{}", self.base, path)
}
}
pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> CtlResult<T> {
let resp = self.http.get(self.url(path)).send().await?;
decode_json(resp).await
}
pub async fn post_json<B: Serialize, T: DeserializeOwned>(
&self,
path: &str,
body: &B,
) -> CtlResult<T> {
let resp = self.http.post(self.url(path)).json(body).send().await?;
decode_json(resp).await
}
pub async fn delete(&self, path: &str) -> CtlResult<()> {
let resp = self.http.delete(self.url(path)).send().await?;
check_status(resp).await.map(|_| ())
}
pub async fn get_stream(&self, path: &str) -> CtlResult<reqwest::Response> {
let resp = self.http.get(self.url(path)).send().await?;
check_status(resp).await
}
pub fn ws_url(&self, path: &str) -> CtlResult<String> {
let mut u = url::Url::parse(&self.url(path))
.map_err(|e| CtlError::usage(format!("bad url: {e}")))?;
match u.scheme() {
"http" => u
.set_scheme("ws")
.map_err(|_| CtlError::usage("set ws scheme"))?,
"https" => u
.set_scheme("wss")
.map_err(|_| CtlError::usage("set wss scheme"))?,
"ws" | "wss" => {}
other => return Err(CtlError::usage(format!("unsupported scheme: {other}"))),
}
Ok(u.to_string())
}
}
async fn check_status(resp: reqwest::Response) -> CtlResult<reqwest::Response> {
let status = resp.status();
if status.is_success() {
return Ok(resp);
}
let body = resp.text().await.unwrap_or_default();
let body_trim = body.trim();
let detail = if body_trim.is_empty() {
format!("server returned {status}")
} else {
format!("server returned {status}: {body_trim}")
};
let code = status.as_u16();
Err(match status {
StatusCode::BAD_REQUEST | StatusCode::UNPROCESSABLE_ENTITY => {
CtlError::validation(detail).with_status(code)
}
_ => CtlError::api(detail).with_status(code),
})
}
async fn decode_json<T: DeserializeOwned>(resp: reqwest::Response) -> CtlResult<T> {
let resp = check_status(resp).await?;
let bytes = resp.bytes().await?;
serde_json::from_slice(&bytes).map_err(|e| CtlError::api(format!("decode json: {e}")))
}