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 bytes_stream = resp
116 .into_stream()
117 .map_err(|e| std::io::Error::other(e.to_string()));
118 let buffered = tokio::io::BufReader::new(tokio_util::io::StreamReader::new(bytes_stream));
119 #[cfg(feature = "compression")]
120 {
121 let codec = self.config.compression.resolve(key);
122 faucet_core::compression::warn_mismatch(key, codec);
123 Ok(faucet_core::compression::wrap_async_reader(buffered, codec))
124 }
125 #[cfg(not(feature = "compression"))]
126 {
127 Ok(Box::pin(buffered))
128 }
129 }
130
131 fn parse_content(&self, key: &str, text: &str) -> Result<Vec<Value>, FaucetError> {
133 parse_file_content(&self.config.file_format, key, text)
134 }
135}
136
137pub(crate) fn parse_file_content(
144 format: &GcsFileFormat,
145 key: &str,
146 text: &str,
147) -> Result<Vec<Value>, FaucetError> {
148 match format {
149 GcsFileFormat::JsonLines => {
150 let mut records = Vec::new();
151 for (line_num, line) in text.lines().enumerate() {
152 let trimmed = line.trim();
153 if trimmed.is_empty() {
154 continue;
155 }
156 let value: Value = serde_json::from_str(trimmed).map_err(|e| {
157 FaucetError::Source(format!(
158 "GCS JSON parse error in '{key}' at line {}: {e}",
159 line_num + 1
160 ))
161 })?;
162 records.push(value);
163 }
164 Ok(records)
165 }
166 GcsFileFormat::JsonArray => {
167 let value: Value = serde_json::from_str(text).map_err(|e| {
168 FaucetError::Source(format!("GCS JSON parse error in '{key}': {e}"))
169 })?;
170 match value {
171 Value::Array(arr) => Ok(arr),
172 other => Err(FaucetError::Source(format!(
173 "GCS expected JSON array in '{key}', got {}",
174 value_type_name(&other)
175 ))),
176 }
177 }
178 GcsFileFormat::RawText => Ok(vec![serde_json::json!({
179 "key": key,
180 "content": text,
181 })]),
182 }
183}
184
185#[async_trait]
186impl faucet_core::Source for GcsSource {
187 async fn fetch_with_context(
188 &self,
189 context: &std::collections::HashMap<String, Value>,
190 ) -> Result<Vec<Value>, FaucetError> {
191 let substituted_prefix: Option<String> = if !context.is_empty() {
192 self.config
193 .prefix
194 .as_ref()
195 .map(|p| faucet_core::util::substitute_context(p, context))
196 } else {
197 None
198 };
199
200 let keys = self
201 .list_object_names(substituted_prefix.as_deref())
202 .await?;
203 tracing::info!(
204 bucket = %self.config.bucket,
205 objects = keys.len(),
206 "Listed GCS objects",
207 );
208
209 let concurrency = self.config.concurrency.max(1);
210 let results: Vec<Vec<Value>> = stream::iter(keys)
211 .map(|key| async move {
212 let text = self.read_object_text(&key).await?;
213 let records = self.parse_content(&key, &text)?;
214 tracing::debug!(key = %key, records = records.len(), "Read GCS object");
215 Ok::<Vec<Value>, FaucetError>(records)
216 })
217 .buffer_unordered(concurrency)
218 .try_collect()
219 .await?;
220
221 let all_records: Vec<Value> = results.into_iter().flatten().collect();
222 tracing::info!(total_records = all_records.len(), "GCS fetch complete");
223 Ok(all_records)
224 }
225
226 fn stream_pages<'a>(
230 &'a self,
231 context: &'a std::collections::HashMap<String, Value>,
232 _batch_size: usize,
233 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
234 let batch_size = self.config.batch_size;
235
236 Box::pin(async_stream::try_stream! {
237 let substituted_prefix: Option<String> = if !context.is_empty() {
238 self.config
239 .prefix
240 .as_ref()
241 .map(|p| faucet_core::util::substitute_context(p, context))
242 } else {
243 None
244 };
245
246 let keys = self.list_object_names(substituted_prefix.as_deref()).await?;
247 tracing::info!(
248 bucket = %self.config.bucket,
249 objects = keys.len(),
250 "Listed GCS objects (stream)",
251 );
252
253 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
254 let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
255 let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
256 let mut total = 0usize;
257
258 for key in &keys {
259 match self.config.file_format {
260 GcsFileFormat::JsonLines => {
261 let reader = self.open_object_reader(key).await?;
262 let mut lines = reader.lines();
263 let mut line_num: usize = 0;
264 while let Some(line) = lines
265 .next_line()
266 .await
267 .map_err(|e| FaucetError::Source(format!(
268 "GCS read body error for key '{key}': {e}"
269 )))?
270 {
271 line_num += 1;
272 let trimmed = line.trim();
273 if trimmed.is_empty() { continue; }
274 let value: Value = serde_json::from_str(trimmed).map_err(|e| {
275 FaucetError::Source(format!(
276 "GCS JSON parse error in '{key}' at line {line_num}: {e}",
277 ))
278 })?;
279 buffer.push(value);
280 if batch_size != 0 && buffer.len() >= chunk {
281 let page = std::mem::replace(
282 &mut buffer,
283 Vec::with_capacity(initial_capacity),
284 );
285 total += page.len();
286 yield StreamPage { records: page, bookmark: None };
287 }
288 }
289 if batch_size == 0 && !buffer.is_empty() {
290 let page = std::mem::take(&mut buffer);
291 total += page.len();
292 yield StreamPage { records: page, bookmark: None };
293 }
294 }
295 GcsFileFormat::RawText => {
296 let text = self.read_object_text(key).await?;
297 let record = serde_json::json!({ "key": key, "content": text });
298 buffer.push(record);
299 if batch_size == 0 {
300 let page = std::mem::take(&mut buffer);
301 total += page.len();
302 yield StreamPage { records: page, bookmark: None };
303 } else if buffer.len() >= chunk {
304 let page = std::mem::replace(
305 &mut buffer,
306 Vec::with_capacity(initial_capacity),
307 );
308 total += page.len();
309 yield StreamPage { records: page, bookmark: None };
310 }
311 }
312 GcsFileFormat::JsonArray => {
313 let text = self.read_object_text(key).await?;
314 let value: Value = serde_json::from_str(&text).map_err(|e| {
315 FaucetError::Source(format!("GCS JSON parse error in '{key}': {e}"))
316 })?;
317 let array = match value {
318 Value::Array(arr) => arr,
319 other => Err(FaucetError::Source(format!(
320 "GCS expected JSON array in '{key}', got {}",
321 value_type_name(&other)
322 )))?,
323 };
324 if batch_size == 0 {
325 if !buffer.is_empty() {
326 let page = std::mem::take(&mut buffer);
327 total += page.len();
328 yield StreamPage { records: page, bookmark: None };
329 }
330 total += array.len();
331 yield StreamPage { records: array, bookmark: None };
332 } else {
333 for record in array {
334 buffer.push(record);
335 if buffer.len() >= chunk {
336 let page = std::mem::replace(
337 &mut buffer,
338 Vec::with_capacity(initial_capacity),
339 );
340 total += page.len();
341 yield StreamPage { records: page, bookmark: None };
342 }
343 }
344 }
345 }
346 }
347 }
348
349 if !buffer.is_empty() {
350 let page = std::mem::take(&mut buffer);
351 total += page.len();
352 yield StreamPage { records: page, bookmark: None };
353 }
354
355 tracing::info!(
356 total_records = total,
357 batch_size,
358 objects = keys.len(),
359 "GCS source stream complete",
360 );
361 })
362 }
363
364 fn config_schema(&self) -> Value {
365 serde_json::to_value(faucet_core::schema_for!(GcsSourceConfig))
366 .expect("schema serialization")
367 }
368
369 fn connector_name(&self) -> &'static str {
370 "gcs"
371 }
372
373 fn dataset_uri(&self) -> String {
374 match &self.config.prefix {
375 Some(p) => format!("gs://{}/{}", self.config.bucket, p),
376 None => format!("gs://{}", self.config.bucket),
377 }
378 }
379}
380
381fn cap_keys(mut keys: Vec<String>, max: Option<usize>) -> Vec<String> {
388 if let Some(n) = max {
389 keys.truncate(n);
390 }
391 keys
392}
393
394fn value_type_name(v: &Value) -> &'static str {
395 match v {
396 Value::Null => "null",
397 Value::Bool(_) => "boolean",
398 Value::Number(_) => "number",
399 Value::String(_) => "string",
400 Value::Array(_) => "array",
401 Value::Object(_) => "object",
402 }
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408 use serde_json::json;
409
410 #[cfg(feature = "compression")]
411 #[test]
412 fn compression_default_is_auto() {
413 let cfg = GcsSourceConfig::new("bucket");
414 assert_eq!(cfg.compression, faucet_core::CompressionConfig::Auto);
415 }
416
417 #[test]
418 fn value_type_name_covers_all_json_variants() {
419 assert_eq!(value_type_name(&Value::Null), "null");
420 assert_eq!(value_type_name(&json!(true)), "boolean");
421 assert_eq!(value_type_name(&json!(7)), "number");
422 assert_eq!(value_type_name(&json!("s")), "string");
423 assert_eq!(value_type_name(&json!([1, 2])), "array");
424 assert_eq!(value_type_name(&json!({"k": 1})), "object");
425 }
426
427 #[test]
428 fn parse_json_lines() {
429 let r =
430 parse_file_content(&GcsFileFormat::JsonLines, "t", "{\"id\":1}\n{\"id\":2}\n").unwrap();
431 assert_eq!(r.len(), 2);
432 assert_eq!(r[0]["id"], 1);
433 }
434
435 #[test]
436 fn parse_json_lines_skips_blanks() {
437 let r = parse_file_content(
438 &GcsFileFormat::JsonLines,
439 "t",
440 "{\"id\":1}\n\n{\"id\":2}\n\n",
441 )
442 .unwrap();
443 assert_eq!(r.len(), 2);
444 }
445
446 #[test]
447 fn parse_json_lines_reports_line_number() {
448 let err = parse_file_content(&GcsFileFormat::JsonLines, "t", "{\"id\":1}\nbad-line\n")
449 .unwrap_err();
450 let msg = err.to_string();
451 assert!(msg.contains("line 2"), "unexpected: {msg}");
452 }
453
454 #[test]
455 fn parse_json_array() {
456 let r = parse_file_content(
457 &GcsFileFormat::JsonArray,
458 "t.json",
459 "[{\"id\":1},{\"id\":2}]",
460 )
461 .unwrap();
462 assert_eq!(r.len(), 2);
463 }
464
465 #[test]
466 fn parse_json_array_rejects_non_array() {
467 let err =
468 parse_file_content(&GcsFileFormat::JsonArray, "t.json", "{\"id\":1}").unwrap_err();
469 assert!(err.to_string().contains("expected JSON array"));
470 }
471
472 #[test]
473 fn parse_raw_text_yields_single_record() {
474 let r = parse_file_content(&GcsFileFormat::RawText, "p/f.txt", "hello").unwrap();
475 assert_eq!(r, vec![json!({"key": "p/f.txt", "content": "hello"})]);
476 }
477
478 #[test]
479 fn cap_keys_truncates_explicit_list_to_max_objects() {
480 let keys = vec!["a".to_string(), "b".to_string(), "c".to_string()];
481 let capped = cap_keys(keys, Some(2));
482 assert_eq!(capped, vec!["a".to_string(), "b".to_string()]);
483 }
484
485 #[test]
486 fn cap_keys_passes_through_when_no_max() {
487 let keys = vec!["a".to_string(), "b".to_string(), "c".to_string()];
488 let capped = cap_keys(keys.clone(), None);
489 assert_eq!(capped, keys);
490 }
491
492 #[test]
493 fn cap_keys_noop_when_max_exceeds_len() {
494 let keys = vec!["a".to_string(), "b".to_string()];
495 let capped = cap_keys(keys.clone(), Some(10));
496 assert_eq!(capped, keys);
497 }
498
499 #[test]
502 fn dataset_uri_no_prefix_logic() {
503 let config = GcsSourceConfig::new("my-bucket");
504 let uri = match &config.prefix {
505 Some(p) => format!("gs://{}/{}", config.bucket, p),
506 None => format!("gs://{}", config.bucket),
507 };
508 assert_eq!(uri, "gs://my-bucket");
509 }
510
511 #[test]
512 fn dataset_uri_with_prefix_logic() {
513 let config = GcsSourceConfig::new("my-bucket").prefix("data/2026/");
514 let uri = match &config.prefix {
515 Some(p) => format!("gs://{}/{}", config.bucket, p),
516 None => format!("gs://{}", config.bucket),
517 };
518 assert_eq!(uri, "gs://my-bucket/data/2026/");
519 }
520}