1use crate::config::{GcsFileFormat, GcsSourceConfig};
4use async_trait::async_trait;
5use faucet_common_gcs::{build_storage, build_storage_control};
6use faucet_core::{FaucetError, Stream, StreamPage};
7use futures::stream::{self, StreamExt, TryStreamExt};
8use google_cloud_gax::paginator::ItemPaginator;
9use google_cloud_storage::client::{Storage, StorageControl};
10use serde_json::Value;
11use std::pin::Pin;
12use tokio::io::AsyncBufReadExt;
13
14pub struct GcsSource {
16 config: GcsSourceConfig,
17 storage: Storage,
18 control: StorageControl,
19}
20
21impl GcsSource {
22 pub async fn new(config: GcsSourceConfig) -> Result<Self, FaucetError> {
25 let storage = build_storage(&config.auth, config.storage_host.as_deref()).await?;
26 let control = build_storage_control(&config.auth, config.storage_host.as_deref()).await?;
27 Ok(Self {
28 config,
29 storage,
30 control,
31 })
32 }
33
34 fn bucket_path(&self) -> String {
36 format!("projects/_/buckets/{}", self.config.bucket)
37 }
38
39 async fn list_object_names(
42 &self,
43 prefix_override: Option<&str>,
44 ) -> Result<Vec<String>, FaucetError> {
45 if let Some(ref keys) = self.config.object_keys {
46 return Ok(cap_keys(keys.clone(), self.config.max_objects));
47 }
48
49 let effective_prefix = prefix_override.or(self.config.prefix.as_deref());
50 let mut req = self.control.list_objects().set_parent(self.bucket_path());
51 if let Some(p) = effective_prefix {
52 req = req.set_prefix(p.to_string());
53 }
54 req = req.set_page_size(1000_i32);
55
56 let mut paginator = req.by_item();
57 let mut names: Vec<String> = Vec::new();
58 while let Some(item) = paginator.next().await {
59 let object = item.map_err(|e| {
60 FaucetError::Source(format!(
61 "GCS list error for bucket '{}': {e}",
62 self.config.bucket
63 ))
64 })?;
65 if object.name.is_empty() {
66 continue;
67 }
68 names.push(object.name);
69 if let Some(max) = self.config.max_objects
70 && names.len() >= max
71 {
72 break;
73 }
74 }
75 Ok(names)
76 }
77
78 async fn read_object_text(&self, key: &str) -> Result<String, FaucetError> {
80 use tokio::io::AsyncReadExt as _;
86 let mut reader = self.open_object_reader(key).await?;
87 let mut text = String::new();
88 reader.read_to_string(&mut text).await.map_err(|e| {
89 FaucetError::Source(format!(
90 "GCS read/decode error for key '{key}' (not valid UTF-8?): {e}"
91 ))
92 })?;
93 Ok(text)
94 }
95
96 async fn open_object_reader(
101 &self,
102 key: &str,
103 ) -> Result<std::pin::Pin<Box<dyn tokio::io::AsyncBufRead + Send + Unpin>>, FaucetError> {
104 let resp = self
105 .storage
106 .read_object(self.bucket_path(), key.to_string())
107 .send()
108 .await
109 .map_err(|e| {
110 FaucetError::Source(format!(
111 "GCS get error for bucket '{}' key '{key}': {e}",
112 self.config.bucket
113 ))
114 })?;
115 let highlights = resp.object();
119 let mut checks: Vec<Box<dyn faucet_core::IntegrityCheck>> = Vec::new();
120 match crate::verify::length_check(
121 highlights.size,
122 &highlights.content_encoding,
123 self.config.verify_length,
124 ) {
125 Some(check) => checks.push(check),
126 None if self.config.verify_length => tracing::debug!(
127 key = %key,
128 size = highlights.size,
129 content_encoding = %highlights.content_encoding,
130 "GCS object length verification skipped (no size or transcoded encoding)"
131 ),
132 None => {}
133 }
134 if self.config.verify_checksum {
135 let (crc32c, md5) = match &highlights.checksums {
136 Some(c) => (c.crc32c, c.md5_hash.clone()),
137 None => (None, bytes::Bytes::new()),
138 };
139 match crate::verify::checksum_check(crc32c, &md5, &highlights.content_encoding) {
140 Some(check) => checks.push(check),
141 None if highlights.content_encoding.is_empty() => tracing::warn!(
142 key = %key,
143 "verify_checksum is enabled but GCS advertised no verifiable checksum for \
144 this object; relying on the length check only"
145 ),
146 None => {}
147 }
148 }
149
150 let bytes_stream = resp
151 .into_stream()
152 .map_err(|e| std::io::Error::other(e.to_string()));
153 let verified = faucet_core::VerifyingReader::new(
156 tokio_util::io::StreamReader::new(bytes_stream),
157 checks,
158 );
159 let buffered = tokio::io::BufReader::new(verified);
160 #[cfg(feature = "compression")]
161 {
162 let codec = self.config.compression.resolve(key);
163 faucet_core::compression::warn_mismatch(key, codec);
164 Ok(faucet_core::compression::wrap_async_reader(buffered, codec))
165 }
166 #[cfg(not(feature = "compression"))]
167 {
168 Ok(Box::pin(buffered))
169 }
170 }
171
172 fn parse_content(&self, key: &str, text: &str) -> Result<Vec<Value>, FaucetError> {
174 parse_file_content(&self.config.file_format, key, text)
175 }
176}
177
178pub(crate) fn parse_file_content(
185 format: &GcsFileFormat,
186 key: &str,
187 text: &str,
188) -> Result<Vec<Value>, FaucetError> {
189 match format {
190 GcsFileFormat::JsonLines => {
191 let mut records = Vec::new();
192 for (line_num, line) in text.lines().enumerate() {
193 let trimmed = line.trim();
194 if trimmed.is_empty() {
195 continue;
196 }
197 let value: Value = serde_json::from_str(trimmed).map_err(|e| {
198 FaucetError::Source(format!(
199 "GCS JSON parse error in '{key}' at line {}: {e}",
200 line_num + 1
201 ))
202 })?;
203 records.push(value);
204 }
205 Ok(records)
206 }
207 GcsFileFormat::JsonArray => {
208 let value: Value = serde_json::from_str(text).map_err(|e| {
209 FaucetError::Source(format!("GCS JSON parse error in '{key}': {e}"))
210 })?;
211 match value {
212 Value::Array(arr) => Ok(arr),
213 other => Err(FaucetError::Source(format!(
214 "GCS expected JSON array in '{key}', got {}",
215 value_type_name(&other)
216 ))),
217 }
218 }
219 GcsFileFormat::RawText => Ok(vec![serde_json::json!({
220 "key": key,
221 "content": text,
222 })]),
223 }
224}
225
226#[async_trait]
227impl faucet_core::Source for GcsSource {
228 async fn fetch_with_context(
229 &self,
230 context: &std::collections::HashMap<String, Value>,
231 ) -> Result<Vec<Value>, FaucetError> {
232 let substituted_prefix: Option<String> = if !context.is_empty() {
233 self.config
234 .prefix
235 .as_ref()
236 .map(|p| faucet_core::util::substitute_context(p, context))
237 } else {
238 None
239 };
240
241 let keys = self
242 .list_object_names(substituted_prefix.as_deref())
243 .await?;
244 tracing::info!(
245 bucket = %self.config.bucket,
246 objects = keys.len(),
247 "Listed GCS objects",
248 );
249
250 let concurrency = self.config.concurrency.max(1);
251 let results: Vec<Vec<Value>> = stream::iter(keys)
252 .map(|key| async move {
253 let text = self.read_object_text(&key).await?;
254 let records = self.parse_content(&key, &text)?;
255 tracing::debug!(key = %key, records = records.len(), "Read GCS object");
256 Ok::<Vec<Value>, FaucetError>(records)
257 })
258 .buffer_unordered(concurrency)
259 .try_collect()
260 .await?;
261
262 let all_records: Vec<Value> = results.into_iter().flatten().collect();
263 tracing::info!(total_records = all_records.len(), "GCS fetch complete");
264 Ok(all_records)
265 }
266
267 fn stream_pages<'a>(
271 &'a self,
272 context: &'a std::collections::HashMap<String, Value>,
273 _batch_size: usize,
274 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
275 let batch_size = self.config.batch_size;
276
277 Box::pin(async_stream::try_stream! {
278 let substituted_prefix: Option<String> = if !context.is_empty() {
279 self.config
280 .prefix
281 .as_ref()
282 .map(|p| faucet_core::util::substitute_context(p, context))
283 } else {
284 None
285 };
286
287 let keys = self.list_object_names(substituted_prefix.as_deref()).await?;
288 tracing::info!(
289 bucket = %self.config.bucket,
290 objects = keys.len(),
291 "Listed GCS objects (stream)",
292 );
293
294 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
295 let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
296 let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
297 let mut total = 0usize;
298
299 for key in &keys {
300 match self.config.file_format {
301 GcsFileFormat::JsonLines => {
302 let reader = self.open_object_reader(key).await?;
303 let mut lines = reader.lines();
304 let mut line_num: usize = 0;
305 while let Some(line) = lines
306 .next_line()
307 .await
308 .map_err(|e| FaucetError::Source(format!(
309 "GCS read body error for key '{key}': {e}"
310 )))?
311 {
312 line_num += 1;
313 let trimmed = line.trim();
314 if trimmed.is_empty() { continue; }
315 let value: Value = serde_json::from_str(trimmed).map_err(|e| {
316 FaucetError::Source(format!(
317 "GCS JSON parse error in '{key}' at line {line_num}: {e}",
318 ))
319 })?;
320 buffer.push(value);
321 if batch_size != 0 && buffer.len() >= chunk {
322 let page = std::mem::replace(
323 &mut buffer,
324 Vec::with_capacity(initial_capacity),
325 );
326 total += page.len();
327 yield StreamPage { records: page, bookmark: None };
328 }
329 }
330 if batch_size == 0 && !buffer.is_empty() {
331 let page = std::mem::take(&mut buffer);
332 total += page.len();
333 yield StreamPage { records: page, bookmark: None };
334 }
335 }
336 GcsFileFormat::RawText => {
337 let text = self.read_object_text(key).await?;
338 let record = serde_json::json!({ "key": key, "content": text });
339 buffer.push(record);
340 if batch_size == 0 {
341 let page = std::mem::take(&mut buffer);
342 total += page.len();
343 yield StreamPage { records: page, bookmark: None };
344 } else if buffer.len() >= chunk {
345 let page = std::mem::replace(
346 &mut buffer,
347 Vec::with_capacity(initial_capacity),
348 );
349 total += page.len();
350 yield StreamPage { records: page, bookmark: None };
351 }
352 }
353 GcsFileFormat::JsonArray => {
354 let text = self.read_object_text(key).await?;
355 let value: Value = serde_json::from_str(&text).map_err(|e| {
356 FaucetError::Source(format!("GCS JSON parse error in '{key}': {e}"))
357 })?;
358 let array = match value {
359 Value::Array(arr) => arr,
360 other => Err(FaucetError::Source(format!(
361 "GCS expected JSON array in '{key}', got {}",
362 value_type_name(&other)
363 )))?,
364 };
365 if batch_size == 0 {
366 if !buffer.is_empty() {
367 let page = std::mem::take(&mut buffer);
368 total += page.len();
369 yield StreamPage { records: page, bookmark: None };
370 }
371 total += array.len();
372 yield StreamPage { records: array, bookmark: None };
373 } else {
374 for record in array {
375 buffer.push(record);
376 if buffer.len() >= chunk {
377 let page = std::mem::replace(
378 &mut buffer,
379 Vec::with_capacity(initial_capacity),
380 );
381 total += page.len();
382 yield StreamPage { records: page, bookmark: None };
383 }
384 }
385 }
386 }
387 }
388 }
389
390 if !buffer.is_empty() {
391 let page = std::mem::take(&mut buffer);
392 total += page.len();
393 yield StreamPage { records: page, bookmark: None };
394 }
395
396 tracing::info!(
397 total_records = total,
398 batch_size,
399 objects = keys.len(),
400 "GCS source stream complete",
401 );
402 })
403 }
404
405 fn config_schema(&self) -> Value {
406 serde_json::to_value(faucet_core::schema_for!(GcsSourceConfig))
407 .expect("schema serialization")
408 }
409
410 fn connector_name(&self) -> &'static str {
411 "gcs"
412 }
413
414 fn dataset_uri(&self) -> String {
415 match &self.config.prefix {
416 Some(p) => format!("gs://{}/{}", self.config.bucket, p),
417 None => format!("gs://{}", self.config.bucket),
418 }
419 }
420}
421
422fn cap_keys(mut keys: Vec<String>, max: Option<usize>) -> Vec<String> {
429 if let Some(n) = max {
430 keys.truncate(n);
431 }
432 keys
433}
434
435fn value_type_name(v: &Value) -> &'static str {
436 match v {
437 Value::Null => "null",
438 Value::Bool(_) => "boolean",
439 Value::Number(_) => "number",
440 Value::String(_) => "string",
441 Value::Array(_) => "array",
442 Value::Object(_) => "object",
443 }
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449 use serde_json::json;
450
451 #[cfg(feature = "compression")]
452 #[test]
453 fn compression_default_is_auto() {
454 let cfg = GcsSourceConfig::new("bucket");
455 assert_eq!(cfg.compression, faucet_core::CompressionConfig::Auto);
456 }
457
458 #[test]
459 fn value_type_name_covers_all_json_variants() {
460 assert_eq!(value_type_name(&Value::Null), "null");
461 assert_eq!(value_type_name(&json!(true)), "boolean");
462 assert_eq!(value_type_name(&json!(7)), "number");
463 assert_eq!(value_type_name(&json!("s")), "string");
464 assert_eq!(value_type_name(&json!([1, 2])), "array");
465 assert_eq!(value_type_name(&json!({"k": 1})), "object");
466 }
467
468 #[test]
469 fn parse_json_lines() {
470 let r =
471 parse_file_content(&GcsFileFormat::JsonLines, "t", "{\"id\":1}\n{\"id\":2}\n").unwrap();
472 assert_eq!(r.len(), 2);
473 assert_eq!(r[0]["id"], 1);
474 }
475
476 #[test]
477 fn parse_json_lines_skips_blanks() {
478 let r = parse_file_content(
479 &GcsFileFormat::JsonLines,
480 "t",
481 "{\"id\":1}\n\n{\"id\":2}\n\n",
482 )
483 .unwrap();
484 assert_eq!(r.len(), 2);
485 }
486
487 #[test]
488 fn parse_json_lines_reports_line_number() {
489 let err = parse_file_content(&GcsFileFormat::JsonLines, "t", "{\"id\":1}\nbad-line\n")
490 .unwrap_err();
491 let msg = err.to_string();
492 assert!(msg.contains("line 2"), "unexpected: {msg}");
493 }
494
495 #[test]
496 fn parse_json_array() {
497 let r = parse_file_content(
498 &GcsFileFormat::JsonArray,
499 "t.json",
500 "[{\"id\":1},{\"id\":2}]",
501 )
502 .unwrap();
503 assert_eq!(r.len(), 2);
504 }
505
506 #[test]
507 fn parse_json_array_rejects_non_array() {
508 let err =
509 parse_file_content(&GcsFileFormat::JsonArray, "t.json", "{\"id\":1}").unwrap_err();
510 assert!(err.to_string().contains("expected JSON array"));
511 }
512
513 #[test]
514 fn parse_raw_text_yields_single_record() {
515 let r = parse_file_content(&GcsFileFormat::RawText, "p/f.txt", "hello").unwrap();
516 assert_eq!(r, vec![json!({"key": "p/f.txt", "content": "hello"})]);
517 }
518
519 #[test]
520 fn cap_keys_truncates_explicit_list_to_max_objects() {
521 let keys = vec!["a".to_string(), "b".to_string(), "c".to_string()];
522 let capped = cap_keys(keys, Some(2));
523 assert_eq!(capped, vec!["a".to_string(), "b".to_string()]);
524 }
525
526 #[test]
527 fn cap_keys_passes_through_when_no_max() {
528 let keys = vec!["a".to_string(), "b".to_string(), "c".to_string()];
529 let capped = cap_keys(keys.clone(), None);
530 assert_eq!(capped, keys);
531 }
532
533 #[test]
534 fn cap_keys_noop_when_max_exceeds_len() {
535 let keys = vec!["a".to_string(), "b".to_string()];
536 let capped = cap_keys(keys.clone(), Some(10));
537 assert_eq!(capped, keys);
538 }
539
540 #[test]
543 fn dataset_uri_no_prefix_logic() {
544 let config = GcsSourceConfig::new("my-bucket");
545 let uri = match &config.prefix {
546 Some(p) => format!("gs://{}/{}", config.bucket, p),
547 None => format!("gs://{}", config.bucket),
548 };
549 assert_eq!(uri, "gs://my-bucket");
550 }
551
552 #[test]
553 fn dataset_uri_with_prefix_logic() {
554 let config = GcsSourceConfig::new("my-bucket").prefix("data/2026/");
555 let uri = match &config.prefix {
556 Some(p) => format!("gs://{}/{}", config.bucket, p),
557 None => format!("gs://{}", config.bucket),
558 };
559 assert_eq!(uri, "gs://my-bucket/data/2026/");
560 }
561}