use std::collections::HashMap;
use std::fmt;
use std::sync::{Arc, Mutex};
use prost::Message as _;
use prost_reflect::{DescriptorPool, DynamicMessage, MessageDescriptor};
#[cfg(feature = "asyncapi")]
use ruststream::asyncapi::Bindings;
use ruststream::runtime::{Outgoing, PublishLayer, PublishNext, PublishPipeline};
use ruststream::{OutgoingFor, OutgoingMessage, PairError, PublishPolicy, Publisher};
use crate::broker::ConnectedKafkaBroker;
use crate::error::KafkaError;
use crate::publisher::{KafkaPublish, KafkaPublisher};
use crate::schema_registry::{
RegisteredSchema, SchemaRegistry, SchemaType, SubjectMap, SubjectStrategy, WIRE_MAGIC,
parse_envelope,
};
pub fn decode_confluent<T: prost::Message + Default>(payload: &[u8]) -> Result<T, KafkaError> {
match parse_envelope(payload) {
Some((schema_id, datum)) => decode_datum(schema_id, datum),
None => T::decode(payload).map_err(KafkaError::wire_format),
}
}
fn decode_datum<T: prost::Message + Default>(
schema_id: u32,
datum: &[u8],
) -> Result<T, KafkaError> {
let message = skip_indexes(datum).ok_or_else(|| {
KafkaError::malformed(format!(
"the message-index path of schema id {schema_id} is truncated, so the Protobuf \
message after it cannot be found",
))
})?;
T::decode(message).map_err(KafkaError::wire_format)
}
#[derive(Clone)]
struct IndexPrefix {
schema_id: u32,
bytes: Arc<[u8]>,
}
type IndexPrefixes = Arc<Mutex<HashMap<String, IndexPrefix>>>;
#[derive(Debug)]
pub(crate) enum Framing {
Framed(Vec<u8>),
AlreadyFramed,
NoSubject,
OtherFormat(SchemaType),
}
#[derive(Clone)]
pub(crate) struct ProtobufFraming {
subjects: SubjectMap,
messages: HashMap<String, String>,
prefixes: IndexPrefixes,
}
impl fmt::Debug for ProtobufFraming {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ProtobufFraming")
.field("strategy", &self.subjects.strategy())
.field("subjects", self.subjects.pins())
.field("messages", &self.messages)
.finish_non_exhaustive()
}
}
impl ProtobufFraming {
pub(crate) fn new(registry: SchemaRegistry) -> Self {
Self {
subjects: SubjectMap::new(registry),
messages: HashMap::new(),
prefixes: Arc::new(Mutex::new(HashMap::new())),
}
}
pub(crate) fn set_strategy(&mut self, strategy: SubjectStrategy) {
self.subjects.set_strategy(strategy);
}
pub(crate) fn strategy(&self) -> SubjectStrategy {
self.subjects.strategy()
}
pub(crate) fn pin_subject(&mut self, topic: String, subject: String) {
self.subjects.pin(topic, subject);
}
pub(crate) fn pin_message(&mut self, topic: String, message: String) {
self.messages.insert(topic, message);
}
pub(crate) fn subject_for(&self, topic: &str) -> String {
self.subjects.subject_for(topic)
}
pub(crate) async fn frame(&self, topic: &str, payload: &[u8]) -> Result<Framing, KafkaError> {
if parse_envelope(payload).is_some() {
return Ok(Framing::AlreadyFramed);
}
let Some(schema) = self.subjects.schema_for(topic).await? else {
return Ok(Framing::NoSubject);
};
if schema.schema_type() != SchemaType::Protobuf {
return Ok(Framing::OtherFormat(schema.schema_type()));
}
let prefix = self.prefix_for(topic, &schema)?;
let mut framed = Vec::with_capacity(1 + 4 + prefix.len() + payload.len());
framed.push(WIRE_MAGIC);
framed.extend_from_slice(&schema.id().to_be_bytes());
framed.extend_from_slice(&prefix);
framed.extend_from_slice(payload);
Ok(Framing::Framed(framed))
}
fn prefix_for(&self, topic: &str, schema: &RegisteredSchema) -> Result<Arc<[u8]>, KafkaError> {
if let Some(prefix) = self
.prefixes
.lock()
.expect("message-index prefix mutex poisoned")
.get(topic)
&& prefix.schema_id == schema.id()
{
return Ok(Arc::clone(&prefix.bytes));
}
let pool = self.subjects.registry().parsed_proto(schema)?;
let descriptor = match self.messages.get(topic) {
Some(name) => pool.get_message_by_name(name).ok_or_else(|| {
KafkaError::InvalidOptions(format!(
"message {name:?}, pinned for topic {topic:?}, is not in its subject's \
schema (use the fully qualified name, package included)",
))
})?,
None => registry_file(&pool)
.and_then(|file| file.messages().next())
.ok_or_else(|| {
KafkaError::InvalidOptions(format!(
"the subject of topic {topic:?} holds a Protobuf schema declaring no \
messages, so there is no message-index path to frame with",
))
})?,
};
let indexes = message_indexes(&descriptor).ok_or_else(|| {
KafkaError::InvalidOptions(format!(
"message {:?} is not declared by the registered schema file of topic {topic:?}",
descriptor.full_name(),
))
})?;
let bytes: Arc<[u8]> = Arc::from(encode_indexes(&indexes));
self.prefixes
.lock()
.expect("message-index prefix mutex poisoned")
.insert(
topic.to_owned(),
IndexPrefix {
schema_id: schema.id(),
bytes: Arc::clone(&bytes),
},
);
Ok(bytes)
}
}
#[derive(Clone, Debug)]
pub struct ProtobufFrame {
framing: ProtobufFraming,
}
impl ProtobufFrame {
#[must_use]
pub fn new(registry: SchemaRegistry) -> Self {
Self {
framing: ProtobufFraming::new(registry),
}
}
#[must_use]
pub fn subject_strategy(mut self, strategy: SubjectStrategy) -> Self {
self.framing.set_strategy(strategy);
self
}
#[must_use]
pub fn subject(mut self, topic: impl Into<String>, subject: impl Into<String>) -> Self {
self.framing.pin_subject(topic.into(), subject.into());
self
}
#[must_use]
pub fn message(mut self, topic: impl Into<String>, message: impl Into<String>) -> Self {
self.framing.pin_message(topic.into(), message.into());
self
}
}
impl PublishLayer for ProtobufFrame {
async fn on_publish<'a, N: PublishPipeline, P: Publisher>(
&'a self,
out: &'a mut Outgoing<'a>,
next: PublishNext<'a, N, P>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if let Framing::Framed(framed) = self.framing.frame(out.name(), out.payload()).await? {
let payload = out.payload_mut();
payload.clear();
payload.extend_from_slice(&framed);
}
next.run(out).await
}
}
#[derive(Clone, Debug)]
#[must_use]
pub struct KafkaFramedPublish {
publish: KafkaPublish,
framing: ProtobufFraming,
}
impl KafkaFramedPublish {
pub fn over(publish: KafkaPublish, registry: &SchemaRegistry) -> Self {
Self {
publish,
framing: ProtobufFraming::new(registry.clone()),
}
}
pub fn subject_strategy(mut self, strategy: SubjectStrategy) -> Self {
self.framing.set_strategy(strategy);
self
}
pub fn subject(mut self, topic: impl Into<String>, subject: impl Into<String>) -> Self {
self.framing.pin_subject(topic.into(), subject.into());
self
}
pub fn message(mut self, topic: impl Into<String>, message: impl Into<String>) -> Self {
self.framing.pin_message(topic.into(), message.into());
self
}
}
impl KafkaFramedPublish {
#[cfg(feature = "asyncapi")]
pub(crate) fn message_bindings(&self) -> Bindings {
crate::bindings::message(self.framing.strategy())
}
}
impl PublishPolicy<ConnectedKafkaBroker> for KafkaFramedPublish {
type Live = KafkaFramedPublisher<KafkaPublisher>;
async fn pair(self, connected: &ConnectedKafkaBroker) -> Result<Self::Live, PairError> {
Ok(KafkaFramedPublisher::new(
self.publish.pair(connected).await?,
self.framing,
))
}
#[cfg(feature = "asyncapi")]
fn channel_bindings(&self, channel: &str) -> Bindings {
crate::bindings::channel(channel)
}
#[cfg(feature = "asyncapi")]
fn message_bindings(&self, _channel: &str) -> Bindings {
Self::message_bindings(self)
}
}
#[derive(Clone, Debug)]
pub struct KafkaFramedPublisher<P = KafkaPublisher> {
inner: P,
framing: ProtobufFraming,
}
impl<P> KafkaFramedPublisher<P> {
pub(crate) const fn new(inner: P, framing: ProtobufFraming) -> Self {
Self { inner, framing }
}
}
impl<P: Publisher<Error = KafkaError> + Send + Sync> Publisher for KafkaFramedPublisher<P> {
type Payload = P::Payload;
type Error = KafkaError;
type Options = P::Options;
async fn publish(
&self,
msg: OutgoingFor<'_, Self::Payload>,
options: Option<&Self::Options>,
) -> Result<(), Self::Error> {
let framed = match self.framing.frame(msg.name(), msg.payload()).await? {
Framing::Framed(framed) => framed,
Framing::AlreadyFramed => return self.inner.publish(msg, options).await,
Framing::NoSubject => {
return Err(KafkaError::SchemaRegistry(
format!(
"topic {:?} publishes through a framing publisher, but the registry \
holds no subject {:?} to frame its records with. Register the schema, \
point the policy at the subject that carries it with `.subject(..)`, or \
publish through a plain KafkaPublish if this topic is not \
registry-backed.",
msg.name(),
self.framing.subject_for(msg.name()),
)
.into(),
));
}
Framing::OtherFormat(format) => {
return Err(KafkaError::InvalidOptions(format!(
"subject {:?}, which topic {:?} publishes under, holds a {format:?} schema; a \
framing publisher writes Protobuf. Use the codec for that format, or the \
transcoding SchemaFrame layer.",
self.framing.subject_for(msg.name()),
msg.name(),
)));
}
};
let (topic, _payload, headers) = msg.into_parts();
let framed = OutgoingMessage::new(topic, &framed).with_headers(headers);
self.inner.publish(framed, options).await
}
}
pub(crate) fn protobuf_to_json(
registry: &SchemaRegistry,
schema: &RegisteredSchema,
datum: &[u8],
) -> Result<Vec<u8>, KafkaError> {
let pool = registry.parsed_proto(schema)?;
let (indexes, message_bytes) = decode_indexes(datum).ok_or_else(|| {
KafkaError::InvalidOptions("malformed message-indexes in the Protobuf envelope".to_owned())
})?;
let descriptor = message_by_indexes(&pool, &indexes).ok_or_else(|| {
KafkaError::InvalidOptions(format!(
"message indexes {indexes:?} do not address a message in schema id {}",
schema.id(),
))
})?;
let dynamic =
DynamicMessage::decode(descriptor, message_bytes).map_err(KafkaError::schema_registry)?;
let options = prost_reflect::SerializeOptions::new()
.use_proto_field_name(true)
.skip_default_fields(false)
.stringify_64_bit_integers(false);
let mut json = serde_json::Serializer::new(Vec::new());
dynamic
.serialize_with_options(&mut json, &options)
.map_err(KafkaError::schema_registry)?;
Ok(json.into_inner())
}
pub(crate) fn json_to_protobuf(
registry: &SchemaRegistry,
schema: &RegisteredSchema,
message: Option<&str>,
payload: &[u8],
) -> Result<Vec<u8>, KafkaError> {
let pool = registry.parsed_proto(schema)?;
let descriptor = match message {
Some(name) => pool.get_message_by_name(name).ok_or_else(|| {
KafkaError::InvalidOptions(format!(
"message {name:?} is not in the subject's schema (use the fully qualified \
name, package included)",
))
})?,
None => registry_file(&pool)
.and_then(|file| file.messages().next())
.ok_or_else(|| {
KafkaError::InvalidOptions("the subject's schema declares no messages".to_owned())
})?,
};
let indexes = message_indexes(&descriptor).ok_or_else(|| {
KafkaError::InvalidOptions(format!(
"message {:?} is not declared by the registered schema file",
descriptor.full_name(),
))
})?;
let json: serde_json::Value =
serde_json::from_slice(payload).map_err(KafkaError::schema_registry)?;
let dynamic =
DynamicMessage::deserialize(descriptor, json).map_err(KafkaError::schema_registry)?;
let mut datum = encode_indexes(&indexes);
datum.extend_from_slice(&dynamic.encode_to_vec());
Ok(datum)
}
fn registry_file(pool: &DescriptorPool) -> Option<prost_reflect::FileDescriptor> {
pool.files().last()
}
fn message_indexes(descriptor: &MessageDescriptor) -> Option<Vec<i32>> {
let mut path = Vec::new();
let mut current = descriptor.clone();
while let Some(parent) = current.parent_message() {
let index = parent.child_messages().position(|child| child == current)?;
path.push(i32::try_from(index).ok()?);
current = parent;
}
let file = current.parent_file();
let index = file.messages().position(|message| message == current)?;
path.push(i32::try_from(index).ok()?);
path.reverse();
Some(path)
}
fn message_by_indexes(pool: &DescriptorPool, indexes: &[i32]) -> Option<MessageDescriptor> {
let file = registry_file(pool)?;
let mut iter = indexes.iter();
let first = usize::try_from(*iter.next()?).ok()?;
let mut message = file.messages().nth(first)?;
for index in iter {
let index = usize::try_from(*index).ok()?;
let child = message.child_messages().nth(index)?;
message = child;
}
Some(message)
}
fn encode_indexes(indexes: &[i32]) -> Vec<u8> {
if indexes == [0] {
return vec![0];
}
let mut out = Vec::with_capacity(1 + indexes.len());
write_zigzag(&mut out, i64::try_from(indexes.len()).unwrap_or(i64::MAX));
for index in indexes {
write_zigzag(&mut out, i64::from(*index));
}
out
}
fn skip_indexes(datum: &[u8]) -> Option<&[u8]> {
let (count, mut rest) = read_zigzag(datum)?;
if count == 0 {
return Some(rest);
}
for _ in 0..count {
let (_, tail) = read_zigzag(rest)?;
rest = tail;
}
Some(rest)
}
fn decode_indexes(datum: &[u8]) -> Option<(Vec<i32>, &[u8])> {
let (count, mut rest) = read_zigzag(datum)?;
if count == 0 {
return Some((vec![0], rest));
}
let count = usize::try_from(count).ok()?;
let mut indexes = Vec::with_capacity(count);
for _ in 0..count {
let (index, tail) = read_zigzag(rest)?;
indexes.push(i32::try_from(index).ok()?);
rest = tail;
}
Some((indexes, rest))
}
fn write_zigzag(out: &mut Vec<u8>, value: i64) {
#[allow(clippy::cast_sign_loss)] let mut encoded = ((value << 1) ^ (value >> 63)) as u64;
loop {
let byte = (encoded & 0x7f) as u8;
encoded >>= 7;
if encoded == 0 {
out.push(byte);
return;
}
out.push(byte | 0x80);
}
}
fn read_zigzag(bytes: &[u8]) -> Option<(i64, &[u8])> {
let mut encoded: u64 = 0;
let mut shift = 0u32;
for (position, byte) in bytes.iter().enumerate() {
encoded |= u64::from(byte & 0x7f) << shift;
if byte & 0x80 == 0 {
#[allow(clippy::cast_possible_wrap)] let value = ((encoded >> 1) as i64) ^ -((encoded & 1) as i64);
return Some((value, &bytes[position + 1..]));
}
shift += 7;
if shift >= 64 {
return None;
}
}
None
}
#[cfg(test)]
mod tests {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
use crate::schema_registry::SchemaType;
const ORDERS_PROTO: &str = r#"
syntax = "proto3";
package acme;
message Ignored {
string noise = 1;
}
message Order {
int64 id = 1;
string item = 2;
message Line {
string sku = 1;
int32 quantity = 2;
}
}
"#;
async fn registry_with_orders(id: u32) -> (MockServer, SchemaRegistry, RegisteredSchema) {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/subjects/orders-value/versions"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": id })))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/subjects/orders-value/versions/latest"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": id,
"version": 1,
"schema": ORDERS_PROTO,
"schemaType": "PROTOBUF",
})))
.mount(&server)
.await;
let registry = SchemaRegistry::new(server.uri());
registry
.register("orders-value", SchemaType::Protobuf, ORDERS_PROTO)
.await
.expect("register");
let schema = registry.cached_subject("orders-value").expect("cached");
(server, registry, (*schema).clone())
}
#[tokio::test]
async fn json_protobuf_json_roundtrips_with_index_paths() {
let (_server, registry, schema) = registry_with_orders(5).await;
let json = br#"{"id":42,"item":"anvil"}"#;
let datum = json_to_protobuf(®istry, &schema, Some("acme.Order"), json).expect("encode");
assert_ne!(datum[0], 0, "a real index path must be encoded");
let back = protobuf_to_json(®istry, &schema, &datum).expect("decode");
let value: serde_json::Value = serde_json::from_slice(&back).expect("json");
assert_eq!(value["id"], 42);
assert_eq!(value["item"], "anvil");
}
#[tokio::test]
async fn nested_messages_address_by_index_path() {
let (_server, registry, schema) = registry_with_orders(6).await;
let json = br#"{"sku":"SKU-1","quantity":3}"#;
let datum =
json_to_protobuf(®istry, &schema, Some("acme.Order.Line"), json).expect("encode");
let back = protobuf_to_json(®istry, &schema, &datum).expect("decode");
let value: serde_json::Value = serde_json::from_slice(&back).expect("json");
assert_eq!(value["sku"], "SKU-1");
assert_eq!(value["quantity"], 3);
}
#[tokio::test]
async fn unknown_messages_error_clearly() {
let (_server, registry, schema) = registry_with_orders(7).await;
let err = json_to_protobuf(®istry, &schema, Some("acme.Missing"), b"{}")
.expect_err("unknown message");
assert!(err.to_string().contains("fully qualified"));
}
#[test]
fn a_truncated_index_path_is_rejected() {
let payload = [0x00, 0x00, 0x00, 0x00, 0x01, 0x04];
let err = decode_confluent::<LaneOrder>(&payload).expect_err("truncated");
assert!(err.to_string().contains("truncated"));
}
#[test]
fn skipping_a_path_lands_where_decoding_it_does() {
for path in [vec![0], vec![1], vec![0, 2], vec![3, 1, 4]] {
let mut encoded = encode_indexes(&path);
encoded.extend_from_slice(b"message");
let (_, decoded_rest) = decode_indexes(&encoded).expect("decodes");
assert_eq!(skip_indexes(&encoded).expect("skips"), decoded_rest);
}
}
#[test]
fn index_paths_roundtrip() {
for path in [vec![0], vec![1], vec![0, 2], vec![3, 1, 4]] {
let encoded = encode_indexes(&path);
let (decoded, rest) = decode_indexes(&encoded).expect("decodes");
assert_eq!(decoded, path);
assert_eq!(rest, b"");
}
assert_eq!(
encode_indexes(&[0]),
vec![0],
"the compact single-zero form"
);
}
#[test]
fn zigzag_varints_roundtrip() {
for value in [0i64, 1, -1, 63, 64, -64, 300, i64::from(i32::MAX)] {
let mut out = Vec::new();
write_zigzag(&mut out, value);
let (decoded, rest) = read_zigzag(&out).expect("decodes");
assert_eq!(decoded, value);
assert_eq!(rest, b"");
}
}
#[derive(
Clone, PartialEq, prost::Message, ruststream::Deserialized, ruststream::Serialized,
)]
#[wire(encode = ::prost::Message::encode, decode = decode_confluent)]
struct LaneOrder {
#[prost(int64, tag = "1")]
id: i64,
#[prost(string, tag = "2")]
item: String,
}
#[test]
fn a_generated_type_reads_a_framed_delivery_with_no_registry() {
use ruststream::runtime::{Deserialized, Serialized};
let order = LaneOrder {
id: 42,
item: "anvil".to_owned(),
};
let mut buf = ruststream::BytesMut::new();
let written = order.wire_bytes(&mut buf).expect("encode");
let bare = written.of(&buf).to_vec();
assert_eq!(bare, order.encode_to_vec());
let mut framed = vec![0x00, 0x00, 0x00, 0x00, 0x09, 0x00];
framed.extend_from_slice(&bare);
assert_eq!(LaneOrder::from_payload(&framed).expect("decode"), order);
assert_eq!(LaneOrder::from_payload(&bare).expect("decode"), order);
}
#[test]
fn a_bare_message_never_looks_like_an_envelope() {
for id in [i64::MIN, -1, 0, 1, i64::MAX] {
let bare = LaneOrder {
id,
item: String::new(),
}
.encode_to_vec();
assert!(parse_envelope(&bare).is_none(), "id {id} looked framed");
}
}
fn framing(registry: SchemaRegistry, message: Option<&str>) -> ProtobufFraming {
let mut framing = ProtobufFraming::new(registry);
if let Some(message) = message {
framing.pin_message("orders".to_owned(), message.to_owned());
}
framing
}
#[tokio::test]
async fn the_prefix_addresses_the_pinned_message() {
let (_server, registry, schema) = registry_with_orders(11).await;
let framing = framing(registry.clone(), Some("acme.Order"));
let bare = LaneOrder {
id: 42,
item: "anvil".to_owned(),
}
.encode_to_vec();
let Framing::Framed(framed) = framing.frame("orders", &bare).await.expect("frame") else {
panic!("a Protobuf subject frames");
};
let (id, datum) = parse_envelope(&framed).expect("framed");
assert_eq!(id, 11);
let (indexes, message) = decode_indexes(datum).expect("decodes");
assert_eq!(indexes, vec![1]);
assert_eq!(message, bare.as_slice());
let json = protobuf_to_json(®istry, &schema, datum).expect("transcode");
let value: serde_json::Value = serde_json::from_slice(&json).expect("json");
assert_eq!(value["id"], 42);
assert_eq!(value["item"], "anvil");
}
#[tokio::test]
async fn an_unpinned_topic_takes_the_first_top_level_message() {
let (_server, registry, _) = registry_with_orders(12).await;
let framing = framing(registry, None);
let Framing::Framed(framed) = framing.frame("orders", &[0x08, 0x07]).await.expect("frame")
else {
panic!("a Protobuf subject frames");
};
assert_eq!(parse_envelope(&framed).expect("framed").1[0], 0);
}
#[tokio::test]
async fn an_already_framed_payload_is_left_alone() {
let (_server, registry, _) = registry_with_orders(14).await;
let framing = framing(registry, Some("acme.Order"));
let already = [0x00, 0x00, 0x00, 0x00, 0x09, 0x00, 0x08, 0x07];
assert!(matches!(
framing.frame("orders", &already).await.expect("frame"),
Framing::AlreadyFramed,
));
}
#[tokio::test]
async fn a_destination_the_registry_does_not_know_is_reported_not_guessed() {
let (server, registry, _) = registry_with_orders(15).await;
Mock::given(method("GET"))
.and(path("/subjects/unknown-value/versions/latest"))
.respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
"error_code": 40401,
"message": "Subject not found",
})))
.mount(&server)
.await;
let framing = framing(registry, None);
assert!(matches!(
framing
.frame("unknown", &[0x08, 0x07])
.await
.expect("frame"),
Framing::NoSubject,
));
}
#[tokio::test]
async fn a_message_the_schema_does_not_declare_is_named_in_the_error() {
let (_server, registry, _) = registry_with_orders(13).await;
let framing = framing(registry, Some("acme.Missing"));
let err = framing
.frame("orders", &[0x08, 0x07])
.await
.expect_err("unknown message");
let message = err.to_string();
assert!(message.contains("acme.Missing"), "{message}");
assert!(message.contains("orders"), "{message}");
}
}