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;