use faucet_core::JsonSchema;
use faucet_core::Value;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
#[serde(rename_all = "snake_case")]
pub enum MalformedPolicy {
#[default]
Skip,
Fail,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SingerSourceConfig {
pub executable: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub tap_config: Value,
#[serde(default)]
pub catalog: Option<Value>,
pub stream: String,
#[serde(default)]
pub state_key: Option<String>,
#[serde(default = "default_true")]
pub flush_on_state: bool,
#[serde(default)]
pub idle_timeout_secs: Option<u64>,
#[serde(default)]
pub on_malformed: MalformedPolicy,
}
fn default_true() -> bool {
true
}
impl SingerSourceConfig {
pub fn new(executable: impl Into<String>, stream: impl Into<String>) -> Self {
Self {
executable: executable.into(),
args: Vec::new(),
tap_config: Value::Object(Default::default()),
catalog: None,
stream: stream.into(),
state_key: None,
flush_on_state: true,
idle_timeout_secs: None,
on_malformed: MalformedPolicy::Skip,
}
}
pub fn effective_state_key(&self) -> String {
self.state_key
.clone()
.unwrap_or_else(|| format!("singer:{}:{}", sanitize(&self.executable), self.stream))
}
}
fn sanitize(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.') {
c
} else {
'_'
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_apply() {
let cfg: SingerSourceConfig =
serde_json::from_value(serde_json::json!({"executable": "tap-x", "stream": "s"}))
.unwrap();
assert!(cfg.flush_on_state);
assert_eq!(cfg.on_malformed, MalformedPolicy::Skip);
assert!(cfg.args.is_empty());
assert!(cfg.catalog.is_none());
assert!(cfg.idle_timeout_secs.is_none());
}
#[test]
fn default_state_key_derivation() {
let cfg = SingerSourceConfig::new("tap-github", "issues");
assert_eq!(cfg.effective_state_key(), "singer:tap-github:issues");
let cfg2 = SingerSourceConfig {
state_key: Some("custom".into()),
..SingerSourceConfig::new("tap-github", "issues")
};
assert_eq!(cfg2.effective_state_key(), "custom");
}
#[test]
fn derived_state_key_is_valid_even_for_path_executable() {
let cfg = SingerSourceConfig::new("/opt/taps/tap-csv", "s");
let key = cfg.effective_state_key();
assert_eq!(key, "singer:_opt_taps_tap-csv:s");
faucet_core::state::validate_state_key(&key).expect("derived key must be valid");
}
#[test]
fn malformed_policy_parses_snake_case() {
let p: MalformedPolicy = serde_json::from_value(serde_json::json!("fail")).unwrap();
assert_eq!(p, MalformedPolicy::Fail);
}
}