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