1use crate::error::{ErrorData, Result};
2use crate::traits::{Binding, Kv, PutOptions, ScanResult};
3use alien_error::{AlienError, Context, IntoAlienError};
4use alien_gcp_clients::firestore::{
5 CollectionSelector, Direction, Document, FieldFilter, FieldFilterOperator, FieldReference,
6 Filter, FirestoreApi, FirestoreClient, Order, QueryType, RunQueryRequest, StructuredQuery,
7 Value,
8};
9use async_trait::async_trait;
10use base64::{self, Engine};
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::fmt::{Debug, Formatter};
15
16use super::{validate_key, validate_value};
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20struct KvDocument {
21 value: String, created_at: DateTime<Utc>,
23 expires_at: Option<DateTime<Utc>>, }
25
26pub struct GcpFirestoreKv {
28 client: FirestoreClient,
29 project_id: String,
30 database_id: String,
31 collection_name: String,
32}
33
34impl Debug for GcpFirestoreKv {
35 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
36 f.debug_struct("GcpFirestoreKv")
37 .field("project_id", &self.project_id)
38 .field("database_id", &self.database_id)
39 .field("collection_name", &self.collection_name)
40 .finish()
41 }
42}
43
44impl GcpFirestoreKv {
45 pub fn new(
46 client: FirestoreClient,
47 project_id: String,
48 database_id: String,
49 collection_name: String,
50 ) -> Result<Self> {
51 Ok(Self {
52 client,
53 project_id,
54 database_id,
55 collection_name,
56 })
57 }
58
59 fn is_expired(&self, expires_at: Option<DateTime<Utc>>) -> bool {
61 if let Some(expiry) = expires_at {
62 Utc::now() >= expiry
63 } else {
64 false
65 }
66 }
67
68 fn kv_document_to_firestore(&self, _key: &str, kv_doc: &KvDocument) -> Document {
70 let mut fields = HashMap::new();
71
72 fields.insert(
73 "value".to_string(),
74 Value::StringValue(kv_doc.value.clone()),
75 );
76 fields.insert(
77 "created_at".to_string(),
78 Value::TimestampValue(kv_doc.created_at.to_rfc3339()),
79 );
80
81 if let Some(expires_at) = kv_doc.expires_at {
82 fields.insert(
83 "expires_at".to_string(),
84 Value::TimestampValue(expires_at.to_rfc3339()),
85 );
86 }
87
88 Document::builder().fields(fields).build()
89 }
90
91 fn kv_document_to_firestore_with_name(&self, key: &str, kv_doc: &KvDocument) -> Document {
93 let mut fields = HashMap::new();
94
95 fields.insert(
96 "value".to_string(),
97 Value::StringValue(kv_doc.value.clone()),
98 );
99 fields.insert(
100 "created_at".to_string(),
101 Value::TimestampValue(kv_doc.created_at.to_rfc3339()),
102 );
103
104 if let Some(expires_at) = kv_doc.expires_at {
105 fields.insert(
106 "expires_at".to_string(),
107 Value::TimestampValue(expires_at.to_rfc3339()),
108 );
109 }
110
111 Document::builder()
112 .name(format!(
113 "projects/{}/databases/{}/documents/{}/{}",
114 self.project_id, self.database_id, self.collection_name, key
115 ))
116 .fields(fields)
117 .build()
118 }
119
120 async fn try_take_over_expired(&self, key: &str, document: &Document) -> Result<bool> {
128 use alien_client_core::ErrorData as CloudErrorData;
129 use alien_gcp_clients::gcp::firestore::{Precondition, PreconditionType};
130
131 let document_path = format!("{}/{}", self.collection_name, key);
132 let existing = match self
133 .client
134 .get_document(
135 self.database_id.clone(),
136 document_path.clone(),
137 None,
138 None,
139 None,
140 )
141 .await
142 {
143 Ok(existing) => existing,
144 Err(e)
145 if matches!(
146 e.error.as_ref(),
147 Some(CloudErrorData::RemoteResourceNotFound { .. })
148 ) =>
149 {
150 return Ok(false);
151 }
152 Err(e) => {
153 return Err(crate::error::map_cloud_client_error(
154 e,
155 format!("Failed to read existing document for key '{}'", key),
156 Some(key.to_string()),
157 ));
158 }
159 };
160
161 let kv_doc = self.firestore_to_kv_document(&existing)?;
162 if !self.is_expired(kv_doc.expires_at) {
163 return Ok(false);
164 }
165 let Some(update_time) = existing.update_time.clone() else {
166 return Ok(false);
167 };
168
169 match self
170 .client
171 .patch_document(
172 self.database_id.clone(),
173 document_path,
174 document.clone(),
175 None,
176 None,
177 Some(Precondition {
178 condition: PreconditionType::UpdateTime(update_time),
179 }),
180 )
181 .await
182 {
183 Ok(_) => Ok(true),
184 Err(e) => match e.error.as_ref() {
185 Some(CloudErrorData::RemoteResourceConflict { .. })
189 | Some(CloudErrorData::RemoteResourceNotFound { .. }) => Ok(false),
190 _ => Err(crate::error::map_cloud_client_error(
191 e,
192 format!("Failed to take over expired document for key '{}'", key),
193 Some(key.to_string()),
194 )),
195 },
196 }
197 }
198
199 fn firestore_to_kv_document(&self, doc: &Document) -> Result<KvDocument> {
200 let fields = doc.fields.as_ref().ok_or_else(|| {
201 AlienError::new(ErrorData::UnexpectedResponseFormat {
202 provider: "gcp".to_string(),
203 binding_name: "firestore".to_string(),
204 field: "fields".to_string(),
205 response_json: serde_json::to_string(doc).unwrap_or_default(),
206 })
207 })?;
208
209 let value = match fields.get("value") {
210 Some(Value::StringValue(v)) => v.clone(),
211 _ => {
212 return Err(AlienError::new(ErrorData::UnexpectedResponseFormat {
213 provider: "gcp".to_string(),
214 binding_name: "firestore".to_string(),
215 field: "value".to_string(),
216 response_json: serde_json::to_string(doc).unwrap_or_default(),
217 }))
218 }
219 };
220
221 let created_at = match fields.get("created_at") {
222 Some(Value::TimestampValue(t)) => DateTime::parse_from_rfc3339(t)
223 .map_err(|_| {
224 AlienError::new(ErrorData::UnexpectedResponseFormat {
225 provider: "gcp".to_string(),
226 binding_name: "firestore".to_string(),
227 field: "created_at".to_string(),
228 response_json: serde_json::to_string(doc).unwrap_or_default(),
229 })
230 })?
231 .with_timezone(&Utc),
232 _ => {
233 return Err(AlienError::new(ErrorData::UnexpectedResponseFormat {
234 provider: "gcp".to_string(),
235 binding_name: "firestore".to_string(),
236 field: "created_at".to_string(),
237 response_json: serde_json::to_string(doc).unwrap_or_default(),
238 }))
239 }
240 };
241
242 let expires_at = match fields.get("expires_at") {
243 Some(Value::TimestampValue(t)) => Some(
244 DateTime::parse_from_rfc3339(t)
245 .map_err(|_| {
246 AlienError::new(ErrorData::UnexpectedResponseFormat {
247 provider: "gcp".to_string(),
248 binding_name: "firestore".to_string(),
249 field: "expires_at".to_string(),
250 response_json: serde_json::to_string(doc).unwrap_or_default(),
251 })
252 })?
253 .with_timezone(&Utc),
254 ),
255 _ => None,
256 };
257
258 Ok(KvDocument {
259 value,
260 created_at,
261 expires_at,
262 })
263 }
264}
265
266impl Binding for GcpFirestoreKv {}
267
268#[async_trait]
269impl Kv for GcpFirestoreKv {
270 async fn get(&self, key: &str) -> Result<Option<Vec<u8>>> {
271 validate_key(key)?;
272
273 let document_id = key;
274 let document_path = format!("{}/{}", self.collection_name, document_id);
275
276 match self
277 .client
278 .get_document(self.database_id.clone(), document_path, None, None, None)
279 .await
280 {
281 Ok(doc) => {
282 let kv_doc = self.firestore_to_kv_document(&doc)?;
283
284 if self.is_expired(kv_doc.expires_at) {
286 return Ok(None); }
288
289 let value = base64::engine::general_purpose::STANDARD
290 .decode(&kv_doc.value)
291 .into_alien_error()
292 .context(ErrorData::KvOperationFailed {
293 operation: "get".to_string(),
294 key: key.to_string(),
295 reason: "Failed to decode base64 value".to_string(),
296 })?;
297
298 Ok(Some(value))
299 }
300 Err(e) => {
301 match &e.error {
303 Some(alien_client_core::ErrorData::RemoteResourceNotFound { .. }) => {
304 Ok(None) }
306 _ => Err(crate::error::map_cloud_client_error(
307 e,
308 "Failed to get Firestore document".to_string(),
309 Some(key.to_string()),
310 )),
311 }
312 }
313 }
314 }
315
316 async fn put(&self, key: &str, value: Vec<u8>, options: Option<PutOptions>) -> Result<bool> {
317 validate_key(key)?;
318 validate_value(&value)?;
319
320 let options = options.unwrap_or_default();
321
322 let encoded_value = base64::engine::general_purpose::STANDARD.encode(&value);
323 let kv_doc = KvDocument {
324 value: encoded_value,
325 created_at: Utc::now(),
326 expires_at: options.ttl.map(|d| Utc::now() + d),
327 };
328
329 let document = self.kv_document_to_firestore(key, &kv_doc);
330
331 if options.if_not_exists {
332 let document_id = key.to_string();
333 match self
334 .client
335 .create_document(
336 self.database_id.clone(),
337 self.collection_name.clone(),
338 Some(document_id),
339 document.clone(),
340 None,
341 )
342 .await
343 {
344 Ok(_) => Ok(true),
345 Err(e) => {
346 match &e.error {
348 Some(alien_client_core::ErrorData::RemoteResourceConflict { .. }) => {
349 self.try_take_over_expired(key, &document).await
356 }
357 _ => Err(crate::error::map_cloud_client_error(
358 e,
359 "Failed to create Firestore document".to_string(),
360 Some(key.to_string()),
361 )),
362 }
363 }
364 }
365 } else {
366 let document_id = key;
367 let document_path = format!("{}/{}", self.collection_name, document_id);
368 let document_with_name = self.kv_document_to_firestore_with_name(key, &kv_doc);
369
370 self.client
371 .patch_document(
372 self.database_id.clone(),
373 document_path,
374 document_with_name,
375 None,
376 None,
377 None,
378 )
379 .await
380 .map_err(|e| {
381 crate::error::map_cloud_client_error(
382 e,
383 "Failed to patch Firestore document".to_string(),
384 Some(key.to_string()),
385 )
386 })?;
387
388 Ok(true)
389 }
390 }
391
392 async fn delete(&self, key: &str) -> Result<()> {
393 validate_key(key)?;
394
395 let document_id = key;
396 let document_path = format!("{}/{}", self.collection_name, document_id);
397
398 self.client
399 .delete_document(self.database_id.clone(), document_path, None)
400 .await
401 .map_err(|e| {
402 crate::error::map_cloud_client_error(
403 e,
404 "Failed to delete Firestore document".to_string(),
405 Some(key.to_string()),
406 )
407 })?;
408
409 Ok(())
410 }
411
412 async fn exists(&self, key: &str) -> Result<bool> {
413 validate_key(key)?;
414
415 let document_id = key;
416 let document_path = format!("{}/{}", self.collection_name, document_id);
417
418 match self
419 .client
420 .get_document(self.database_id.clone(), document_path, None, None, None)
421 .await
422 {
423 Ok(doc) => {
424 let kv_doc = self.firestore_to_kv_document(&doc)?;
425
426 Ok(!self.is_expired(kv_doc.expires_at))
428 }
429 Err(e) => {
430 match &e.error {
431 Some(alien_client_core::ErrorData::RemoteResourceNotFound { .. }) => {
432 Ok(false) }
434 _ => Err(crate::error::map_cloud_client_error(
435 e,
436 "Failed to get Firestore document".to_string(),
437 Some(key.to_string()),
438 )),
439 }
440 }
441 }
442 }
443
444 async fn scan_prefix(
445 &self,
446 prefix: &str,
447 limit: Option<usize>,
448 cursor: Option<String>,
449 ) -> Result<ScanResult> {
450 validate_key(prefix)?; let collection_selector = CollectionSelector::builder()
453 .collection_id(self.collection_name.clone())
454 .build();
455
456 let mut structured_query = StructuredQuery::builder()
457 .from(vec![collection_selector])
458 .order_by(vec![Order::builder()
459 .field(
460 FieldReference::builder()
461 .field_path("__name__".to_string())
462 .build(),
463 )
464 .direction(Direction::Ascending)
465 .build()])
466 .build();
467
468 if !prefix.is_empty() {
470 let document_id_prefix = prefix;
471 let prefix_filter = Filter::FieldFilter(
472 FieldFilter::builder()
473 .field(
474 FieldReference::builder()
475 .field_path("__name__".to_string())
476 .build(),
477 )
478 .op(FieldFilterOperator::GreaterThanOrEqual)
479 .value(Value::ReferenceValue(format!(
480 "projects/{}/databases/{}/documents/{}/{}",
481 self.project_id, self.database_id, self.collection_name, document_id_prefix
482 )))
483 .build(),
484 );
485
486 structured_query.r#where = Some(prefix_filter);
487 }
488
489 if let Some(limit) = limit {
490 structured_query.limit = Some(limit as i32);
491 }
492
493 if let Some(ref cursor) = cursor {
494 if let Ok(offset) = cursor.parse::<i32>() {
496 structured_query.offset = Some(offset);
497 }
498 }
499
500 let query_request = RunQueryRequest::builder()
501 .parent(format!(
502 "projects/{}/databases/{}/documents",
503 self.project_id, self.database_id
504 ))
505 .query_type(QueryType::StructuredQuery(structured_query))
506 .build();
507
508 let query_responses = self
509 .client
510 .run_query(self.database_id.clone(), query_request)
511 .await
512 .map_err(|e| {
513 crate::error::map_cloud_client_error(
514 e,
515 "Failed to run Firestore query".to_string(),
516 Some(prefix.to_string()),
517 )
518 })?;
519
520 let items: Vec<(String, Vec<u8>)> = query_responses
521 .iter()
522 .filter_map(|response| {
523 let doc = response.document.as_ref()?;
524 let doc_name = doc.name.as_ref()?;
525
526 let document_id = doc_name.split('/').last()?.to_string();
528
529 let key = document_id;
531
532 if !key.starts_with(prefix) {
534 return None;
535 }
536
537 let kv_doc = self.firestore_to_kv_document(doc).ok()?;
538
539 if self.is_expired(kv_doc.expires_at) {
541 return None; }
543
544 let value = base64::engine::general_purpose::STANDARD
545 .decode(&kv_doc.value)
546 .ok()?;
547 Some((key, value))
548 })
549 .collect();
550
551 let next_cursor = if items.len() == limit.unwrap_or(usize::MAX) {
552 let current_offset = cursor
554 .as_ref()
555 .and_then(|c| c.parse::<usize>().ok())
556 .unwrap_or(0);
557 Some((current_offset + items.len()).to_string())
558 } else {
559 None
560 };
561
562 Ok(ScanResult { items, next_cursor })
563 }
564}