Skip to main content

systemprompt_cloud/api_client/
client.rs

1//! `CloudApiClient` constructor + accessors. Lower-level HTTP verbs
2//! live in `methods.rs`; high-level endpoints in `endpoints.rs`.
3//!
4//! Copyright (c) systemprompt.io — Business Source License 1.1.
5//! See <https://systemprompt.io> for licensing details.
6
7use std::sync::Arc;
8use std::time::Instant;
9
10use reqwest::{Client, StatusCode};
11use serde::de::DeserializeOwned;
12use systemprompt_models::net::{HTTP_CONNECT_TIMEOUT, HTTP_DEFAULT_TIMEOUT};
13use tokio::sync::Mutex;
14
15use super::types::ApiError;
16use crate::error::{CloudError, CloudResult};
17
18pub(super) type TenantTokenCache = Arc<Mutex<Option<(String, Instant)>>>;
19
20#[derive(Debug)]
21pub struct CloudApiClient {
22    pub(super) client: Client,
23    pub(super) api_url: String,
24    pub(super) token: String,
25    pub(super) tenant_token_cache: TenantTokenCache,
26}
27
28impl CloudApiClient {
29    pub fn new(api_url: &str, token: &str) -> Result<Self, reqwest::Error> {
30        Ok(Self {
31            client: Client::builder()
32                .connect_timeout(HTTP_CONNECT_TIMEOUT)
33                .timeout(HTTP_DEFAULT_TIMEOUT)
34                .build()?,
35            api_url: api_url.to_owned(),
36            token: token.to_owned(),
37            tenant_token_cache: Arc::new(Mutex::new(None)),
38        })
39    }
40
41    #[must_use]
42    pub fn api_url(&self) -> &str {
43        &self.api_url
44    }
45
46    #[must_use]
47    pub fn token(&self) -> &str {
48        &self.token
49    }
50
51    pub(super) async fn handle_response<T: DeserializeOwned>(
52        &self,
53        response: reqwest::Response,
54    ) -> CloudResult<T> {
55        let status = response.status();
56
57        if status == StatusCode::UNAUTHORIZED {
58            return Err(CloudError::Unauthorized);
59        }
60
61        if !status.is_success() {
62            return Err(parse_error_response(status, response).await);
63        }
64
65        response.json().await.map_err(CloudError::from)
66    }
67
68    pub(super) async fn handle_no_content_response(
69        &self,
70        response: reqwest::Response,
71    ) -> CloudResult<()> {
72        let status = response.status();
73        if status == StatusCode::UNAUTHORIZED {
74            return Err(CloudError::Unauthorized);
75        }
76        if status == StatusCode::NO_CONTENT || status.is_success() {
77            return Ok(());
78        }
79        Err(parse_error_response(status, response).await)
80    }
81}
82
83pub(super) async fn parse_error_response(
84    status: StatusCode,
85    response: reqwest::Response,
86) -> CloudError {
87    let error_text = match response.text().await {
88        Ok(t) => t,
89        Err(e) => {
90            tracing::warn!(error = %e, "Failed to read error response body");
91            String::from("<failed to read response body>")
92        },
93    };
94
95    serde_json::from_str::<ApiError>(&error_text).map_or_else(
96        |_| CloudError::HttpStatus {
97            status: status.as_u16(),
98            body: error_text.chars().take(500).collect(),
99        },
100        |parsed| CloudError::ApiError {
101            message: format!("{}: {}", parsed.error.code, parsed.error.message),
102        },
103    )
104}