use faucet_core::{
AvroOptions, CompressionConfig, CsvOptions, DEFAULT_BATCH_SIZE, ExcelOptions, FaucetError,
FormatOptions, OrcOptions, XmlOptions,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub use faucet_common_file::FileFormatChoice as FileSourceFormat;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum IncrementalBy {
Mtime,
Name,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct IncrementalSpec {
pub by: IncrementalBy,
}
fn default_concurrency() -> usize {
4
}
fn default_batch_size() -> usize {
DEFAULT_BATCH_SIZE
}
fn default_http_retries() -> u32 {
3
}
fn default_max_object_bytes() -> u64 {
faucet_core::file_format::DEFAULT_MAX_OBJECT_BYTES
}
fn default_http_connect_timeout_secs() -> u64 {
30
}
fn default_http_read_timeout_secs() -> u64 {
300
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct FileSourceConfig {
pub path: String,
#[serde(default)]
pub format: FileSourceFormat,
#[serde(default)]
pub compression: CompressionConfig,
#[serde(default)]
pub recursive: bool,
#[serde(default = "default_concurrency")]
pub concurrency: usize,
#[serde(default = "default_batch_size")]
pub batch_size: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub incremental: Option<IncrementalSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stable_for_secs: Option<u64>,
#[serde(default)]
pub strict: bool,
#[serde(default = "default_max_object_bytes")]
pub max_object_bytes: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_files: Option<usize>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
#[serde(default = "default_http_retries")]
pub http_retries: u32,
#[serde(default = "default_http_connect_timeout_secs")]
pub http_connect_timeout_secs: u64,
#[serde(default = "default_http_read_timeout_secs")]
pub http_read_timeout_secs: u64,
#[serde(default)]
pub csv: CsvOptions,
#[serde(default)]
pub excel: ExcelOptions,
#[serde(default)]
pub xml: XmlOptions,
#[serde(default)]
pub avro: AvroOptions,
#[serde(default)]
pub orc: OrcOptions,
#[serde(default)]
pub parquet: ParquetReadOptions,
#[cfg(feature = "encryption")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub encryption: Option<faucet_core::EncryptionSpec>,
}
pub use faucet_core::ParquetReadOptions;
impl FileSourceConfig {
pub fn new(path: impl Into<String>) -> Self {
Self {
path: path.into(),
format: FileSourceFormat::Auto,
compression: CompressionConfig::Auto,
recursive: false,
concurrency: default_concurrency(),
batch_size: DEFAULT_BATCH_SIZE,
incremental: None,
stable_for_secs: None,
strict: false,
max_files: None,
max_object_bytes: default_max_object_bytes(),
headers: BTreeMap::new(),
http_retries: default_http_retries(),
http_connect_timeout_secs: default_http_connect_timeout_secs(),
http_read_timeout_secs: default_http_read_timeout_secs(),
csv: CsvOptions::default(),
excel: ExcelOptions::default(),
xml: XmlOptions::default(),
avro: AvroOptions::default(),
orc: OrcOptions::default(),
parquet: ParquetReadOptions::default(),
#[cfg(feature = "encryption")]
encryption: None,
}
}
pub fn format(mut self, format: FileSourceFormat) -> Self {
self.format = format;
self
}
pub fn recursive(mut self, recursive: bool) -> Self {
self.recursive = recursive;
self
}
pub fn with_batch_size(mut self, batch_size: usize) -> Self {
self.batch_size = batch_size;
self
}
pub fn incremental(mut self, by: IncrementalBy) -> Self {
self.incremental = Some(IncrementalSpec { by });
self
}
pub fn is_http(&self) -> bool {
faucet_common_file::is_http_path(&self.path)
}
pub fn parquet_columns<I, S>(mut self, columns: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.parquet.columns = Some(columns.into_iter().map(Into::into).collect());
self
}
pub fn encryption_set(&self) -> bool {
#[cfg(feature = "encryption")]
return self.encryption.is_some();
#[cfg(not(feature = "encryption"))]
false
}
pub fn format_options(&self) -> FormatOptions {
let mut csv = self.csv.clone();
csv.flexible.get_or_insert(false);
FormatOptions {
csv,
excel: self.excel.clone(),
xml: self.xml.clone(),
avro: self.avro.clone(),
orc: self.orc.clone(),
}
}
pub fn validate(&self) -> Result<(), FaucetError> {
faucet_core::validate_batch_size(self.batch_size)?;
faucet_common_file::require_path("file source", &self.path)?;
if !self.headers.is_empty() && !self.is_http() {
return Err(FaucetError::Config(
"file source: `headers` apply only to an http(s):// path".into(),
));
}
if self.max_object_bytes == 0 {
return Err(FaucetError::Config(
"file source: `max_object_bytes` must be at least 1".into(),
));
}
if self.http_connect_timeout_secs == 0 || self.http_read_timeout_secs == 0 {
return Err(FaucetError::Config(
"file source: `http_connect_timeout_secs` and `http_read_timeout_secs` must be \
at least 1"
.into(),
));
}
for (k, v) in &self.headers {
reqwest::header::HeaderName::from_bytes(k.as_bytes())
.map_err(|e| FaucetError::Config(format!("file source: header name {k:?}: {e}")))?;
reqwest::header::HeaderValue::from_str(v)
.map_err(|e| FaucetError::Config(format!("file source: header {k:?}: {e}")))?;
}
#[cfg(feature = "file-format-avro")]
self.avro.parsed_schema()?;
self.csv.validate()?;
self.parquet.validate()?;
#[cfg(feature = "encryption")]
if let Some(spec) = &self.encryption {
faucet_core::CompiledEncryption::compile(spec)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn defaults_and_builders() {
let c = FileSourceConfig::new("data/")
.format(FileSourceFormat::Csv)
.recursive(true)
.with_batch_size(10)
.incremental(IncrementalBy::Name);
assert_eq!(c.format.explicit(), Some(faucet_core::FileFormat::Csv));
assert!(c.recursive);
assert_eq!(c.batch_size, 10);
assert_eq!(c.incremental.unwrap().by, IncrementalBy::Name);
assert!(!c.is_http());
assert!(FileSourceConfig::new("HTTPS://x/y.csv").is_http());
c.validate().expect("valid");
}
#[test]
fn deserializes_with_option_blocks_and_refuses_unknown_keys() {
let c: FileSourceConfig = serde_json::from_value(json!({
"path": "in/*.csv",
"csv": {"delimiter": ";"},
"orc": {"columns": ["a"]},
"incremental": {"by": "mtime"},
"stable_for_secs": 5
}))
.expect("config");
assert_eq!(c.format_options().csv.delimiter, ";");
assert_eq!(c.format_options().orc.columns, Some(vec!["a".to_string()]));
assert_eq!(c.incremental.unwrap().by, IncrementalBy::Mtime);
assert!(
serde_json::from_value::<FileSourceConfig>(json!({"path": "x", "nope": 1})).is_err()
);
}
#[test]
fn validation_refuses_bad_configs() {
assert!(FileSourceConfig::new(" ").validate().is_err());
assert!(
FileSourceConfig::new("x")
.with_batch_size(usize::MAX)
.validate()
.is_err()
);
let mut c = FileSourceConfig::new("local.csv");
c.headers.insert("Authorization".into(), "x".into());
assert!(c.validate().is_err(), "headers need an http path");
let mut c = FileSourceConfig::new("https://h/x.csv");
c.headers.insert("bad header".into(), "x".into());
assert!(c.validate().is_err());
let mut c = FileSourceConfig::new("https://h/x.csv");
c.headers.insert("X-Ok".into(), "line\nbreak".into());
assert!(c.validate().is_err());
#[cfg(feature = "file-format-avro")]
{
let mut c = FileSourceConfig::new("x.avro");
c.avro.schema = Some(json!({"type": "nope"}));
assert!(c.validate().is_err());
}
}
}