systemprompt_cloud/api_client/
client.rs1use 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}