Skip to main content

systemprompt_cloud/api_client/
methods.rs

1//! Low-level HTTP verb helpers for [`super::CloudApiClient`].
2//!
3//! Two flavours live here:
4//!
5//! - `get` sends the operator JWT (`self.token`) verbatim as a bearer. It is
6//!   used by control-plane endpoints in `endpoints.rs`.
7//! - `tenant_get` / `tenant_post` / `tenant_delete` / `tenant_post_empty` /
8//!   `tenant_put_no_content` first acquire a short-lived access token via RFC
9//!   8693 token-exchange against the tenant deployment's
10//!   `/api/v1/core/oauth/token` endpoint, cache it for the lifetime of this
11//!   client, and retry exactly once after clearing the cache on a 401.
12//!
13//! Copyright (c) systemprompt.io — Business Source License 1.1.
14//! See <https://systemprompt.io> for licensing details.
15
16use std::time::{Duration, Instant};
17
18use serde::de::DeserializeOwned;
19use serde::{Deserialize, Serialize};
20
21use super::CloudApiClient;
22use crate::error::{CloudError, CloudResult};
23
24const TOKEN_REFRESH_MARGIN: Duration = Duration::from_secs(30);
25const RFC8693_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:token-exchange";
26const RFC8693_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:access_token";
27const PUBLIC_CLIENT_ID: &str = "sp_web";
28
29#[derive(Debug, Deserialize)]
30struct TokenExchangeResponse {
31    access_token: String,
32    #[serde(default)]
33    expires_in: Option<u64>,
34}
35
36impl CloudApiClient {
37    pub(super) async fn get<T: DeserializeOwned>(&self, path: &str) -> CloudResult<T> {
38        let url = format!("{}{}", self.api_url, path);
39        let response = self
40            .client
41            .get(&url)
42            .header("Authorization", format!("Bearer {}", self.token))
43            .send()
44            .await?;
45        self.handle_response(response).await
46    }
47
48    pub async fn tenant_session_token(&self) -> CloudResult<String> {
49        self.tenant_access_token().await
50    }
51
52    async fn tenant_access_token(&self) -> CloudResult<String> {
53        {
54            let cached = self.tenant_token_cache.lock().await;
55            if let Some((token, expires_at)) = cached.as_ref()
56                && *expires_at > Instant::now() + TOKEN_REFRESH_MARGIN
57            {
58                return Ok(token.clone());
59            }
60        }
61        self.exchange_token().await
62    }
63
64    async fn exchange_token(&self) -> CloudResult<String> {
65        let url = format!("{}/api/v1/core/oauth/token", self.api_url);
66        let response = self
67            .client
68            .post(&url)
69            .form(&[
70                ("grant_type", RFC8693_GRANT_TYPE),
71                ("subject_token", self.token.as_str()),
72                ("subject_token_type", RFC8693_TOKEN_TYPE),
73                ("resource", self.api_url.as_str()),
74                ("client_id", PUBLIC_CLIENT_ID),
75            ])
76            .send()
77            .await?;
78
79        let status = response.status();
80        if status == reqwest::StatusCode::UNAUTHORIZED {
81            return Err(CloudError::Unauthorized);
82        }
83        if !status.is_success() {
84            let body = response
85                .text()
86                .await
87                .unwrap_or_else(|e| format!("<unreadable body: {e}>"));
88            return Err(CloudError::HttpStatus {
89                status: status.as_u16(),
90                body: body.chars().take(500).collect(),
91            });
92        }
93
94        let parsed: TokenExchangeResponse = response.json().await?;
95        let lifetime = parsed
96            .expires_in
97            .map_or_else(|| Duration::from_secs(300), Duration::from_secs);
98        let expires_at = Instant::now() + lifetime;
99
100        let access_token = parsed.access_token;
101        {
102            let mut cached = self.tenant_token_cache.lock().await;
103            *cached = Some((access_token.clone(), expires_at));
104        }
105        Ok(access_token)
106    }
107
108    async fn invalidate_tenant_token(&self) {
109        let mut cached = self.tenant_token_cache.lock().await;
110        *cached = None;
111    }
112
113    pub(super) async fn tenant_get<T: DeserializeOwned>(&self, path: &str) -> CloudResult<T> {
114        let url = format!("{}{}", self.api_url, path);
115        let send = || async {
116            let bearer = self.tenant_access_token().await?;
117            self.client
118                .get(&url)
119                .header("Authorization", format!("Bearer {bearer}"))
120                .send()
121                .await
122                .map_err(CloudError::from)
123        };
124        let response = send().await?;
125        if response.status() == reqwest::StatusCode::UNAUTHORIZED {
126            self.invalidate_tenant_token().await;
127            let retry = send().await?;
128            return self.handle_response(retry).await;
129        }
130        self.handle_response(response).await
131    }
132
133    pub(super) async fn tenant_post<T: DeserializeOwned, B: Serialize + Sync>(
134        &self,
135        path: &str,
136        body: &B,
137    ) -> CloudResult<T> {
138        let url = format!("{}{}", self.api_url, path);
139        let send = || async {
140            let bearer = self.tenant_access_token().await?;
141            self.client
142                .post(&url)
143                .header("Authorization", format!("Bearer {bearer}"))
144                .json(body)
145                .send()
146                .await
147                .map_err(CloudError::from)
148        };
149        let response = send().await?;
150        if response.status() == reqwest::StatusCode::UNAUTHORIZED {
151            self.invalidate_tenant_token().await;
152            let retry = send().await?;
153            return self.handle_response(retry).await;
154        }
155        self.handle_response(response).await
156    }
157
158    pub(super) async fn tenant_put_no_content<B: Serialize + Sync>(
159        &self,
160        path: &str,
161        body: &B,
162    ) -> CloudResult<()> {
163        let url = format!("{}{}", self.api_url, path);
164        let send = || async {
165            let bearer = self.tenant_access_token().await?;
166            self.client
167                .put(&url)
168                .header("Authorization", format!("Bearer {bearer}"))
169                .json(body)
170                .send()
171                .await
172                .map_err(CloudError::from)
173        };
174        let response = send().await?;
175        if response.status() == reqwest::StatusCode::UNAUTHORIZED {
176            self.invalidate_tenant_token().await;
177            let retry = send().await?;
178            return self.handle_no_content_response(retry).await;
179        }
180        self.handle_no_content_response(response).await
181    }
182
183    pub(super) async fn tenant_delete(&self, path: &str) -> CloudResult<()> {
184        let url = format!("{}{}", self.api_url, path);
185        let send = || async {
186            let bearer = self.tenant_access_token().await?;
187            self.client
188                .delete(&url)
189                .header("Authorization", format!("Bearer {bearer}"))
190                .send()
191                .await
192                .map_err(CloudError::from)
193        };
194        let response = send().await?;
195        if response.status() == reqwest::StatusCode::UNAUTHORIZED {
196            self.invalidate_tenant_token().await;
197            let retry = send().await?;
198            return self.handle_no_content_response(retry).await;
199        }
200        self.handle_no_content_response(response).await
201    }
202
203    pub(super) async fn tenant_post_empty<T: DeserializeOwned>(
204        &self,
205        path: &str,
206    ) -> CloudResult<T> {
207        let url = format!("{}{}", self.api_url, path);
208        let send = || async {
209            let bearer = self.tenant_access_token().await?;
210            self.client
211                .post(&url)
212                .header("Authorization", format!("Bearer {bearer}"))
213                .send()
214                .await
215                .map_err(CloudError::from)
216        };
217        let response = send().await?;
218        if response.status() == reqwest::StatusCode::UNAUTHORIZED {
219            self.invalidate_tenant_token().await;
220            let retry = send().await?;
221            return self.handle_response(retry).await;
222        }
223        self.handle_response(response).await
224    }
225}