1use 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
16const 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 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 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 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 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 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 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 #[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 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}