mod cache;
mod client;
mod name;
use std::collections::{HashMap, HashSet};
use std::error::Error as StdError;
use std::fmt;
use std::sync::{Arc, Mutex};
use std::time::Duration;
pub use cache::{MemorySchemaCache, SchemaCache, SchemaCachePolicy};
pub use client::{HttpRegistryClient, RegistryClient};
use client::{Auth, HttpConfig};
pub(crate) use name::serde_name;
use ruststream::codec::{Codec, CodecError};
use ruststream::runtime::{Outgoing, PublishLayer, PublishNext, PublishPipeline};
use ruststream::{BytesMut, Publisher};
pub use schemars::JsonSchema;
use crate::error::KafkaError;
pub(crate) const WIRE_MAGIC: u8 = 0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SchemaType {
Avro,
Protobuf,
Json,
}
impl SchemaType {
fn as_api(self) -> &'static str {
match self {
Self::Avro => "AVRO",
Self::Protobuf => "PROTOBUF",
Self::Json => "JSON",
}
}
fn from_api(value: Option<&str>) -> Self {
match value {
Some("PROTOBUF") => Self::Protobuf,
Some("JSON") => Self::Json,
_ => Self::Avro,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RegisteredSchema {
id: u32,
schema_type: SchemaType,
definition: String,
}
impl RegisteredSchema {
#[must_use]
pub fn new(id: u32, schema_type: SchemaType, definition: impl Into<String>) -> Self {
Self {
id,
schema_type,
definition: definition.into(),
}
}
#[must_use]
pub fn id(&self) -> u32 {
self.id
}
#[must_use]
pub fn schema_type(&self) -> SchemaType {
self.schema_type
}
#[must_use]
pub fn definition(&self) -> &str {
&self.definition
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum SubjectStrategy {
#[default]
TopicName,
RecordName,
TopicRecordName,
}
impl SubjectStrategy {
#[must_use]
pub fn subject(self, topic: &str, record: &str) -> String {
match self {
Self::TopicName => format!("{topic}-value"),
Self::RecordName => record.to_owned(),
Self::TopicRecordName => format!("{topic}-{record}"),
}
}
}
struct RegistryInner {
client: Arc<dyn RegistryClient>,
cache: Arc<dyn SchemaCache>,
shipped: Option<HttpConfig>,
#[cfg(feature = "avro")]
parsed_avro: Mutex<HashMap<u32, Arc<apache_avro::Schema>>>,
#[cfg(feature = "protobuf")]
parsed_proto: Mutex<HashMap<u32, Arc<prost_reflect::DescriptorPool>>>,
}
#[derive(Clone)]
pub struct SchemaRegistry {
inner: Arc<RegistryInner>,
}
impl fmt::Debug for SchemaRegistry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SchemaRegistry").finish_non_exhaustive()
}
}
impl SchemaRegistry {
#[must_use]
pub fn new(base_url: impl Into<String>) -> Self {
let mut base_url = base_url.into();
while base_url.ends_with('/') {
base_url.pop();
}
Self::shipping(HttpConfig::new(base_url))
}
#[must_use]
pub fn with_client(client: Arc<dyn RegistryClient>) -> Self {
Self::assembled(
client,
Arc::new(MemorySchemaCache::new(SchemaCachePolicy::default())),
)
}
pub(crate) fn base_url(&self) -> Option<&str> {
self.inner
.shipped
.as_ref()
.map(|config| config.base_url.as_str())
}
#[must_use]
pub fn with_cache(self, cache: Arc<dyn SchemaCache>) -> Self {
let assembled = Self::assembled(Arc::clone(&self.inner.client), cache);
match self.inner.shipped.clone() {
Some(config) => assembled.remembering(config),
None => assembled,
}
}
#[must_use]
pub fn cache_policy(self, policy: SchemaCachePolicy) -> Self {
self.with_cache(Arc::new(MemorySchemaCache::new(policy)))
}
#[must_use]
pub fn basic_auth(self, user: impl Into<String>, password: impl Into<String>) -> Self {
self.with_auth(Auth::Basic {
user: user.into(),
password: password.into(),
})
}
#[must_use]
pub fn request_timeout(self, timeout: Duration) -> Self {
let mut config = self.shipped_config();
config.timeout = timeout;
Self::shipping(config)
}
#[must_use]
pub fn bearer_token(self, token: impl Into<String>) -> Self {
self.with_auth(Auth::Bearer(token.into()))
}
fn with_auth(self, auth: Auth) -> Self {
let mut config = self.shipped_config();
config.auth = auth;
Self::shipping(config)
}
fn shipped_config(&self) -> HttpConfig {
self.inner.shipped.clone().expect(
"basic_auth, bearer_token and request_timeout configure the HTTP client this crate \
ships; a RegistryClient supplied with `with_client` carries its own credentials and \
bounds its own requests",
)
}
fn shipping(config: HttpConfig) -> Self {
Self::assembled(
Arc::new(HttpRegistryClient::new(config.clone())),
Arc::new(MemorySchemaCache::new(SchemaCachePolicy::default())),
)
.remembering(config)
}
fn assembled(client: Arc<dyn RegistryClient>, cache: Arc<dyn SchemaCache>) -> Self {
Self {
inner: Arc::new(RegistryInner {
client,
cache,
shipped: None,
#[cfg(feature = "avro")]
parsed_avro: Mutex::new(HashMap::new()),
#[cfg(feature = "protobuf")]
parsed_proto: Mutex::new(HashMap::new()),
}),
}
}
fn remembering(self, config: HttpConfig) -> Self {
let mut inner = self.inner;
let slot = Arc::get_mut(&mut inner).expect("freshly assembled, not yet shared");
slot.shipped = Some(config);
Self { inner }
}
fn remember(&self, subject: Option<&str>, schema: &Arc<RegisteredSchema>) {
self.inner.cache.store(subject, schema);
let evicted = self.inner.cache.evicted_ids();
if !evicted.is_empty() {
self.purge_parsed(&evicted);
}
}
pub async fn schema_by_id(&self, id: u32) -> Result<Arc<RegisteredSchema>, KafkaError> {
if let Some(schema) = self.cached_schema(id) {
return Ok(schema);
}
let schema = self.inner.client.schema_by_id(id).await?;
self.remember(None, &schema);
Ok(schema)
}
pub async fn register(
&self,
subject: &str,
schema_type: SchemaType,
definition: impl Into<String>,
) -> Result<u32, KafkaError> {
let definition = definition.into();
let id = self
.inner
.client
.register(subject, schema_type, definition.clone())
.await?;
self.remember(
Some(subject),
&Arc::new(RegisteredSchema::new(id, schema_type, definition)),
);
Ok(id)
}
pub async fn is_compatible(
&self,
subject: &str,
schema_type: SchemaType,
definition: impl Into<String>,
) -> Result<Option<bool>, KafkaError> {
self.inner
.client
.is_compatible(subject, schema_type, definition.into())
.await
}
pub async fn lookup_id(
&self,
subject: &str,
schema_type: SchemaType,
definition: impl Into<String>,
) -> Result<u32, KafkaError> {
self.inner
.client
.lookup_id(subject, schema_type, definition.into())
.await
}
pub async fn warm(&self, subject: &str) -> Result<Arc<RegisteredSchema>, KafkaError> {
self.latest(subject).await?.ok_or_else(|| {
KafkaError::SchemaRegistry(format!("the registry has no subject {subject:?}").into())
})
}
pub(crate) async fn latest(
&self,
subject: &str,
) -> Result<Option<Arc<RegisteredSchema>>, KafkaError> {
let Some(schema) = self.inner.client.latest(subject).await? else {
return Ok(None);
};
self.remember(Some(subject), &schema);
Ok(Some(schema))
}
#[cfg(any(feature = "avro", feature = "protobuf"))]
fn purge_parsed(&self, evicted: &[u32]) {
for id in evicted {
#[cfg(feature = "avro")]
self.inner
.parsed_avro
.lock()
.expect("parsed schema cache mutex poisoned")
.remove(id);
#[cfg(feature = "protobuf")]
self.inner
.parsed_proto
.lock()
.expect("descriptor cache mutex poisoned")
.remove(id);
}
}
#[cfg(not(any(feature = "avro", feature = "protobuf")))]
#[allow(clippy::unused_self)]
fn purge_parsed(&self, _evicted: &[u32]) {}
#[must_use]
pub fn cached_schema(&self, id: u32) -> Option<Arc<RegisteredSchema>> {
self.inner.cache.schema(id)
}
#[must_use]
pub fn cached_subject(&self, subject: &str) -> Option<Arc<RegisteredSchema>> {
self.inner.cache.subject(subject)
}
pub async fn register_json<T: JsonSchema>(&self, subject: &str) -> Result<u32, KafkaError> {
let schema = schemars::SchemaGenerator::default().into_root_schema_for::<T>();
let definition = serde_json::to_string(&schema).map_err(KafkaError::schema_registry)?;
self.register(subject, SchemaType::Json, definition).await
}
#[cfg(feature = "avro")]
pub async fn register_avro<T: apache_avro::AvroSchema>(
&self,
subject: &str,
) -> Result<u32, KafkaError> {
let definition = crate::avro::schema_json(&T::get_schema())?;
self.register(subject, SchemaType::Avro, definition).await
}
#[cfg(feature = "avro")]
pub(crate) fn parsed_avro(
&self,
schema: &RegisteredSchema,
) -> Result<Arc<apache_avro::Schema>, KafkaError> {
if let Some(parsed) = self
.inner
.parsed_avro
.lock()
.expect("parsed schema cache mutex poisoned")
.get(&schema.id)
{
return Ok(Arc::clone(parsed));
}
let parsed = Arc::new(
apache_avro::Schema::parse_str(&schema.definition)
.map_err(KafkaError::schema_registry)?,
);
self.inner
.parsed_avro
.lock()
.expect("parsed schema cache mutex poisoned")
.insert(schema.id, Arc::clone(&parsed));
Ok(parsed)
}
#[cfg(feature = "protobuf")]
pub(crate) fn parsed_proto(
&self,
schema: &RegisteredSchema,
) -> Result<Arc<prost_reflect::DescriptorPool>, KafkaError> {
if let Some(parsed) = self
.inner
.parsed_proto
.lock()
.expect("descriptor cache mutex poisoned")
.get(&schema.id)
{
return Ok(Arc::clone(parsed));
}
let pool = compile_proto(&schema.definition).map_err(KafkaError::schema_registry)?;
let pool = Arc::new(pool);
self.inner
.parsed_proto
.lock()
.expect("descriptor cache mutex poisoned")
.insert(schema.id, Arc::clone(&pool));
Ok(pool)
}
pub(crate) async fn incoming_to_json(&self, payload: &[u8]) -> Option<Vec<u8>> {
let (id, datum) = parse_envelope(payload)?;
let schema = match self.schema_by_id(id).await {
Ok(schema) => schema,
Err(err) => {
tracing::warn!(
target: "ruststream_rdkafka",
schema_id = id,
error = %err,
"schema fetch failed; the delivery passes through un-transcoded until \
the registry recovers",
);
return None;
}
};
let transcoded = incoming_datum_to_json(self, &schema, datum);
match transcoded {
Ok(json) => Some(json),
Err(err) => {
tracing::warn!(
target: "ruststream_rdkafka",
schema_id = id,
error = %err,
"transcoding a framed delivery failed; the delivery passes through \
un-transcoded",
);
None
}
}
}
}
#[cfg(feature = "protobuf")]
fn compile_proto(source: &str) -> Result<prost_reflect::DescriptorPool, protox::Error> {
use protox::file::{ChainFileResolver, File, FileResolver, GoogleFileResolver};
struct Single(String);
impl FileResolver for Single {
fn open_file(&self, name: &str) -> Result<File, protox::Error> {
if name == "registry.proto" {
File::from_source(name, &self.0)
} else {
Err(protox::Error::file_not_found(name))
}
}
}
let mut resolver = ChainFileResolver::new();
resolver.add(GoogleFileResolver::new());
resolver.add(Single(source.to_owned()));
let mut compiler = protox::Compiler::with_file_resolver(resolver);
compiler.include_imports(true);
compiler.open_file("registry.proto")?;
let set = compiler.file_descriptor_set();
prost_reflect::DescriptorPool::from_file_descriptor_set(set)
.map_err(|err| protox::Error::new(err.to_string()))
}
fn incoming_datum_to_json(
registry: &SchemaRegistry,
schema: &RegisteredSchema,
datum: &[u8],
) -> Result<Vec<u8>, KafkaError> {
let _ = registry;
match schema.schema_type {
SchemaType::Json => Ok(datum.to_vec()),
#[cfg(feature = "avro")]
SchemaType::Avro => crate::avro::avro_to_json(registry, schema, datum),
#[cfg(feature = "protobuf")]
SchemaType::Protobuf => crate::protobuf::protobuf_to_json(registry, schema, datum),
#[allow(unreachable_patterns)] other => Err(KafkaError::InvalidOptions(format!(
"schema id {} is {other:?}, but the matching cargo feature is not enabled on \
ruststream-rdkafka; enable it to consume this topic",
schema.id,
))),
}
}
fn outgoing_json_to_datum(
registry: &SchemaRegistry,
schema: &RegisteredSchema,
message: Option<&str>,
payload: &[u8],
) -> Result<Vec<u8>, KafkaError> {
let _ = (registry, message);
match schema.schema_type {
SchemaType::Json => Ok(payload.to_vec()),
#[cfg(feature = "avro")]
SchemaType::Avro => crate::avro::json_to_avro(registry, schema, payload),
#[cfg(feature = "protobuf")]
SchemaType::Protobuf => {
crate::protobuf::json_to_protobuf(registry, schema, message, payload)
}
#[allow(unreachable_patterns)] other => Err(KafkaError::InvalidOptions(format!(
"the subject's schema (id {}) is {other:?}, but the matching cargo feature is \
not enabled on ruststream-rdkafka; enable it to publish to this topic",
schema.id,
))),
}
}
#[derive(Clone)]
pub struct SchemaPrefetch {
registry: SchemaRegistry,
registrations: Arc<Mutex<Vec<Registration>>>,
on_missing: MissingSubject,
check_compatibility: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct Registration {
pub(crate) subject: String,
pub(crate) schema_type: SchemaType,
pub(crate) definition: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum MissingSubject {
#[default]
Refuse,
AutoRegister,
PublishUnframed,
}
impl fmt::Debug for SchemaPrefetch {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SchemaPrefetch")
.field("registry", &self.registry)
.field("on_missing", &self.on_missing)
.finish_non_exhaustive()
}
}
impl SchemaPrefetch {
#[must_use]
pub fn new(registry: SchemaRegistry) -> Self {
Self {
registry,
registrations: Arc::new(Mutex::new(Vec::new())),
on_missing: MissingSubject::default(),
check_compatibility: true,
}
}
#[must_use]
pub fn check_compatibility(mut self, check: bool) -> Self {
self.check_compatibility = check;
self
}
#[must_use]
pub fn on_missing_subject(mut self, policy: MissingSubject) -> Self {
self.on_missing = policy;
self
}
#[must_use]
pub fn registry(&self) -> &SchemaRegistry {
&self.registry
}
pub(crate) fn missing_subject(&self) -> MissingSubject {
self.on_missing
}
pub(crate) fn record(&self, registration: Registration) {
self.registrations
.lock()
.expect("prefetch registrations mutex poisoned")
.push(registration);
}
pub(crate) async fn warm_subjects(&self) -> Result<(), KafkaError> {
let registrations: Vec<Registration> = self
.registrations
.lock()
.expect("prefetch registrations mutex poisoned")
.clone();
for registration in registrations {
let subject = ®istration.subject;
if self.registry.latest(subject).await?.is_some() {
if self.check_compatibility {
self.registry
.is_compatible(
subject,
registration.schema_type,
registration.definition.clone(),
)
.await?;
}
continue;
}
match self.on_missing {
MissingSubject::Refuse => {
return Err(KafkaError::SchemaRegistry(
format!(
"subject {subject:?} is registered by a codec in this app but the \
registry does not hold it, so there is no id to frame its records \
with. It was most likely deleted; re-register it, point the codec \
at the subject that carries this schema, or choose a \
MissingSubject policy that repairs or bypasses it."
)
.into(),
));
}
MissingSubject::AutoRegister => {
let id = self
.registry
.register(
subject,
registration.schema_type,
registration.definition.clone(),
)
.await?;
tracing::warn!(
target: "ruststream_rdkafka",
subject = %subject,
schema_id = id,
schema_type = ?registration.schema_type,
definition = %registration.definition,
"the registry did not hold a subject this app publishes under, so it \
was registered again from the schema the message type carries; records \
written before the deletion keep the id they were framed with, which a \
permanent delete has already made unreadable",
);
}
MissingSubject::PublishUnframed => {
tracing::warn!(
target: "ruststream_rdkafka",
subject = %subject,
schema_type = ?registration.schema_type,
definition = %registration.definition,
"the registry did not hold a subject this app publishes under, so its \
records go out with no Confluent envelope; a registry-backed consumer \
cannot decode them",
);
}
}
}
Ok(())
}
pub(crate) async fn warm_delivery(&self, payload: &[u8]) {
let Some((id, _)) = parse_envelope(payload) else {
return;
};
if self.registry.cached_schema(id).is_some() {
return;
}
if let Err(err) = self.registry.schema_by_id(id).await {
tracing::warn!(
target: "ruststream_rdkafka",
schema_id = id,
error = %err,
"the writer schema of an arriving delivery could not be resolved; its decode \
will report the miss and the subscription's failure policy settles it",
);
}
}
}
#[derive(Debug, Clone)]
pub struct SchemaFramed<C> {
registry: SchemaRegistry,
subjects: Arc<HashMap<&'static str, String>>,
on_missing: MissingSubject,
prefetch: SchemaPrefetch,
inner: C,
}
impl<C> SchemaFramed<C> {
#[must_use]
pub fn new(prefetch: &SchemaPrefetch, inner: C) -> Self {
Self {
registry: prefetch.registry().clone(),
subjects: Arc::new(HashMap::new()),
on_missing: prefetch.missing_subject(),
prefetch: prefetch.clone(),
inner,
}
}
#[must_use]
pub fn register<T: serde::Serialize + JsonSchema>(
mut self,
subject: impl Into<String>,
) -> Self {
let subject = subject.into();
let name: &'static str = String::leak(T::schema_name().to_string());
let definition = serde_json::to_string(
&schemars::SchemaGenerator::default().into_root_schema_for::<T>(),
)
.unwrap_or_else(|err| panic!("the JSON Schema of the type under {subject:?}: {err}"));
let table = Arc::get_mut(&mut self.subjects).expect("not yet shared with a mount");
if let Some(existing) = table.insert(name, subject.clone())
&& existing != subject
{
panic!(
"two types registered on this codec are both called {name:?} to serde, so a \
publish could not tell {existing:?} from {subject:?}; give one of them a \
distinct `#[serde(rename = \"..\")]`"
);
}
self.prefetch.record(Registration {
subject,
schema_type: SchemaType::Json,
definition,
});
self
}
pub fn inner(&self) -> &C {
&self.inner
}
}
impl<C: Codec> Codec for SchemaFramed<C> {
fn encode<T: serde::Serialize>(&self, value: &T) -> Result<BytesMut, CodecError> {
let name = serde_name(value).ok_or_else(|| {
CodecError::Encode(Box::new(KafkaError::malformed(
"this codec frames by the name serde gives a value's type, and this value has \
none - a bare number, a sequence or a map. Publish a named struct, or use a \
codec that needs no subject"
.to_owned(),
)))
})?;
let subject = self.subjects.get(name).ok_or_else(|| {
CodecError::Encode(Box::new(KafkaError::malformed(format!(
"no subject is registered for messages named {name:?}, so this codec has no id \
to frame them with; add `.register::<{name}>(\"..\")` to it. The types it does \
carry are {:?}",
self.subjects.keys().collect::<Vec<_>>(),
))))
})?;
let datum = self.inner.encode(value)?;
let Some(schema) = self.registry.cached_subject(subject) else {
if self.on_missing == MissingSubject::PublishUnframed {
return Ok(datum);
}
return Err(CodecError::Encode(Box::new(KafkaError::malformed(
format!(
"no schema is cached for subject {subject:?}, which messages named {name:?} \
publish under. Attach the SchemaPrefetch this codec was built from to the \
broker (KafkaBroker::schema_prefetch) so connect resolves it; if it was \
resolved and has since gone, the subject was deleted from the registry and \
MissingSubject says what to do about that"
),
))));
};
let mut framed = BytesMut::with_capacity(1 + 4 + datum.len());
framed.extend_from_slice(&[WIRE_MAGIC]);
framed.extend_from_slice(&schema.id().to_be_bytes());
framed.extend_from_slice(&datum);
Ok(framed)
}
fn decode<T: serde::de::DeserializeOwned>(&self, bytes: &[u8]) -> Result<T, CodecError> {
let (_, datum) = parse_envelope(bytes).ok_or_else(|| {
CodecError::Decode(Box::new(KafkaError::malformed(format!(
"the delivery does not carry the Confluent wire format (a zero magic byte and a \
4-byte schema id) that a registry-backed topic publishes under; its first bytes \
are {:02x?}",
&bytes[..bytes.len().min(8)],
))))
})?;
self.inner.decode(datum)
}
}
#[derive(Clone)]
pub(crate) struct SubjectMap {
registry: SchemaRegistry,
strategy: SubjectStrategy,
pins: HashMap<String, String>,
skipped: Arc<Mutex<HashSet<String>>>,
}
impl SubjectMap {
pub(crate) fn new(registry: SchemaRegistry) -> Self {
Self {
registry,
strategy: SubjectStrategy::default(),
pins: HashMap::new(),
skipped: Arc::new(Mutex::new(HashSet::new())),
}
}
pub(crate) fn registry(&self) -> &SchemaRegistry {
&self.registry
}
pub(crate) fn strategy(&self) -> SubjectStrategy {
self.strategy
}
pub(crate) fn set_strategy(&mut self, strategy: SubjectStrategy) {
self.strategy = strategy;
}
pub(crate) fn pins(&self) -> &HashMap<String, String> {
&self.pins
}
pub(crate) fn pin(&mut self, topic: String, subject: String) {
self.pins.insert(topic, subject);
}
pub(crate) fn subject_for(&self, topic: &str) -> String {
self.pins
.get(topic)
.cloned()
.unwrap_or_else(|| self.strategy.subject(topic, ""))
}
pub(crate) async fn schema_for(
&self,
topic: &str,
) -> Result<Option<Arc<RegisteredSchema>>, KafkaError> {
let subject = self.subject_for(topic);
if let Some(schema) = self.registry.cached_subject(&subject) {
return Ok(Some(schema));
}
if self.is_skipped(&subject) {
return Ok(None);
}
let Some(schema) = self.registry.latest(&subject).await? else {
self.skip(topic, &subject);
return Ok(None);
};
Ok(Some(schema))
}
fn is_skipped(&self, subject: &str) -> bool {
self.skipped
.lock()
.expect("skipped-subjects mutex poisoned")
.contains(subject)
}
fn skip(&self, topic: &str, subject: &str) {
let mut skipped = self
.skipped
.lock()
.expect("skipped-subjects mutex poisoned");
if skipped.insert(subject.to_owned()) {
tracing::info!(
target: "ruststream_rdkafka",
topic,
subject,
"no schema registered for the topic's subject; its publishes go out un-framed",
);
}
}
}
#[derive(Clone)]
pub struct SchemaFrame {
subjects: SubjectMap,
messages: HashMap<String, String>,
}
impl fmt::Debug for SchemaFrame {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SchemaFrame")
.field("strategy", &self.subjects.strategy())
.field("subjects", self.subjects.pins())
.finish_non_exhaustive()
}
}
impl SchemaFrame {
#[must_use]
pub fn new(registry: SchemaRegistry) -> Self {
Self {
subjects: SubjectMap::new(registry),
messages: HashMap::new(),
}
}
#[must_use]
pub fn subject_strategy(mut self, strategy: SubjectStrategy) -> Self {
self.subjects.set_strategy(strategy);
self
}
#[must_use]
pub fn subject(mut self, topic: impl Into<String>, subject: impl Into<String>) -> Self {
self.subjects.pin(topic.into(), subject.into());
self
}
#[must_use]
pub fn message(mut self, topic: impl Into<String>, message: impl Into<String>) -> Self {
self.messages.insert(topic.into(), message.into());
self
}
async fn frame(&self, out: &mut Outgoing<'_>) -> Result<(), KafkaError> {
let topic = out.name().to_owned();
let Some(schema) = self.subjects.schema_for(&topic).await? else {
return Ok(());
};
let message = self.messages.get(&topic).map(String::as_str);
let datum =
outgoing_json_to_datum(self.subjects.registry(), &schema, message, out.payload())?;
let framed = encode_envelope(schema.id, &datum);
let payload = out.payload_mut();
payload.clear();
payload.extend_from_slice(&framed);
Ok(())
}
}
impl PublishLayer for SchemaFrame {
async fn on_publish<'a, N: PublishPipeline, P: Publisher>(
&'a self,
out: &'a mut Outgoing<'a>,
next: PublishNext<'a, N, P>,
) -> Result<(), Box<dyn StdError + Send + Sync>> {
self.frame(out).await?;
next.run(out).await
}
}
#[must_use]
pub fn parse_envelope(payload: &[u8]) -> Option<(u32, &[u8])> {
let (&magic, rest) = payload.split_first()?;
if magic != WIRE_MAGIC || rest.len() < 4 {
return None;
}
let (id_bytes, datum) = rest.split_at(4);
let id = u32::from_be_bytes(id_bytes.try_into().expect("4-byte slice"));
Some((id, datum))
}
#[must_use]
pub fn encode_envelope(schema_id: u32, datum: &[u8]) -> Vec<u8> {
let mut framed = Vec::with_capacity(1 + 4 + datum.len());
framed.push(WIRE_MAGIC);
framed.extend_from_slice(&schema_id.to_be_bytes());
framed.extend_from_slice(datum);
framed
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn envelope_roundtrips() {
let framed = encode_envelope(1234, b"datum");
let (id, datum) = parse_envelope(&framed).expect("framed");
assert_eq!(id, 1234);
assert_eq!(datum, b"datum");
}
#[test]
fn non_framed_payloads_are_recognized() {
assert!(parse_envelope(b"").is_none());
assert!(parse_envelope(b"{\"json\":1}").is_none());
assert!(parse_envelope(&[0, 1, 2]).is_none(), "short id");
}
#[tokio::test]
async fn frame_wraps_registered_json_subjects() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/subjects/orders-value/versions/latest"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 11,
"version": 1,
"schema": "{\"type\":\"object\"}",
"schemaType": "JSON",
})))
.expect(1)
.mount(&server)
.await;
let frame = SchemaFrame::new(SchemaRegistry::new(server.uri()));
let json = br#"{"id":7}"#;
let mut out = Outgoing::new("orders", json.as_slice());
frame.frame(&mut out).await.expect("frame");
let (id, datum) = parse_envelope(out.payload()).expect("framed");
assert_eq!(id, 11);
assert_eq!(datum, json);
let mut again = Outgoing::new("orders", json.as_slice());
frame.frame(&mut again).await.expect("frame cached");
assert_eq!(again.payload(), out.payload());
}
#[tokio::test]
async fn unregistered_subjects_pass_through_and_cache_the_miss() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/subjects/plain-value/versions/latest"))
.respond_with(ResponseTemplate::new(404))
.expect(1)
.mount(&server)
.await;
let frame = SchemaFrame::new(SchemaRegistry::new(server.uri()));
let json = br#"{"plain":true}"#;
for _ in 0..2 {
let mut out = Outgoing::new("plain", json.as_slice());
frame.frame(&mut out).await.expect("pass through");
assert_eq!(out.payload(), json, "un-framed");
}
}
#[tokio::test]
async fn registry_outages_fail_the_publish() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/subjects/orders-value/versions/latest"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let frame = SchemaFrame::new(SchemaRegistry::new(server.uri()));
let mut out = Outgoing::new("orders", br#"{"id":7}"#.as_slice());
let err = frame.frame(&mut out).await.expect_err("outage");
assert!(matches!(err, KafkaError::SchemaRegistry(_)));
}
#[tokio::test]
async fn pinned_subjects_override_the_strategy() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/subjects/com.acme.Order/versions/latest"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 3,
"version": 1,
"schema": "{\"type\":\"object\"}",
"schemaType": "JSON",
})))
.expect(1)
.mount(&server)
.await;
let frame =
SchemaFrame::new(SchemaRegistry::new(server.uri())).subject("orders", "com.acme.Order");
let mut out = Outgoing::new("orders", br#"{"id":1}"#.as_slice());
frame.frame(&mut out).await.expect("frame");
let (id, _) = parse_envelope(out.payload()).expect("framed");
assert_eq!(id, 3);
}
#[tokio::test]
async fn the_startup_check_asks_what_registration_would() {
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/subjects/orders-value/versions/latest"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 21,
"version": 3,
"schema": "{\"type\":\"object\"}",
"schemaType": "JSON",
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/compatibility/subjects/orders-value/versions"))
.and(query_param("verbose", "true"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"is_compatible": false,
"messages": ["incompatible with version 1"],
})))
.expect(1)
.mount(&server)
.await;
let prefetch = SchemaPrefetch::new(SchemaRegistry::new(server.uri()));
prefetch.record(Registration {
subject: "orders-value".to_owned(),
schema_type: SchemaType::Json,
definition: "{\"type\":\"object\"}".to_owned(),
});
let err = prefetch
.warm_subjects()
.await
.expect_err("a schema the subject's history refuses must not start");
assert!(
err.to_string().contains("incompatible with version 1"),
"the registry's own account travels in the error: {err}",
);
}
#[test]
fn subject_strategies_name_confluent_style() {
assert_eq!(
SubjectStrategy::TopicName.subject("orders", "com.acme.Order"),
"orders-value"
);
assert_eq!(
SubjectStrategy::RecordName.subject("orders", "com.acme.Order"),
"com.acme.Order"
);
assert_eq!(
SubjectStrategy::TopicRecordName.subject("orders", "com.acme.Order"),
"orders-com.acme.Order"
);
}
}