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