Skip to main content

systemprompt_cloud/api_client/
client.rs

1use anyhow::{Context, Result, anyhow};
2use chrono::Utc;
3use reqwest::{Client, StatusCode};
4use serde::Serialize;
5use serde::de::DeserializeOwned;
6use systemprompt_models::modules::ApiPaths;
7use systemprompt_models::net::{HTTP_CONNECT_TIMEOUT, HTTP_DEFAULT_TIMEOUT};
8
9use super::types::{
10    ActivityRequest, ApiError, CheckoutRequest, CheckoutResponse, ListResponse, Plan, Tenant,
11    UserMeResponse,
12};
13
14#[derive(Debug)]
15pub struct CloudApiClient {
16    pub(super) client: Client,
17    pub(super) api_url: String,
18    pub(super) token: String,
19}
20
21impl CloudApiClient {
22    pub fn new(api_url: &str, token: &str) -> Result<Self, reqwest::Error> {
23        Ok(Self {
24            client: Client::builder()
25                .connect_timeout(HTTP_CONNECT_TIMEOUT)
26                .timeout(HTTP_DEFAULT_TIMEOUT)
27                .build()?,
28            api_url: api_url.to_string(),
29            token: token.to_string(),
30        })
31    }
32
33    #[must_use]
34    pub fn api_url(&self) -> &str {
35        &self.api_url
36    }
37
38    #[must_use]
39    pub fn token(&self) -> &str {
40        &self.token
41    }
42
43    pub(super) async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
44        let url = format!("{}{}", self.api_url, path);
45        let response = self
46            .client
47            .get(&url)
48            .header("Authorization", format!("Bearer {}", self.token))
49            .send()
50            .await
51            .context("Failed to connect to API")?;
52
53        self.handle_response(response).await
54    }
55
56    pub(super) async fn post<T: DeserializeOwned, B: Serialize + Sync>(
57        &self,
58        path: &str,
59        body: &B,
60    ) -> Result<T> {
61        let url = format!("{}{}", self.api_url, path);
62        let response = self
63            .client
64            .post(&url)
65            .header("Authorization", format!("Bearer {}", self.token))
66            .json(body)
67            .send()
68            .await
69            .context("Failed to connect to API")?;
70
71        self.handle_response(response).await
72    }
73
74    pub(super) async fn post_no_response<B: Serialize + Sync>(
75        &self,
76        path: &str,
77        body: &B,
78    ) -> Result<()> {
79        let url = format!("{}{}", self.api_url, path);
80        let response = self
81            .client
82            .post(&url)
83            .header("Authorization", format!("Bearer {}", self.token))
84            .json(body)
85            .send()
86            .await
87            .context("Failed to connect to API")?;
88
89        let status = response.status();
90        if status == StatusCode::UNAUTHORIZED {
91            return Err(anyhow!(
92                "Authentication failed. Please run 'systemprompt cloud login' again."
93            ));
94        }
95        if status == StatusCode::NO_CONTENT || status.is_success() {
96            return Ok(());
97        }
98
99        let error_text = response
100            .text()
101            .await
102            .unwrap_or_else(|_| String::from("<failed to read response body>"));
103
104        serde_json::from_str::<ApiError>(&error_text).map_or_else(
105            |_| {
106                Err(anyhow!(
107                    "Request failed with status {}: {}",
108                    status,
109                    error_text.chars().take(500).collect::<String>()
110                ))
111            },
112            |parsed| Err(anyhow!("{}: {}", parsed.error.code, parsed.error.message)),
113        )
114    }
115
116    pub(super) async fn put<T: DeserializeOwned, B: Serialize + Sync>(
117        &self,
118        path: &str,
119        body: &B,
120    ) -> Result<T> {
121        let url = format!("{}{}", self.api_url, path);
122        let response = self
123            .client
124            .put(&url)
125            .header("Authorization", format!("Bearer {}", self.token))
126            .json(body)
127            .send()
128            .await
129            .context("Failed to connect to API")?;
130
131        self.handle_response(response).await
132    }
133
134    pub(super) async fn put_no_content<B: Serialize + Sync>(
135        &self,
136        path: &str,
137        body: &B,
138    ) -> Result<()> {
139        let url = format!("{}{}", self.api_url, path);
140        let response = self
141            .client
142            .put(&url)
143            .header("Authorization", format!("Bearer {}", self.token))
144            .json(body)
145            .send()
146            .await
147            .context("Failed to connect to API")?;
148
149        let status = response.status();
150        if status == StatusCode::UNAUTHORIZED {
151            return Err(anyhow!(
152                "Authentication failed. Please run 'systemprompt cloud login' again."
153            ));
154        }
155        if status == StatusCode::NO_CONTENT || status.is_success() {
156            return Ok(());
157        }
158
159        let error_text = response
160            .text()
161            .await
162            .unwrap_or_else(|_| String::from("<failed to read response body>"));
163
164        serde_json::from_str::<ApiError>(&error_text).map_or_else(
165            |_| {
166                Err(anyhow!(
167                    "Request failed with status {}: {}",
168                    status,
169                    error_text.chars().take(500).collect::<String>()
170                ))
171            },
172            |parsed| Err(anyhow!("{}: {}", parsed.error.code, parsed.error.message)),
173        )
174    }
175
176    pub(super) async fn delete(&self, path: &str) -> Result<()> {
177        let url = format!("{}{}", self.api_url, path);
178        let response = self
179            .client
180            .delete(&url)
181            .header("Authorization", format!("Bearer {}", self.token))
182            .send()
183            .await
184            .context("Failed to connect to API")?;
185
186        let status = response.status();
187
188        if status == StatusCode::UNAUTHORIZED {
189            return Err(anyhow!(
190                "Authentication failed. Please run 'systemprompt cloud login' again."
191            ));
192        }
193
194        if status == StatusCode::NO_CONTENT {
195            return Ok(());
196        }
197
198        if !status.is_success() {
199            let error_text = response
200                .text()
201                .await
202                .unwrap_or_else(|_| String::from("<failed to read response body>"));
203
204            return serde_json::from_str::<ApiError>(&error_text).map_or_else(
205                |_| {
206                    Err(anyhow!(
207                        "Request failed with status {}: {}",
208                        status,
209                        error_text.chars().take(500).collect::<String>()
210                    ))
211                },
212                |parsed| Err(anyhow!("{}: {}", parsed.error.code, parsed.error.message)),
213            );
214        }
215
216        Ok(())
217    }
218
219    pub(super) async fn post_empty<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
220        let url = format!("{}{}", self.api_url, path);
221        let response = self
222            .client
223            .post(&url)
224            .header("Authorization", format!("Bearer {}", self.token))
225            .send()
226            .await
227            .context("Failed to connect to API")?;
228
229        self.handle_response(response).await
230    }
231
232    async fn handle_response<T: DeserializeOwned>(&self, response: reqwest::Response) -> Result<T> {
233        let status = response.status();
234
235        if status == StatusCode::UNAUTHORIZED {
236            return Err(anyhow!(
237                "Authentication failed. Please run 'systemprompt cloud login' again."
238            ));
239        }
240
241        if !status.is_success() {
242            let error_text = response
243                .text()
244                .await
245                .unwrap_or_else(|_| String::from("<failed to read response body>"));
246
247            return serde_json::from_str::<ApiError>(&error_text).map_or_else(
248                |_| {
249                    Err(anyhow!(
250                        "Request failed with status {}: {}",
251                        status,
252                        error_text.chars().take(500).collect::<String>()
253                    ))
254                },
255                |parsed| Err(anyhow!("{}: {}", parsed.error.code, parsed.error.message)),
256            );
257        }
258
259        response
260            .json()
261            .await
262            .context("Failed to parse API response")
263    }
264
265    pub async fn get_user(&self) -> Result<UserMeResponse> {
266        self.get(ApiPaths::AUTH_ME).await
267    }
268
269    pub async fn list_tenants(&self) -> Result<Vec<Tenant>> {
270        let response: ListResponse<Tenant> = self.get(ApiPaths::CLOUD_TENANTS).await?;
271        Ok(response.data)
272    }
273
274    pub async fn get_plans(&self) -> Result<Vec<Plan>> {
275        let plans: Vec<Plan> = self.get(ApiPaths::CLOUD_CHECKOUT_PLANS).await?;
276        Ok(plans)
277    }
278
279    pub async fn create_checkout(
280        &self,
281        price_id: &str,
282        region: &str,
283        redirect_uri: Option<&str>,
284    ) -> Result<CheckoutResponse> {
285        let request = CheckoutRequest {
286            price_id: price_id.to_string(),
287            region: region.to_string(),
288            redirect_uri: redirect_uri.map(String::from),
289        };
290        self.post(ApiPaths::CLOUD_CHECKOUT, &request).await
291    }
292
293    pub async fn report_activity(&self, event_type: &str, user_id: &str) -> Result<()> {
294        let request = ActivityRequest {
295            event: event_type.to_string(),
296            timestamp: Utc::now().to_rfc3339(),
297            data: super::types::ActivityData {
298                user_id: user_id.to_string(),
299            },
300        };
301        self.post_no_response(ApiPaths::CLOUD_ACTIVITY, &request)
302            .await
303    }
304}