use faucet_core::Value;
#[derive(Debug, Clone, PartialEq)]
pub enum SingerMessage {
Record { stream: String, record: Value },
Schema {
stream: String,
schema: Value,
key_properties: Option<Vec<String>>,
bookmark_properties: Option<Vec<String>>,
},
State { value: Value },
Other { message_type: String },
}
pub fn parse_line(line: &str) -> Result<SingerMessage, String> {
let trimmed = line.trim();
if trimmed.is_empty() {
return Err("blank line".to_string());
}
let value: Value = serde_json::from_str(trimmed).map_err(|e| format!("not valid JSON: {e}"))?;
let obj = value
.as_object()
.ok_or_else(|| "message is not a JSON object".to_string())?;
let msg_type = obj
.get("type")
.and_then(Value::as_str)
.ok_or_else(|| "missing or non-string `type` field".to_string())?;
match msg_type {
"RECORD" => {
let stream = obj
.get("stream")
.and_then(Value::as_str)
.ok_or_else(|| "RECORD missing `stream`".to_string())?
.to_string();
let record = obj
.get("record")
.ok_or_else(|| "RECORD missing `record`".to_string())?
.clone();
if !record.is_object() {
return Err("RECORD `record` is not an object".to_string());
}
Ok(SingerMessage::Record { stream, record })
}
"SCHEMA" => {
let stream = obj
.get("stream")
.and_then(Value::as_str)
.ok_or_else(|| "SCHEMA missing `stream`".to_string())?
.to_string();
let schema = obj
.get("schema")
.ok_or_else(|| "SCHEMA missing `schema`".to_string())?
.clone();
Ok(SingerMessage::Schema {
stream,
schema,
key_properties: string_array(obj.get("key_properties")),
bookmark_properties: string_array(obj.get("bookmark_properties")),
})
}
"STATE" => {
let value = obj
.get("value")
.ok_or_else(|| "STATE missing `value`".to_string())?
.clone();
Ok(SingerMessage::State { value })
}
other => Ok(SingerMessage::Other {
message_type: other.to_string(),
}),
}
}
fn string_array(v: Option<&Value>) -> Option<Vec<String>> {
v.and_then(Value::as_array).map(|arr| {
arr.iter()
.filter_map(|x| x.as_str().map(str::to_string))
.collect()
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parses_record() {
let m = parse_line(r#"{"type":"RECORD","stream":"s","record":{"id":1}}"#).unwrap();
assert_eq!(
m,
SingerMessage::Record {
stream: "s".into(),
record: json!({"id": 1})
}
);
}
#[test]
fn parses_schema_with_key_props() {
let m = parse_line(
r#"{"type":"SCHEMA","stream":"s","schema":{"type":"object"},"key_properties":["id"],"bookmark_properties":["updated_at"]}"#,
)
.unwrap();
match m {
SingerMessage::Schema {
stream,
key_properties,
bookmark_properties,
..
} => {
assert_eq!(stream, "s");
assert_eq!(key_properties, Some(vec!["id".to_string()]));
assert_eq!(bookmark_properties, Some(vec!["updated_at".to_string()]));
}
_ => panic!("expected SCHEMA"),
}
}
#[test]
fn parses_state() {
let m = parse_line(r#"{"type":"STATE","value":{"bookmarks":{"s":{"v":5}}}}"#).unwrap();
assert_eq!(
m,
SingerMessage::State {
value: json!({"bookmarks":{"s":{"v":5}}})
}
);
}
#[test]
fn activate_version_and_batch_are_other() {
assert_eq!(
parse_line(r#"{"type":"ACTIVATE_VERSION","stream":"s","version":1}"#).unwrap(),
SingerMessage::Other {
message_type: "ACTIVATE_VERSION".into()
}
);
assert_eq!(
parse_line(r#"{"type":"BATCH","stream":"s"}"#).unwrap(),
SingerMessage::Other {
message_type: "BATCH".into()
}
);
}
#[test]
fn malformed_variants_error() {
assert!(parse_line("").is_err()); assert!(parse_line("not json").is_err()); assert!(parse_line("[1,2,3]").is_err()); assert!(parse_line(r#"{"stream":"s"}"#).is_err()); assert!(parse_line(r#"{"type":"RECORD","stream":"s"}"#).is_err()); assert!(parse_line(r#"{"type":"RECORD","record":{"id":1}}"#).is_err()); assert!(parse_line(r#"{"type":"RECORD","stream":"s","record":5}"#).is_err()); assert!(parse_line(r#"{"type":"STATE"}"#).is_err()); assert!(parse_line(r#"{"type":"SCHEMA","stream":"s"}"#).is_err()); }
}