Skip to main content

context69_sdk/
client.rs

1mod library;
2mod search;
3mod settings;
4mod sources;
5mod workspace;
6
7use std::{sync::Arc, time::Duration};
8
9use context69_contracts::{ApiErrorResponse, AuthMeResponse, HealthResponse};
10use reqwest::{
11    Method, RequestBuilder, Response, Url,
12    header::{AUTHORIZATION, USER_AGENT},
13    multipart::{Form, Part},
14};
15use tokio::sync::RwLock;
16use uuid::Uuid;
17
18use crate::Error;
19pub use library::LibraryApi;
20pub use search::SearchApi;
21pub use settings::SettingsApi;
22pub use sources::SourcesApi;
23pub use workspace::WorkspaceApi;
24
25pub(crate) const PERSONAL_ACCESS_TOKEN_PREFIX: &str = "ctx_pat_";
26
27#[derive(Debug, Clone, Default)]
28struct SessionState {
29    personal_access_token: Option<String>,
30}
31
32#[derive(Clone)]
33pub struct Context69Client {
34    client: reqwest::Client,
35    base_url: Url,
36    session: Arc<RwLock<SessionState>>,
37}
38
39#[derive(Debug)]
40pub struct Context69ClientBuilder {
41    base_url: Option<Url>,
42    user_agent: Option<String>,
43    timeout: Option<Duration>,
44    personal_access_token: Option<String>,
45}
46
47impl Context69Client {
48    pub fn builder() -> Context69ClientBuilder {
49        Context69ClientBuilder {
50            base_url: None,
51            user_agent: None,
52            timeout: None,
53            personal_access_token: None,
54        }
55    }
56
57    pub fn with_personal_access_token(
58        &self,
59        token: impl Into<String>,
60    ) -> Result<Context69Client, Error> {
61        Ok(Context69Client {
62            client: self.client.clone(),
63            base_url: self.base_url.clone(),
64            session: Arc::new(RwLock::new(SessionState {
65                personal_access_token: Some(validate_personal_access_token(token.into())?),
66            })),
67        })
68    }
69
70    pub fn workspace(&self) -> WorkspaceApi<'_> {
71        WorkspaceApi::new(self)
72    }
73
74    pub fn sources(&self) -> SourcesApi<'_> {
75        SourcesApi::new(self)
76    }
77
78    pub fn library(&self) -> LibraryApi<'_> {
79        LibraryApi::new(self)
80    }
81
82    pub fn settings(&self) -> SettingsApi<'_> {
83        SettingsApi::new(self)
84    }
85
86    pub fn search(&self) -> SearchApi<'_> {
87        SearchApi::new(self)
88    }
89
90    pub async fn me(&self) -> Result<AuthMeResponse, Error> {
91        self.execute_json(self.authorized_request(Method::GET, "/v1/auth/me").await?)
92            .await
93    }
94
95    pub async fn healthz(&self) -> Result<HealthResponse, Error> {
96        let response = self.client.get(self.url("/healthz")?).send().await?;
97        self.read_json_response(response).await
98    }
99
100    pub(crate) async fn authorized_request(
101        &self,
102        method: Method,
103        path: &str,
104    ) -> Result<RequestBuilder, Error> {
105        let personal_access_token = self
106            .session
107            .read()
108            .await
109            .personal_access_token
110            .clone()
111            .ok_or(Error::AuthenticationRequired)?;
112        Ok(self
113            .client
114            .request(method, self.url(path)?)
115            .header(AUTHORIZATION, format!("Bearer {personal_access_token}")))
116    }
117
118    pub(crate) async fn authorized_url_request(
119        &self,
120        method: Method,
121        url: Url,
122    ) -> Result<RequestBuilder, Error> {
123        let personal_access_token = self
124            .session
125            .read()
126            .await
127            .personal_access_token
128            .clone()
129            .ok_or(Error::AuthenticationRequired)?;
130        Ok(self
131            .client
132            .request(method, url)
133            .header(AUTHORIZATION, format!("Bearer {personal_access_token}")))
134    }
135
136    pub(crate) fn url(&self, path: &str) -> Result<Url, Error> {
137        self.base_url
138            .join(path.trim_start_matches('/'))
139            .map_err(|source| Error::UrlJoin {
140                path: path.to_string(),
141                source,
142            })
143    }
144
145    pub(crate) async fn execute_json<T: serde::de::DeserializeOwned>(
146        &self,
147        request: RequestBuilder,
148    ) -> Result<T, Error> {
149        self.read_json_response(request.send().await?).await
150    }
151
152    pub(crate) async fn execute_empty(&self, request: RequestBuilder) -> Result<(), Error> {
153        self.read_empty_response(request.send().await?).await
154    }
155
156    async fn read_empty_response(&self, response: Response) -> Result<(), Error> {
157        let status = response.status();
158        if status.is_success() {
159            return Ok(());
160        }
161        Err(self.build_http_error(response).await)
162    }
163
164    async fn read_json_response<T: serde::de::DeserializeOwned>(
165        &self,
166        response: Response,
167    ) -> Result<T, Error> {
168        let status = response.status();
169        if !status.is_success() {
170            return Err(self.build_http_error(response).await);
171        }
172        Ok(response.json::<T>().await?)
173    }
174
175    async fn build_http_error(&self, response: Response) -> Error {
176        let status = response.status();
177        let body = response.text().await.unwrap_or_default();
178        Error::HttpStatus {
179            status,
180            api_error: parse_api_error_message(&body),
181            body,
182        }
183    }
184}
185
186impl Context69ClientBuilder {
187    pub fn base_url(mut self, base_url: &str) -> Result<Self, Error> {
188        let mut url =
189            Url::parse(base_url).map_err(|_| Error::InvalidBaseUrl(base_url.to_string()))?;
190        if !url.path().ends_with('/') {
191            let next_path = format!("{}/", url.path());
192            url.set_path(&next_path);
193        }
194        self.base_url = Some(url);
195        Ok(self)
196    }
197
198    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
199        self.user_agent = Some(user_agent.into());
200        self
201    }
202
203    pub fn timeout(mut self, timeout: Duration) -> Result<Self, Error> {
204        if timeout.is_zero() {
205            return Err(Error::InvalidTimeout(timeout));
206        }
207        self.timeout = Some(timeout);
208        Ok(self)
209    }
210
211    pub fn with_personal_access_token(mut self, token: impl Into<String>) -> Result<Self, Error> {
212        self.personal_access_token = Some(validate_personal_access_token(token.into())?);
213        Ok(self)
214    }
215
216    pub fn build(self) -> Result<Context69Client, Error> {
217        let base_url = self
218            .base_url
219            .ok_or_else(|| Error::InvalidBaseUrl("missing base_url".to_string()))?;
220        let mut builder = reqwest::Client::builder();
221        if let Some(user_agent) = self.user_agent {
222            builder = builder.default_headers({
223                let mut headers = reqwest::header::HeaderMap::new();
224                headers.insert(
225                    USER_AGENT,
226                    user_agent
227                        .parse()
228                        .map_err(|_| Error::InvalidHeader(user_agent.clone()))?,
229                );
230                headers
231            });
232        }
233        if let Some(timeout) = self.timeout {
234            builder = builder.timeout(timeout);
235        }
236        let client = builder.build()?;
237        Ok(Context69Client {
238            client,
239            base_url,
240            session: Arc::new(RwLock::new(SessionState {
241                personal_access_token: self.personal_access_token,
242            })),
243        })
244    }
245}
246
247pub(crate) fn file_upload_form(folder_id: Option<Uuid>, files: Vec<Part>) -> Form {
248    let mut form = Form::new();
249    if let Some(folder_id) = folder_id {
250        form = form.text("folder_id", folder_id.to_string());
251    }
252    for file in files {
253        form = form.part("files", file);
254    }
255    form
256}
257
258pub(crate) fn validate_personal_access_token(token: String) -> Result<String, Error> {
259    let trimmed = token.trim();
260    if trimmed.is_empty() {
261        return Err(Error::InvalidPersonalAccessToken(
262            "personal access token must not be empty".to_string(),
263        ));
264    }
265    if !trimmed.starts_with(PERSONAL_ACCESS_TOKEN_PREFIX) {
266        return Err(Error::InvalidPersonalAccessToken(
267            "expected personal access token with ctx_pat_ prefix".to_string(),
268        ));
269    }
270    Ok(trimmed.to_string())
271}
272
273pub(crate) fn parse_api_error_message(body: &str) -> Option<String> {
274    serde_json::from_str::<ApiErrorResponse>(body)
275        .ok()
276        .map(|value| value.error)
277}
278
279#[cfg(test)]
280mod tests;