Skip to main content

context69_sdk/client/
facade.rs

1use std::time::{Duration, Instant};
2
3pub use context69_contracts::{
4    AuthMeResponse, BatchGetDocumentsRequest, BatchGetDocumentsResponse, CancelActiveTasksResponse,
5    CreateMetadataIndexRequest, DeleteBatchRequest, DocumentChunkResponse, DocumentKey,
6    DocumentResponse, EnsureScopeResponse, FileBatchItem, FileBatchRequest, GenericTaskRequest,
7    GroupKind, GroupResponse, HealthResponse, ImportLibraryFileFromUrlRequest as UrlBatchItem,
8    LibraryFileUploadMetadata as FileMetadata, LibraryTextContentFormat as TextContentFormat,
9    MetadataDataType, MetadataFilter, MetadataFilterOperator, MetadataValueKind, PurgeTasksRequest,
10    PurgeTasksResponse, RerunTaskResponse, ScopeMetadataIndex, ScopeSpec, SearchRequest,
11    TaskItemResponse, TaskItemStatus, TaskItemsResponse, TaskKind, TaskListQuery,
12    TaskMaintenanceOverview, TaskPageResponse, TaskProgress, TaskRef, TaskResponse,
13    TaskRetryResponse, TaskStatus, TextBatchRequest, TranslationDirective, TranslationStatus,
14    UpdateTaskMaintenanceSettingsRequest, UpsertLibraryTextRequest as TextBatchItem,
15    UrlBatchRequest, Visibility,
16};
17use reqwest::Method;
18use uuid::Uuid;
19
20use super::{Context69Client, transport::group_path};
21use crate::Error;
22
23#[derive(Debug, Clone)]
24pub struct WaitOptions {
25    pub timeout: Duration,
26    pub initial_backoff: Duration,
27    pub max_backoff: Duration,
28}
29
30impl Default for WaitOptions {
31    fn default() -> Self {
32        Self {
33            timeout: Duration::from_secs(30 * 60),
34            initial_backoff: Duration::from_millis(100),
35            max_backoff: Duration::from_secs(2),
36        }
37    }
38}
39
40#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
41pub struct CompactSearchHit {
42    pub document_id: i64,
43    pub external_id: String,
44    pub title: String,
45    pub summary: Option<String>,
46    pub source_uri: String,
47    pub published_at: Option<chrono::DateTime<chrono::Utc>>,
48    pub score: f32,
49    pub snippet: String,
50}
51
52#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
53pub struct CompactSearchResponse {
54    pub query: String,
55    pub hits: Vec<CompactSearchHit>,
56}
57
58impl Context69Client {
59    pub async fn ensure_scope(&self, spec: &ScopeSpec) -> Result<EnsureScopeResponse, Error> {
60        self.execute_json(
61            self.authorized_request(Method::POST, "/v1/scopes/ensure")
62                .await?
63                .json(spec),
64        )
65        .await
66    }
67
68    pub async fn text_batch(
69        &self,
70        group_path_value: &str,
71        request: &TextBatchRequest,
72    ) -> Result<TaskRef, Error> {
73        self.submit_batch(group_path(group_path_value, "/batch/text"), request)
74            .await
75    }
76
77    pub async fn url_batch(
78        &self,
79        group_path_value: &str,
80        request: &UrlBatchRequest,
81    ) -> Result<TaskRef, Error> {
82        self.submit_batch(group_path(group_path_value, "/batch/url"), request)
83            .await
84    }
85
86    pub async fn file_batch(
87        &self,
88        group_path_value: &str,
89        request: &FileBatchRequest,
90    ) -> Result<TaskRef, Error> {
91        self.submit_batch(group_path(group_path_value, "/batch/file"), request)
92            .await
93    }
94
95    pub async fn delete_batch(
96        &self,
97        group_path_value: &str,
98        request: &DeleteBatchRequest,
99    ) -> Result<TaskRef, Error> {
100        self.submit_batch(group_path(group_path_value, "/batch/delete"), request)
101            .await
102    }
103
104    pub async fn submit_task(&self, request: &GenericTaskRequest) -> Result<TaskRef, Error> {
105        self.submit_batch("/v1/tasks".to_string(), request).await
106    }
107
108    async fn submit_batch<T: serde::Serialize>(
109        &self,
110        path: String,
111        body: &T,
112    ) -> Result<TaskRef, Error> {
113        let key = idempotency_key(&path, body)?;
114        self.execute_json(
115            self.authorized_request(Method::POST, &path)
116                .await?
117                .header("Idempotency-Key", key)
118                .json(body),
119        )
120        .await
121    }
122
123    pub async fn task(&self, task_id: Uuid) -> Result<TaskResponse, Error> {
124        let path = format!("/v1/tasks/{task_id}");
125        self.execute_json(self.authorized_request(Method::GET, &path).await?)
126            .await
127    }
128
129    pub async fn tasks(&self, query: &TaskListQuery) -> Result<TaskPageResponse, Error> {
130        self.execute_json(
131            self.authorized_request(Method::GET, "/v1/tasks")
132                .await?
133                .query(query),
134        )
135        .await
136    }
137
138    pub async fn task_items(
139        &self,
140        task_id: Uuid,
141        cursor: Option<&str>,
142    ) -> Result<TaskItemsResponse, Error> {
143        let path = format!("/v1/tasks/{task_id}/items");
144        let query = [
145            ("limit", "200".to_string()),
146            ("cursor", cursor.unwrap_or("0").to_string()),
147        ];
148        self.execute_json(
149            self.authorized_request(Method::GET, &path)
150                .await?
151                .query(&query),
152        )
153        .await
154    }
155
156    pub async fn wait(&self, task_id: Uuid, timeout: Duration) -> Result<TaskResponse, Error> {
157        self.wait_with_options(
158            task_id,
159            WaitOptions {
160                timeout,
161                ..WaitOptions::default()
162            },
163        )
164        .await
165    }
166
167    pub async fn wait_with_options(
168        &self,
169        task_id: Uuid,
170        options: WaitOptions,
171    ) -> Result<TaskResponse, Error> {
172        if options.timeout.is_zero()
173            || options.initial_backoff.is_zero()
174            || options.max_backoff.is_zero()
175        {
176            return Err(Error::InvalidTimeout(options.timeout));
177        }
178        let started = Instant::now();
179        let mut delay = options.initial_backoff;
180        loop {
181            if started.elapsed() >= options.timeout {
182                return Err(Error::TaskWaitTimeout {
183                    task_id,
184                    timeout: options.timeout,
185                });
186            }
187            match self.task(task_id).await {
188                Ok(task) => {
189                    if matches!(
190                        task.status,
191                        context69_contracts::TaskStatus::Succeeded
192                            | context69_contracts::TaskStatus::Failed
193                            | context69_contracts::TaskStatus::Cancelled
194                    ) {
195                        return Ok(task);
196                    }
197                }
198                Err(error) if error.is_retryable() => {}
199                Err(error) => return Err(error),
200            }
201            tokio::time::sleep(delay.min(options.timeout.saturating_sub(started.elapsed()))).await;
202            delay = (delay + delay).min(options.max_backoff);
203        }
204    }
205
206    pub async fn retry_task(&self, task_id: Uuid) -> Result<TaskRetryResponse, Error> {
207        let path = format!("/v1/tasks/{task_id}/retry");
208        self.execute_json(self.authorized_request(Method::POST, &path).await?)
209            .await
210    }
211
212    pub async fn rerun_task(&self, task_id: Uuid) -> Result<RerunTaskResponse, Error> {
213        let path = format!("/v1/tasks/{task_id}/rerun");
214        self.execute_json(self.authorized_request(Method::POST, &path).await?)
215            .await
216    }
217
218    pub async fn cancel_task(&self, task_id: Uuid) -> Result<(), Error> {
219        let path = format!("/v1/tasks/{task_id}/cancel");
220        self.execute_empty(self.authorized_request(Method::POST, &path).await?)
221            .await
222    }
223
224    pub async fn task_maintenance(&self) -> Result<TaskMaintenanceOverview, Error> {
225        self.execute_json(
226            self.authorized_request(Method::GET, "/v1/admin/tasks/maintenance")
227                .await?,
228        )
229        .await
230    }
231
232    pub async fn update_task_maintenance(
233        &self,
234        request: &UpdateTaskMaintenanceSettingsRequest,
235    ) -> Result<TaskMaintenanceOverview, Error> {
236        self.execute_json(
237            self.authorized_request(Method::PUT, "/v1/admin/tasks/maintenance")
238                .await?
239                .json(request),
240        )
241        .await
242    }
243
244    pub async fn cancel_active_tasks(&self) -> Result<CancelActiveTasksResponse, Error> {
245        self.execute_json(
246            self.authorized_request(Method::POST, "/v1/admin/tasks/cancel-active")
247                .await?,
248        )
249        .await
250    }
251
252    pub async fn purge_tasks(
253        &self,
254        request: &PurgeTasksRequest,
255    ) -> Result<PurgeTasksResponse, Error> {
256        self.execute_json(
257            self.authorized_request(Method::POST, "/v1/admin/tasks/purge")
258                .await?
259                .json(request),
260        )
261        .await
262    }
263
264    pub async fn search_compact(
265        &self,
266        request: &SearchRequest,
267    ) -> Result<CompactSearchResponse, Error> {
268        let response: context69_contracts::SearchResponse = self
269            .execute_json(
270                self.authorized_request(Method::POST, "/v1/search")
271                    .await?
272                    .json(request),
273            )
274            .await?;
275        Ok(CompactSearchResponse {
276            query: response.query,
277            hits: response
278                .items
279                .into_iter()
280                .map(|hit| CompactSearchHit {
281                    document_id: hit.document_id,
282                    external_id: hit.external_id,
283                    title: hit.title,
284                    summary: hit.summary,
285                    source_uri: hit.source_uri,
286                    published_at: hit.published_at,
287                    score: hit.score,
288                    snippet: hit.chunk_text.chars().take(320).collect(),
289                })
290                .collect(),
291        })
292    }
293
294    pub async fn get_document(
295        &self,
296        document_id: i64,
297        locale: Option<&str>,
298    ) -> Result<DocumentResponse, Error> {
299        let path = format!("/v1/documents/{document_id}");
300        self.execute_json(
301            self.authorized_request(Method::GET, &path)
302                .await?
303                .query(&[("locale", locale.unwrap_or("").to_string())]),
304        )
305        .await
306    }
307
308    pub async fn get_document_by_key(
309        &self,
310        group_path_value: &str,
311        key: &DocumentKey,
312        locale: Option<&str>,
313    ) -> Result<DocumentResponse, Error> {
314        let path = group_path(group_path_value, "/documents/by-external-id");
315        self.execute_json(self.authorized_request(Method::GET, &path).await?.query(&[
316            ("source_key", key.source_key.clone()),
317            ("external_id", key.external_id.clone()),
318            ("locale", locale.unwrap_or("").to_string()),
319        ]))
320        .await
321    }
322
323    pub async fn get_documents(
324        &self,
325        group_path_value: &str,
326        request: &BatchGetDocumentsRequest,
327    ) -> Result<BatchGetDocumentsResponse, Error> {
328        let path = group_path(group_path_value, "/documents/batch-get");
329        self.execute_json(
330            self.authorized_request(Method::POST, &path)
331                .await?
332                .json(request),
333        )
334        .await
335    }
336
337    pub async fn me(&self) -> Result<AuthMeResponse, Error> {
338        self.execute_json(self.authorized_request(Method::GET, "/v1/auth/me").await?)
339            .await
340    }
341
342    pub async fn healthz(&self) -> Result<HealthResponse, Error> {
343        let response = self.client.get(self.url("/healthz")?).send().await?;
344        self.read_json_response(response).await
345    }
346}
347
348fn idempotency_key<T: serde::Serialize>(path: &str, body: &T) -> Result<String, Error> {
349    use sha2::{Digest, Sha256};
350
351    let mut hasher = Sha256::new();
352    hasher.update(path.as_bytes());
353    hasher.update([0]);
354    hasher.update(serde_json::to_vec(body)?);
355    Ok(format!(
356        "ctx69-sdk-{}",
357        hasher
358            .finalize()
359            .iter()
360            .map(|byte| format!("{byte:02x}"))
361            .collect::<String>()
362    ))
363}
364
365#[cfg(test)]
366mod tests {
367    use super::idempotency_key;
368
369    #[test]
370    fn batch_idempotency_key_is_stable_for_the_same_request() {
371        let first = idempotency_key(
372            "/v1/groups/by-path/research/batch/text",
373            &serde_json::json!({"items":[{"external_id":"a"}]}),
374        )
375        .expect("key");
376        let second = idempotency_key(
377            "/v1/groups/by-path/research/batch/text",
378            &serde_json::json!({"items":[{"external_id":"a"}]}),
379        )
380        .expect("key");
381        let different = idempotency_key(
382            "/v1/groups/by-path/research/batch/text",
383            &serde_json::json!({"items":[{"external_id":"b"}]}),
384        )
385        .expect("key");
386        assert_eq!(first, second);
387        assert_ne!(first, different);
388    }
389}