1use std::collections::HashMap;
4use std::pin::Pin;
5use std::sync::Arc;
6
7use async_trait::async_trait;
8use faucet_common_azure::build_store;
9use faucet_core::{FaucetError, Stream, StreamPage};
10use futures::stream::{self, StreamExt, TryStreamExt};
11use object_store::path::Path as ObjectPath;
12use object_store::{ObjectStore, ObjectStoreExt};
13use serde_json::Value;
14use tokio::io::AsyncBufReadExt;
15
16use crate::config::{AzureBlobSourceConfig, AzureFileFormat};
17
18pub struct AzureBlobSource {
20 config: AzureBlobSourceConfig,
21 store: Arc<dyn ObjectStore>,
22}
23
24impl AzureBlobSource {
25 pub async fn new(config: AzureBlobSourceConfig) -> Result<Self, FaucetError> {
28 faucet_core::validate_batch_size(config.batch_size)?;
29 let store = build_store(&config.connection)?;
30 Ok(Self { config, store })
31 }
32
33 async fn list_object_names(
37 &self,
38 prefix_override: Option<&str>,
39 ) -> Result<Vec<String>, FaucetError> {
40 if let Some(keys) = &self.config.object_keys {
41 return Ok(cap_keys(keys.clone(), self.config.max_objects));
42 }
43
44 let effective_prefix = prefix_override.or(self.config.prefix.as_deref());
45 let prefix_path = effective_prefix
46 .filter(|p| !p.is_empty())
47 .map(ObjectPath::from);
48
49 let mut listing = self.store.list(prefix_path.as_ref());
50 let mut names: Vec<String> = Vec::new();
51 while let Some(item) = listing.next().await {
52 let meta = item.map_err(|e| {
53 FaucetError::Source(format!(
54 "azure list error for container '{}': {e}",
55 self.config.container()
56 ))
57 })?;
58 let name = meta.location.to_string();
59 if name.is_empty() {
60 continue;
61 }
62 names.push(name);
63 if let Some(max) = self.config.max_objects
64 && names.len() >= max
65 {
66 break;
67 }
68 }
69 Ok(names)
70 }
71
72 async fn read_object_text(&self, key: &str) -> Result<String, FaucetError> {
74 use tokio::io::AsyncReadExt as _;
75 let mut reader = self.open_object_reader(key).await?;
76 let mut text = String::new();
77 reader.read_to_string(&mut text).await.map_err(|e| {
78 FaucetError::Source(format!(
79 "azure read/decode error for key '{key}' (not valid UTF-8?): {e}"
80 ))
81 })?;
82 Ok(text)
83 }
84
85 async fn open_object_reader(
89 &self,
90 key: &str,
91 ) -> Result<Pin<Box<dyn tokio::io::AsyncBufRead + Send + Unpin>>, FaucetError> {
92 let path = ObjectPath::from(key);
93 let result = self.store.get(&path).await.map_err(|e| {
94 FaucetError::Source(format!(
95 "azure get error for container '{}' key '{key}': {e}",
96 self.config.container()
97 ))
98 })?;
99
100 let byte_stream = result
101 .into_stream()
102 .map_err(|e| std::io::Error::other(e.to_string()));
103 let reader = tokio_util::io::StreamReader::new(byte_stream);
104 let buffered = tokio::io::BufReader::new(reader);
105 #[cfg(feature = "compression")]
106 {
107 let codec = self.config.compression.resolve(key);
108 faucet_core::compression::warn_mismatch(key, codec);
109 Ok(faucet_core::compression::wrap_async_reader(buffered, codec))
110 }
111 #[cfg(not(feature = "compression"))]
112 {
113 Ok(Box::pin(buffered))
114 }
115 }
116
117 fn parse_content(&self, key: &str, text: &str) -> Result<Vec<Value>, FaucetError> {
119 parse_file_content(&self.config.file_format, key, text)
120 }
121}
122
123pub(crate) fn parse_file_content(
127 format: &AzureFileFormat,
128 key: &str,
129 text: &str,
130) -> Result<Vec<Value>, FaucetError> {
131 match format {
132 AzureFileFormat::JsonLines => {
133 let mut records = Vec::new();
134 for (line_num, line) in text.lines().enumerate() {
135 let trimmed = line.trim();
136 if trimmed.is_empty() {
137 continue;
138 }
139 let value: Value = serde_json::from_str(trimmed).map_err(|e| {
140 FaucetError::Source(format!(
141 "azure JSON parse error in '{key}' at line {}: {e}",
142 line_num + 1
143 ))
144 })?;
145 records.push(value);
146 }
147 Ok(records)
148 }
149 AzureFileFormat::JsonArray => {
150 let value: Value = serde_json::from_str(text).map_err(|e| {
151 FaucetError::Source(format!("azure JSON parse error in '{key}': {e}"))
152 })?;
153 match value {
154 Value::Array(arr) => Ok(arr),
155 other => Err(FaucetError::Source(format!(
156 "azure expected JSON array in '{key}', got {}",
157 value_type_name(&other)
158 ))),
159 }
160 }
161 AzureFileFormat::RawText => Ok(vec![serde_json::json!({
162 "key": key,
163 "content": text,
164 })]),
165 }
166}
167
168fn cap_keys(mut keys: Vec<String>, max: Option<usize>) -> Vec<String> {
171 if let Some(n) = max {
172 keys.truncate(n);
173 }
174 keys
175}
176
177fn value_type_name(v: &Value) -> &'static str {
178 match v {
179 Value::Null => "null",
180 Value::Bool(_) => "boolean",
181 Value::Number(_) => "number",
182 Value::String(_) => "string",
183 Value::Array(_) => "array",
184 Value::Object(_) => "object",
185 }
186}
187
188#[async_trait]
189impl faucet_core::Source for AzureBlobSource {
190 async fn fetch_with_context(
191 &self,
192 context: &HashMap<String, Value>,
193 ) -> Result<Vec<Value>, FaucetError> {
194 let substituted_prefix: Option<String> = if !context.is_empty() {
195 self.config
196 .prefix
197 .as_ref()
198 .map(|p| faucet_core::util::substitute_context(p, context))
199 } else {
200 None
201 };
202
203 let keys = self
204 .list_object_names(substituted_prefix.as_deref())
205 .await?;
206 tracing::info!(
207 container = %self.config.container(),
208 objects = keys.len(),
209 "Listed Azure objects",
210 );
211
212 let concurrency = self.config.concurrency.max(1);
213 let results: Vec<Vec<Value>> = stream::iter(keys)
214 .map(|key| async move {
215 let text = self.read_object_text(&key).await?;
216 let records = self.parse_content(&key, &text)?;
217 tracing::debug!(key = %key, records = records.len(), "Read Azure object");
218 Ok::<Vec<Value>, FaucetError>(records)
219 })
220 .buffer_unordered(concurrency)
221 .try_collect()
222 .await?;
223
224 let all_records: Vec<Value> = results.into_iter().flatten().collect();
225 tracing::info!(total_records = all_records.len(), "Azure fetch complete");
226 Ok(all_records)
227 }
228
229 fn stream_pages<'a>(
233 &'a self,
234 context: &'a HashMap<String, Value>,
235 _batch_size: usize,
236 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
237 let batch_size = self.config.batch_size;
238
239 Box::pin(async_stream::try_stream! {
240 let substituted_prefix: Option<String> = if !context.is_empty() {
241 self.config
242 .prefix
243 .as_ref()
244 .map(|p| faucet_core::util::substitute_context(p, context))
245 } else {
246 None
247 };
248
249 let keys = self.list_object_names(substituted_prefix.as_deref()).await?;
250 tracing::info!(
251 container = %self.config.container(),
252 objects = keys.len(),
253 "Listed Azure objects (stream)",
254 );
255
256 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
257 let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
258 let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
259 let mut total = 0usize;
260
261 for key in &keys {
262 match self.config.file_format {
263 AzureFileFormat::JsonLines => {
264 let reader = self.open_object_reader(key).await?;
265 let mut lines = reader.lines();
266 let mut line_num: usize = 0;
267 while let Some(line) = lines
268 .next_line()
269 .await
270 .map_err(|e| FaucetError::Source(format!(
271 "azure read body error for key '{key}': {e}"
272 )))?
273 {
274 line_num += 1;
275 let trimmed = line.trim();
276 if trimmed.is_empty() { continue; }
277 let value: Value = serde_json::from_str(trimmed).map_err(|e| {
278 FaucetError::Source(format!(
279 "azure JSON parse error in '{key}' at line {line_num}: {e}",
280 ))
281 })?;
282 buffer.push(value);
283 if batch_size != 0 && buffer.len() >= chunk {
284 let page = std::mem::replace(
285 &mut buffer,
286 Vec::with_capacity(initial_capacity),
287 );
288 total += page.len();
289 yield StreamPage { records: page, bookmark: None };
290 }
291 }
292 if batch_size == 0 && !buffer.is_empty() {
293 let page = std::mem::take(&mut buffer);
294 total += page.len();
295 yield StreamPage { records: page, bookmark: None };
296 }
297 }
298 AzureFileFormat::RawText => {
299 let text = self.read_object_text(key).await?;
300 let record = serde_json::json!({ "key": key, "content": text });
301 buffer.push(record);
302 if batch_size == 0 {
303 let page = std::mem::take(&mut buffer);
304 total += page.len();
305 yield StreamPage { records: page, bookmark: None };
306 } else if buffer.len() >= chunk {
307 let page = std::mem::replace(
308 &mut buffer,
309 Vec::with_capacity(initial_capacity),
310 );
311 total += page.len();
312 yield StreamPage { records: page, bookmark: None };
313 }
314 }
315 AzureFileFormat::JsonArray => {
316 let text = self.read_object_text(key).await?;
317 let value: Value = serde_json::from_str(&text).map_err(|e| {
318 FaucetError::Source(format!("azure JSON parse error in '{key}': {e}"))
319 })?;
320 let array = match value {
321 Value::Array(arr) => arr,
322 other => Err(FaucetError::Source(format!(
323 "azure expected JSON array in '{key}', got {}",
324 value_type_name(&other)
325 )))?,
326 };
327 if batch_size == 0 {
328 if !buffer.is_empty() {
329 let page = std::mem::take(&mut buffer);
330 total += page.len();
331 yield StreamPage { records: page, bookmark: None };
332 }
333 total += array.len();
334 yield StreamPage { records: array, bookmark: None };
335 } else {
336 for record in array {
337 buffer.push(record);
338 if buffer.len() >= chunk {
339 let page = std::mem::replace(
340 &mut buffer,
341 Vec::with_capacity(initial_capacity),
342 );
343 total += page.len();
344 yield StreamPage { records: page, bookmark: None };
345 }
346 }
347 }
348 }
349 }
350 }
351
352 if !buffer.is_empty() {
353 let page = std::mem::take(&mut buffer);
354 total += page.len();
355 yield StreamPage { records: page, bookmark: None };
356 }
357
358 tracing::info!(
359 total_records = total,
360 batch_size,
361 objects = keys.len(),
362 "Azure source stream complete",
363 );
364 })
365 }
366
367 fn config_schema(&self) -> Value {
368 serde_json::to_value(faucet_core::schema_for!(AzureBlobSourceConfig))
369 .expect("schema serialization")
370 }
371
372 fn connector_name(&self) -> &'static str {
373 "azure-blob"
374 }
375
376 fn dataset_uri(&self) -> String {
377 match &self.config.prefix {
378 Some(p) => format!("az://{}/{}", self.config.container(), p),
379 None => format!("az://{}", self.config.container()),
380 }
381 }
382
383 async fn check(
387 &self,
388 ctx: &faucet_core::check::CheckContext,
389 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
390 use faucet_core::check::{CheckReport, Probe};
391
392 let started = std::time::Instant::now();
393 let probe = match tokio::time::timeout(ctx.timeout, async {
394 let mut listing = self.store.list(None);
395 listing.next().await
396 })
397 .await
398 {
399 Ok(None) | Ok(Some(Ok(_))) => Probe::pass("auth", started.elapsed()),
401 Ok(Some(Err(e))) => Probe::fail_hint(
402 "auth",
403 started.elapsed(),
404 e.to_string(),
405 "check account, container, credentials, and network",
406 ),
407 Err(_) => Probe::fail("network", started.elapsed(), "timed out"),
408 };
409 Ok(CheckReport::single(probe))
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416 use faucet_core::Source as _;
417 use serde_json::json;
418
419 #[test]
420 fn value_type_name_covers_all_json_variants() {
421 assert_eq!(value_type_name(&Value::Null), "null");
422 assert_eq!(value_type_name(&json!(true)), "boolean");
423 assert_eq!(value_type_name(&json!(7)), "number");
424 assert_eq!(value_type_name(&json!("s")), "string");
425 assert_eq!(value_type_name(&json!([1, 2])), "array");
426 assert_eq!(value_type_name(&json!({"k": 1})), "object");
427 }
428
429 #[test]
430 fn parse_json_lines() {
431 let r = parse_file_content(&AzureFileFormat::JsonLines, "t", "{\"id\":1}\n{\"id\":2}\n")
432 .unwrap();
433 assert_eq!(r.len(), 2);
434 assert_eq!(r[0]["id"], 1);
435 }
436
437 #[test]
438 fn parse_json_lines_skips_blanks() {
439 let r = parse_file_content(
440 &AzureFileFormat::JsonLines,
441 "t",
442 "{\"id\":1}\n\n{\"id\":2}\n\n",
443 )
444 .unwrap();
445 assert_eq!(r.len(), 2);
446 }
447
448 #[test]
449 fn parse_json_lines_reports_line_number() {
450 let err = parse_file_content(&AzureFileFormat::JsonLines, "t", "{\"id\":1}\nbad-line\n")
451 .unwrap_err();
452 let msg = err.to_string();
453 assert!(msg.contains("line 2"), "unexpected: {msg}");
454 }
455
456 #[test]
457 fn parse_json_array() {
458 let r = parse_file_content(
459 &AzureFileFormat::JsonArray,
460 "t.json",
461 "[{\"id\":1},{\"id\":2}]",
462 )
463 .unwrap();
464 assert_eq!(r.len(), 2);
465 }
466
467 #[test]
468 fn parse_json_array_rejects_non_array() {
469 let err =
470 parse_file_content(&AzureFileFormat::JsonArray, "t.json", "{\"id\":1}").unwrap_err();
471 assert!(err.to_string().contains("expected JSON array"));
472 }
473
474 #[test]
475 fn parse_json_array_rejects_malformed_json() {
476 let err =
477 parse_file_content(&AzureFileFormat::JsonArray, "t.json", "[not json").unwrap_err();
478 assert!(matches!(err, FaucetError::Source(_)));
479 }
480
481 #[test]
482 fn parse_raw_text_yields_single_record() {
483 let r = parse_file_content(&AzureFileFormat::RawText, "p/f.txt", "hello").unwrap();
484 assert_eq!(r, vec![json!({"key": "p/f.txt", "content": "hello"})]);
485 }
486
487 #[test]
488 fn cap_keys_truncates_explicit_list_to_max_objects() {
489 let keys = vec!["a".to_string(), "b".to_string(), "c".to_string()];
490 assert_eq!(
491 cap_keys(keys, Some(2)),
492 vec!["a".to_string(), "b".to_string()]
493 );
494 }
495
496 #[test]
497 fn cap_keys_passes_through_when_no_max() {
498 let keys = vec!["a".to_string(), "b".to_string(), "c".to_string()];
499 assert_eq!(cap_keys(keys.clone(), None), keys);
500 }
501
502 #[test]
503 fn cap_keys_noop_when_max_exceeds_len() {
504 let keys = vec!["a".to_string(), "b".to_string()];
505 assert_eq!(cap_keys(keys.clone(), Some(10)), keys);
506 }
507
508 #[test]
511 fn dataset_uri_no_prefix_logic() {
512 let config = AzureBlobSourceConfig::new("my-container");
513 let uri = match &config.prefix {
514 Some(p) => format!("az://{}/{}", config.container(), p),
515 None => format!("az://{}", config.container()),
516 };
517 assert_eq!(uri, "az://my-container");
518 }
519
520 #[test]
521 fn dataset_uri_with_prefix_logic() {
522 let config = AzureBlobSourceConfig::new("my-container").prefix("data/2026/");
523 let uri = match &config.prefix {
524 Some(p) => format!("az://{}/{}", config.container(), p),
525 None => format!("az://{}", config.container()),
526 };
527 assert_eq!(uri, "az://my-container/data/2026/");
528 }
529
530 #[tokio::test]
531 async fn new_rejects_out_of_range_batch_size() {
532 let config =
533 AzureBlobSourceConfig::new("c").with_batch_size(faucet_core::MAX_BATCH_SIZE + 1);
534 match AzureBlobSource::new(config).await {
535 Err(FaucetError::Config(m)) => assert!(m.contains("batch_size"), "got: {m}"),
536 Ok(_) => panic!("expected a batch_size Config error, got Ok(source)"),
537 Err(e) => panic!("expected a batch_size Config error, got {e:?}"),
538 }
539 }
540
541 #[tokio::test]
542 async fn new_builds_lazily_with_emulator() {
543 let config = AzureBlobSourceConfig::new("c")
546 .use_emulator(true)
547 .allow_http(true);
548 let source = AzureBlobSource::new(config).await.unwrap();
549 assert_eq!(source.connector_name(), "azure-blob");
550 assert_eq!(source.dataset_uri(), "az://c");
551 }
552}