pub use faucet_common_singer::InheritEnv;
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)]
#[serde(deny_unknown_fields)]
pub struct SingerSourceConfig {
#[schemars(extend("x-faucet-resource" = "command"))]
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 = "default_idle_timeout_secs")]
pub idle_timeout_secs: Option<u64>,
#[serde(default = "default_max_line_bytes")]
pub max_line_bytes: usize,
#[serde(default)]
pub on_malformed: MalformedPolicy,
#[serde(default)]
pub inherit_env: InheritEnv,
}
fn default_true() -> bool {
true
}
fn default_idle_timeout_secs() -> Option<u64> {
Some(3600)
}
fn default_max_line_bytes() -> usize {
faucet_common_singer::DEFAULT_MAX_LINE_BYTES
}
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: default_idle_timeout_secs(),
max_line_bytes: default_max_line_bytes(),
on_malformed: MalformedPolicy::Skip,
inherit_env: InheritEnv::default(),
}
}
pub fn effective_state_key(&self) -> String {
if let Some(key) = &self.state_key {
return key.clone();
}
let prefix = format!("singer:{}:", sanitize(&self.executable));
let raw = format!("{prefix}{}", self.stream);
if faucet_core::state::validate_state_key(&raw).is_ok() {
return raw;
}
let suffix = format!(
"-{:08x}",
faucet_core::shard::shard_hash(&self.stream) >> 32
);
let room = MAX_STATE_KEY_LEN.saturating_sub(prefix.len() + suffix.len());
let stream: String = self
.stream
.chars()
.map(|c| if c == ':' { c } else { sanitize_char(c) })
.take(room)
.collect();
format!("{prefix}{stream}{suffix}")
}
}
const MAX_STATE_KEY_LEN: usize = 256;
fn sanitize(s: &str) -> String {
s.chars().map(sanitize_char).collect()
}
fn sanitize_char(c: char) -> char {
if c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.') {
c
} else {
'_'
}
}
#[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_eq!(cfg.idle_timeout_secs, Some(3600));
assert_eq!(cfg.max_line_bytes, 64 * 1024 * 1024);
}
#[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 derived_state_key_is_valid_for_any_stream_name() {
let key = |stream: &str| SingerSourceConfig::new("tap-x", stream).effective_state_key();
for (stream, expected) in [
("public/orders", "singer:tap-x:public/orders"),
("Sheet_1", "singer:tap-x:Sheet_1"),
("a:b.c-d_e", "singer:tap-x:a:b.c-d_e"),
] {
assert_eq!(key(stream), expected, "a valid name keeps its key");
}
let spaced = key("Sheet 1");
assert!(spaced.starts_with("singer:tap-x:Sheet_1-"), "{spaced}");
assert_ne!(spaced, key("Sheet_1"));
assert_ne!(spaced, key("Sheet/1 "));
assert_ne!(key("a//b"), key("a__b"));
let long = key(&format!("{} x", "s".repeat(400)));
assert!(long.len() <= 256, "{}", long.len());
for stream in ["Sheet 1", "a//b", "/lead", ".hidden", "ünï", "", "x y"] {
let got = key(stream);
faucet_core::state::validate_state_key(&got)
.unwrap_or_else(|e| panic!("{stream:?} -> {got}: {e}"));
}
faucet_core::state::validate_state_key(&long).expect("long name");
}
#[test]
fn malformed_policy_parses_snake_case() {
let p: MalformedPolicy = serde_json::from_value(serde_json::json!("fail")).unwrap();
assert_eq!(p, MalformedPolicy::Fail);
}
}