use crate::cache::CompiledSchema;
use crate::deser::{AvroSerdeDeserializer, AvroValueDeserializer, DecoderCore, SchemaSourceMode};
use crate::registry::{RegistryConfig, spawn_fetcher};
use apache_avro::Schema;
use apache_avro::rabin::Rabin;
use serde::Deserialize;
use spate_core::config::ComponentConfig;
use std::sync::Arc;
use std::time::Duration;
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AvroMode {
#[default]
Confluent,
Raw,
SingleObject,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct SchemaSource {
pub inline: Option<String>,
pub path: Option<std::path::PathBuf>,
}
impl SchemaSource {
#[must_use]
pub fn inline(schema: impl Into<String>) -> Self {
SchemaSource {
inline: Some(schema.into()),
path: None,
}
}
#[must_use]
pub fn path(path: impl Into<std::path::PathBuf>) -> Self {
SchemaSource {
inline: None,
path: Some(path.into()),
}
}
fn load_text(&self) -> Result<String, AvroConfigError> {
match (&self.inline, &self.path) {
(Some(s), None) => Ok(s.clone()),
(None, Some(p)) => {
std::fs::read_to_string(p).map_err(|e| AvroConfigError::SchemaLoad {
detail: format!("{}: {e}", p.display()),
})
}
_ => Err(AvroConfigError::Invalid {
detail: "a schema source needs exactly one of `inline` or `path`".into(),
}),
}
}
fn load(&self) -> Result<Schema, AvroConfigError> {
let text = self.load_text()?;
Schema::parse_str(&text).map_err(|e| AvroConfigError::SchemaLoad {
detail: format!("schema failed to parse: {e}"),
})
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RegistrySection {
pub url: String,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub password: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct AvroSettings {
pub mode: AvroMode,
pub registry: Option<RegistrySection>,
pub prewarm_subjects: Vec<String>,
#[serde(with = "humantime_serde")]
pub negative_cache_ttl: Duration,
pub reader_schema: Option<SchemaSource>,
pub schema: Option<SchemaSource>,
}
impl Default for AvroSettings {
fn default() -> Self {
AvroSettings {
mode: AvroMode::Confluent,
registry: None,
prewarm_subjects: Vec::new(),
negative_cache_ttl: Duration::from_secs(30),
reader_schema: None,
schema: None,
}
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum AvroConfigError {
#[error(transparent)]
Config(#[from] spate_core::config::ConfigError),
#[error("avro schema: {detail}")]
SchemaLoad {
detail: String,
},
#[error("avro configuration: {detail}")]
Invalid {
detail: String,
},
}
#[derive(Clone, Debug)]
pub struct AvroDeserializerBuilder {
core: DecoderCore,
}
impl AvroDeserializerBuilder {
pub fn from_component(
cfg: &ComponentConfig,
runtime: &tokio::runtime::Handle,
) -> Result<Self, AvroConfigError> {
let settings: AvroSettings = cfg.deserialize_into()?;
Self::from_settings(&settings, runtime)
}
pub fn from_settings(
settings: &AvroSettings,
runtime: &tokio::runtime::Handle,
) -> Result<Self, AvroConfigError> {
let reader_schema = settings
.reader_schema
.as_ref()
.map(|s| s.load().map(Arc::new))
.transpose()?;
let mode = match settings.mode {
AvroMode::Confluent => {
let registry = settings.registry.as_ref().ok_or(AvroConfigError::Invalid {
detail: "mode `confluent` requires a `registry` section".into(),
})?;
if settings.schema.is_some() {
return Err(AvroConfigError::Invalid {
detail: "`schema` is only used in `raw`/`single_object` modes; \
in `confluent` mode writer schemas come from the registry \
(use `reader_schema` to pin the resolved shape)"
.into(),
});
}
let registry_cfg = RegistryConfig {
url: registry.url.clone(),
basic_auth: registry
.username
.as_ref()
.map(|u| (u.clone(), registry.password.clone())),
negative_cache_ttl: settings.negative_cache_ttl,
};
let handle = spawn_fetcher(registry_cfg.clone(), runtime);
if !settings.prewarm_subjects.is_empty() {
let subjects = settings.prewarm_subjects.clone();
let cache = Arc::clone(&handle.cache);
runtime.spawn(async move {
crate::registry::prewarm(®istry_cfg, &subjects, &cache).await;
});
}
SchemaSourceMode::Confluent {
registry: handle,
memo: crate::cache::SchemaCache::empty_snapshot(),
}
}
AvroMode::Raw => {
let schema = Self::fixed_schema(settings, "raw")?;
if !settings.prewarm_subjects.is_empty() {
return Err(AvroConfigError::Invalid {
detail: "`prewarm_subjects` requires mode `confluent`".into(),
});
}
SchemaSourceMode::Raw { schema }
}
AvroMode::SingleObject => {
let schema = Self::fixed_schema(settings, "single_object")?;
if !settings.prewarm_subjects.is_empty() {
return Err(AvroConfigError::Invalid {
detail: "`prewarm_subjects` requires mode `confluent`".into(),
});
}
if settings.reader_schema.is_some() {
return Err(AvroConfigError::Invalid {
detail: "single_object mode decodes with the configured schema \
directly; `reader_schema` is not supported"
.into(),
});
}
let apache =
schema
.schema
.as_ref()
.map_err(|reason| AvroConfigError::SchemaLoad {
detail: format!(
"mode `single_object` computes the header fingerprint \
with the apache parser, which rejected the schema: {reason}"
),
})?;
let fp = apache.fingerprint::<Rabin>();
let bytes: [u8; 8] =
fp.bytes
.as_slice()
.try_into()
.map_err(|_| AvroConfigError::Invalid {
detail: "unexpected Rabin fingerprint width".into(),
})?;
SchemaSourceMode::SingleObject {
schema,
fingerprint: u64::from_le_bytes(bytes),
}
}
};
Ok(AvroDeserializerBuilder {
core: DecoderCore {
mode,
reader_schema,
},
})
}
fn fixed_schema(
settings: &AvroSettings,
mode: &str,
) -> Result<Arc<CompiledSchema>, AvroConfigError> {
let source = settings.schema.as_ref().ok_or(AvroConfigError::Invalid {
detail: format!("mode `{mode}` requires a `schema` (inline or path)"),
})?;
if settings.registry.is_some() {
return Err(AvroConfigError::Invalid {
detail: format!("mode `{mode}` does not use a `registry` section"),
});
}
let json = source.load_text()?;
let compiled = CompiledSchema::compile(0, &json);
if let Some(reason) = compiled.unusable_reason() {
return Err(AvroConfigError::SchemaLoad { detail: reason });
}
Ok(Arc::new(compiled))
}
fn fixed_schema_error(&self) -> Option<String> {
match &self.core.mode {
SchemaSourceMode::Raw { schema } | SchemaSourceMode::SingleObject { schema, .. } => {
schema.schema.as_ref().err().cloned()
}
SchemaSourceMode::Confluent { .. } => None,
}
}
pub fn build_value(&self) -> Result<AvroValueDeserializer, AvroConfigError> {
if let Some(reason) = self.fixed_schema_error() {
return Err(AvroConfigError::Invalid { detail: reason });
}
Ok(AvroValueDeserializer::new(self.core.clone()))
}
pub fn build_serde<T>(&self) -> Result<AvroSerdeDeserializer<T>, AvroConfigError>
where
T: serde::de::DeserializeOwned + Send + 'static,
{
if let Some(reason) = self.fixed_schema_error() {
return Err(AvroConfigError::Invalid { detail: reason });
}
Ok(AvroSerdeDeserializer::new(self.core.clone()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use spate_core::config::ComponentConfig;
const SCHEMA: &str = r#"{"type":"record","name":"E","fields":[{"name":"id","type":"long"}]}"#;
fn component(yaml: &str) -> ComponentConfig {
ComponentConfig::new("avro", serde_yaml::from_str(yaml).unwrap())
}
fn runtime() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
}
#[test]
fn full_confluent_section_parses() {
let cfg = component(
r#"
mode: confluent
registry:
url: http://sr:8081
username: svc
password: secret
prewarm_subjects: [orders-value, users-value]
negative_cache_ttl: 45s
reader_schema:
inline: '{"type":"record","name":"E","fields":[{"name":"id","type":"long"}]}'
"#,
);
let settings: AvroSettings = cfg.deserialize_into().unwrap();
assert_eq!(settings.mode, AvroMode::Confluent);
assert_eq!(settings.prewarm_subjects.len(), 2);
assert_eq!(settings.negative_cache_ttl, Duration::from_secs(45));
let rt = runtime();
AvroDeserializerBuilder::from_settings(&settings, rt.handle()).unwrap();
}
#[test]
fn unknown_fields_are_rejected() {
let cfg = component("mode: raw\nschema: {inline: '\"long\"'}\nturbo: true\n");
let err = cfg.deserialize_into::<AvroSettings>().unwrap_err();
assert!(err.to_string().contains("turbo"), "{err}");
}
#[test]
fn mode_requirements_are_validated() {
let rt = runtime();
let cases: &[(&str, &str)] = &[
("mode: confluent", "requires a `registry`"),
("mode: raw", "requires a `schema`"),
("mode: single_object", "requires a `schema`"),
];
for (yaml, needle) in cases {
let settings: AvroSettings = component(yaml).deserialize_into().unwrap();
let err = AvroDeserializerBuilder::from_settings(&settings, rt.handle()).unwrap_err();
assert!(err.to_string().contains(needle), "{yaml}: {err}");
}
}
#[test]
fn conflicting_sections_are_rejected() {
let rt = runtime();
let confluent_with_schema = format!(
"mode: confluent\nregistry: {{url: http://sr}}\nschema: {{inline: '{SCHEMA}'}}"
);
let raw_with_registry =
format!("mode: raw\nschema: {{inline: '{SCHEMA}'}}\nregistry: {{url: http://sr}}");
let raw_with_prewarm =
format!("mode: raw\nschema: {{inline: '{SCHEMA}'}}\nprewarm_subjects: [x]");
let so_with_prewarm =
format!("mode: single_object\nschema: {{inline: '{SCHEMA}'}}\nprewarm_subjects: [x]");
let so_with_reader = format!(
"mode: single_object\nschema: {{inline: '{SCHEMA}'}}\nreader_schema: {{inline: '{SCHEMA}'}}"
);
for yaml in [
confluent_with_schema,
raw_with_registry,
raw_with_prewarm,
so_with_prewarm,
so_with_reader,
] {
let settings: AvroSettings = component(&yaml).deserialize_into().unwrap();
assert!(
AvroDeserializerBuilder::from_settings(&settings, rt.handle()).is_err(),
"must reject: {yaml}"
);
}
}
#[test]
fn schema_from_file_and_bad_schema_errors() {
let rt = runtime();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("e.avsc");
std::fs::write(&path, SCHEMA).unwrap();
let settings: AvroSettings =
component(&format!("mode: raw\nschema: {{path: {}}}", path.display()))
.deserialize_into()
.unwrap();
AvroDeserializerBuilder::from_settings(&settings, rt.handle()).unwrap();
let settings: AvroSettings = component("mode: raw\nschema: {inline: 'not json'}")
.deserialize_into()
.unwrap();
let err = AvroDeserializerBuilder::from_settings(&settings, rt.handle()).unwrap_err();
assert!(matches!(err, AvroConfigError::SchemaLoad { .. }), "{err}");
}
}