use liminal::protocol::SchemaId as ProtocolSchemaId;
use serde_json::Value;
use crate::config::types::ChannelDef;
pub(super) struct ChannelSchema {
pub document: Value,
pub protocol_id: ProtocolSchemaId,
}
const EMPTY_SCHEMA_BYTES: &[u8] = b"{}";
pub(super) fn resolve_channel_schema(channel: &ChannelDef) -> ChannelSchema {
if channel.schema_ref.is_some() && channel.loaded_schema.is_none() {
tracing::warn!(
channel = %channel.name,
"channel declares a schema_ref but no schema was loaded; running permissive (config skipped validation)"
);
}
channel.loaded_schema.as_ref().map_or_else(
|| ChannelSchema {
document: Value::Object(serde_json::Map::new()),
protocol_id: schema_id_from_bytes(EMPTY_SCHEMA_BYTES),
},
|loaded| ChannelSchema {
document: loaded.document.clone(),
protocol_id: schema_id_from_bytes(&loaded.bytes),
},
)
}
pub(super) fn resolve_schema_bytes(
schema_bytes: Option<&[u8]>,
) -> Result<ChannelSchema, serde_json::Error> {
let Some(bytes) = schema_bytes else {
return Ok(ChannelSchema {
document: Value::Object(serde_json::Map::new()),
protocol_id: schema_id_from_bytes(EMPTY_SCHEMA_BYTES),
});
};
Ok(ChannelSchema {
document: serde_json::from_slice(bytes)?,
protocol_id: schema_id_from_bytes(bytes),
})
}
fn schema_id_from_bytes(schema_bytes: &[u8]) -> ProtocolSchemaId {
ProtocolSchemaId::from_schema_bytes(schema_bytes)
}
#[cfg(test)]
mod tests {
use super::{resolve_channel_schema, resolve_schema_bytes, schema_id_from_bytes};
use crate::config::types::{ChannelDef, LoadedSchema};
fn channel(schema_ref: Option<&str>, loaded: Option<LoadedSchema>) -> ChannelDef {
ChannelDef {
name: "orders".to_owned(),
schema_ref: schema_ref.map(Into::into),
durable: false,
loaded_schema: loaded,
}
}
#[test]
fn schema_ids_are_deterministic_and_content_addressed() {
assert_eq!(schema_id_from_bytes(b"a"), schema_id_from_bytes(b"a"));
assert_ne!(schema_id_from_bytes(b"a"), schema_id_from_bytes(b"b"));
}
#[test]
fn loaded_schema_id_derives_from_loaded_bytes() {
let bytes = br#"{"type":"object"}"#.to_vec();
let document = serde_json::json!({"type": "object"});
let resolved = resolve_channel_schema(&channel(
Some("orders.json"),
Some(LoadedSchema {
bytes: bytes.clone(),
document: document.clone(),
}),
));
assert_eq!(resolved.document, document);
assert_eq!(resolved.protocol_id, schema_id_from_bytes(&bytes));
}
#[test]
fn schema_less_channel_is_permissive_and_uses_empty_schema_id() {
let resolved = resolve_channel_schema(&channel(None, None));
assert_eq!(resolved.document, serde_json::json!({}));
assert_eq!(resolved.protocol_id, schema_id_from_bytes(b"{}"));
}
#[test]
fn raw_bytes_resolution_matches_the_channel_def_form() -> Result<(), serde_json::Error> {
let empty = resolve_schema_bytes(None)?;
let from_def = resolve_channel_schema(&channel(None, None));
assert_eq!(empty.document, from_def.document);
assert_eq!(empty.protocol_id, from_def.protocol_id);
let bytes = br#"{"type":"object"}"#.to_vec();
let loaded = resolve_schema_bytes(Some(&bytes))?;
let loaded_from_def = resolve_channel_schema(&channel(
Some("orders.json"),
Some(LoadedSchema {
bytes,
document: serde_json::json!({"type": "object"}),
}),
));
assert_eq!(loaded.document, loaded_from_def.document);
assert_eq!(loaded.protocol_id, loaded_from_def.protocol_id);
Ok(())
}
#[test]
fn raw_bytes_resolution_refuses_bytes_that_are_not_json() {
assert!(resolve_schema_bytes(Some(b"not json")).is_err());
}
#[test]
fn declared_but_unloaded_schema_warns_naming_the_channel() {
let log = crate::test_log::CapturedLog::default();
let resolved = log.capture(|| resolve_channel_schema(&channel(Some("orders.json"), None)));
assert_eq!(resolved.document, serde_json::json!({}));
let output = log.contents();
assert!(
output.contains("WARN") && output.contains("orders"),
"expected a warn naming the channel, got: {output}"
);
}
#[test]
fn schema_less_channel_resolves_without_warning() {
let log = crate::test_log::CapturedLog::default();
let _resolved = log.capture(|| resolve_channel_schema(&channel(None, None)));
let output = log.contents();
assert!(
!output.contains("WARN"),
"a schema-less channel must resolve silently, got: {output}"
);
}
}