ruststream_rdkafka/protobuf.rs
1//! Protobuf as a self-serializing type under the Confluent envelope.
2//!
3//! A `prost`-generated message owns its byte layout and rides the core's byte lanes itself: the
4//! handler takes the generated message and returns the generated message. The registry adds the
5//! envelope, a zero magic byte, the schema id and a message-index path ahead of the message.
6//!
7//! - [Protobuf][protobuf]: [`decode_confluent`], named in `#[wire(decode = ..)]`, steps over the
8//! envelope with no registry involved.
9//! - [framed][framed]: [`KafkaPublish::framed`](crate::KafkaPublish::framed) and
10//! [`KafkaFramedPublish`] put the envelope on every reply, resolving the destination subject's
11//! schema id.
12//! - [ProtobufFrame][frame]: [`ProtobufFrame`] frames byte-lane publishes app-wide.
13//! - [Protobuf transcode][transcode]:
14//! [`KafkaBroker::schema_registry`](crate::KafkaBroker::schema_registry) and
15//! [`SchemaFrame`](crate::SchemaFrame) convert framed Protobuf to and from plain JSON through
16//! descriptors compiled from the registry's `.proto` source.
17//!
18//! A Protobuf subject is registered from its `.proto` text with
19//! [`SchemaRegistry::register`](crate::SchemaRegistry::register). The registry paths read schemas
20//! whose imports are the well-known types.
21//!
22//! [protobuf]: https://powersemmi.github.io/ruststream-rdkafka/latest/components/protobuf/
23//! [framed]: https://powersemmi.github.io/ruststream-rdkafka/latest/components/protobuf/framed/
24//! [frame]: https://powersemmi.github.io/ruststream-rdkafka/latest/components/protobuf/protobuf-frame/
25//! [transcode]: https://powersemmi.github.io/ruststream-rdkafka/latest/components/protobuf/transcode/
26
27use std::collections::HashMap;
28use std::fmt;
29use std::sync::{Arc, Mutex};
30
31use prost::Message as _;
32use prost_reflect::{DescriptorPool, DynamicMessage, MessageDescriptor};
33#[cfg(feature = "asyncapi")]
34use ruststream::asyncapi::Bindings;
35use ruststream::runtime::{Outgoing, PublishLayer, PublishNext, PublishPipeline};
36use ruststream::{OutgoingFor, OutgoingMessage, PairError, PublishPolicy, Publisher};
37
38use crate::broker::ConnectedKafkaBroker;
39use crate::error::KafkaError;
40use crate::publisher::{KafkaPublish, KafkaPublisher};
41use crate::schema_registry::{
42 RegisteredSchema, SchemaRegistry, SchemaType, SubjectMap, SubjectStrategy, WIRE_MAGIC,
43 parse_envelope,
44};
45
46/// Reads a Confluent-framed delivery into a `prost`-generated message, straight off the wire.
47///
48/// This is the function a generated type names in `#[wire(decode = ..)]`, so its handlers take
49/// the message itself and never see the envelope.
50///
51/// Everything the envelope carries in front of the message is parseable without asking anyone:
52/// the magic byte, the four-byte id, and the message-index path. The id is not needed to read
53/// the message at all, because Protobuf's compatibility model is the wire format's own - fields
54/// are tag-addressed, a field the reader does not know is kept as an unknown field and one the
55/// writer never wrote takes its default - so a delivery decodes against the reader's own
56/// generated type. That is what lets this be a synchronous function on the decode lane, where
57/// Avro needs the writer schema its id names and therefore an `await`.
58///
59/// A payload with no envelope is read as a bare message, so a topic that some producers frame
60/// and others do not still decodes. What this drops is the *governance* half - which schema
61/// version a delivery claims - because a generated type reads the same fields whichever version
62/// wrote them.
63///
64/// # Errors
65///
66/// Returns [`KafkaError::WireFormat`] when the message-index path is truncated, or when the
67/// bytes after it are not a message of `T`.
68///
69/// # Examples
70///
71/// ```
72/// use ruststream_rdkafka::prelude::*;
73///
74/// /// What `prost-build` emits, plus the decode lane: deliveries arrive past their envelope.
75/// #[derive(Clone, PartialEq, prost::Message, Deserialized)]
76/// #[wire(decode = ruststream_rdkafka::protobuf::decode_confluent)]
77/// struct Order {
78/// #[prost(int64, tag = "1")]
79/// id: i64,
80/// }
81///
82/// #[subscriber("orders")]
83/// async fn place(order: &Order) -> HandlerOutcome {
84/// tracing::info!(order.id, "accepted");
85/// HandlerOutcome::ack()
86/// }
87///
88/// fn app() -> RustStream {
89/// RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
90/// KafkaBroker::new(["localhost:9092"]).default_group("orders-svc"),
91/// |b| {
92/// b.include(place);
93/// },
94/// )
95/// }
96/// # let _ = app;
97/// ```
98pub fn decode_confluent<T: prost::Message + Default>(payload: &[u8]) -> Result<T, KafkaError> {
99 match parse_envelope(payload) {
100 Some((schema_id, datum)) => decode_datum(schema_id, datum),
101 // An unframed payload is a bare message: no envelope means no index path either.
102 None => T::decode(payload).map_err(KafkaError::wire_format),
103 }
104}
105
106/// Step over the index path, read the message.
107fn decode_datum<T: prost::Message + Default>(
108 schema_id: u32,
109 datum: &[u8],
110) -> Result<T, KafkaError> {
111 let message = skip_indexes(datum).ok_or_else(|| {
112 KafkaError::malformed(format!(
113 "the message-index path of schema id {schema_id} is truncated, so the Protobuf \
114 message after it cannot be found",
115 ))
116 })?;
117 T::decode(message).map_err(KafkaError::wire_format)
118}
119
120/// One topic's message-index prefix, kept with the schema id it was computed against so a
121/// re-registered subject recomputes it instead of framing behind a stale path.
122#[derive(Clone)]
123struct IndexPrefix {
124 schema_id: u32,
125 bytes: Arc<[u8]>,
126}
127
128/// The prefixes resolved so far, by topic.
129type IndexPrefixes = Arc<Mutex<HashMap<String, IndexPrefix>>>;
130
131/// What framing one payload came to, so each caller can decide what its own surface makes of
132/// the three ways there was nothing to do.
133#[derive(Debug)]
134pub(crate) enum Framing {
135 /// The envelope and the message-index path, in front of the bytes that came in.
136 Framed(Vec<u8>),
137 /// The payload already carried an envelope, so it was left alone.
138 AlreadyFramed,
139 /// The registry holds no subject for the destination.
140 NoSubject,
141 /// The destination's subject holds another format's schema.
142 OtherFormat(SchemaType),
143}
144
145/// The shared framing engine: which subject a topic publishes under, which message of its
146/// schema, and the envelope in front of a `prost` message's own bytes.
147///
148/// Two public surfaces sit on this and differ only in what they make of a destination the
149/// registry does not describe: [`ProtobufFrame`], an app-wide layer that sees every topic and so
150/// passes those through, and [`KafkaFramedPublish`](crate::KafkaFramedPublish), a mount-site
151/// policy that was pointed at one destination and so refuses.
152#[derive(Clone)]
153pub(crate) struct ProtobufFraming {
154 subjects: SubjectMap,
155 /// Per-topic message names (fully qualified), for schemas declaring several messages.
156 messages: HashMap<String, String>,
157 prefixes: IndexPrefixes,
158}
159
160impl fmt::Debug for ProtobufFraming {
161 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162 f.debug_struct("ProtobufFraming")
163 .field("strategy", &self.subjects.strategy())
164 .field("subjects", self.subjects.pins())
165 .field("messages", &self.messages)
166 .finish_non_exhaustive()
167 }
168}
169
170impl ProtobufFraming {
171 pub(crate) fn new(registry: SchemaRegistry) -> Self {
172 Self {
173 subjects: SubjectMap::new(registry),
174 messages: HashMap::new(),
175 prefixes: Arc::new(Mutex::new(HashMap::new())),
176 }
177 }
178
179 pub(crate) fn set_strategy(&mut self, strategy: SubjectStrategy) {
180 self.subjects.set_strategy(strategy);
181 }
182
183 pub(crate) fn strategy(&self) -> SubjectStrategy {
184 self.subjects.strategy()
185 }
186
187 pub(crate) fn pin_subject(&mut self, topic: String, subject: String) {
188 self.subjects.pin(topic, subject);
189 }
190
191 pub(crate) fn pin_message(&mut self, topic: String, message: String) {
192 self.messages.insert(topic, message);
193 }
194
195 /// The subject `topic` publishes under, for a diagnostic that has to name it.
196 pub(crate) fn subject_for(&self, topic: &str) -> String {
197 self.subjects.subject_for(topic)
198 }
199
200 /// Frames `payload` for `topic`, or says why there was nothing to frame.
201 ///
202 /// # Errors
203 ///
204 /// Returns [`KafkaError::SchemaRegistry`] when the registry is unreachable, and
205 /// [`KafkaError::InvalidOptions`] when the subject's schema does not declare the message
206 /// the index path must address.
207 pub(crate) async fn frame(&self, topic: &str, payload: &[u8]) -> Result<Framing, KafkaError> {
208 // A bare `prost` message opens with a field tag, whose field number is at least 1, so
209 // its first byte is at least 0x08 and never the zero magic byte. An envelope here is
210 // therefore a payload something already framed, not a message that happens to look
211 // like one.
212 if parse_envelope(payload).is_some() {
213 return Ok(Framing::AlreadyFramed);
214 }
215 let Some(schema) = self.subjects.schema_for(topic).await? else {
216 return Ok(Framing::NoSubject);
217 };
218 if schema.schema_type() != SchemaType::Protobuf {
219 return Ok(Framing::OtherFormat(schema.schema_type()));
220 }
221 let prefix = self.prefix_for(topic, &schema)?;
222
223 let mut framed = Vec::with_capacity(1 + 4 + prefix.len() + payload.len());
224 framed.push(WIRE_MAGIC);
225 framed.extend_from_slice(&schema.id().to_be_bytes());
226 framed.extend_from_slice(&prefix);
227 framed.extend_from_slice(payload);
228 Ok(Framing::Framed(framed))
229 }
230
231 /// The message-index prefix `topic`'s payloads ride behind, resolved once per schema id.
232 fn prefix_for(&self, topic: &str, schema: &RegisteredSchema) -> Result<Arc<[u8]>, KafkaError> {
233 if let Some(prefix) = self
234 .prefixes
235 .lock()
236 .expect("message-index prefix mutex poisoned")
237 .get(topic)
238 && prefix.schema_id == schema.id()
239 {
240 return Ok(Arc::clone(&prefix.bytes));
241 }
242 let pool = self.subjects.registry().parsed_proto(schema)?;
243 let descriptor = match self.messages.get(topic) {
244 Some(name) => pool.get_message_by_name(name).ok_or_else(|| {
245 KafkaError::InvalidOptions(format!(
246 "message {name:?}, pinned for topic {topic:?}, is not in its subject's \
247 schema (use the fully qualified name, package included)",
248 ))
249 })?,
250 None => registry_file(&pool)
251 .and_then(|file| file.messages().next())
252 .ok_or_else(|| {
253 KafkaError::InvalidOptions(format!(
254 "the subject of topic {topic:?} holds a Protobuf schema declaring no \
255 messages, so there is no message-index path to frame with",
256 ))
257 })?,
258 };
259 let indexes = message_indexes(&descriptor).ok_or_else(|| {
260 KafkaError::InvalidOptions(format!(
261 "message {:?} is not declared by the registered schema file of topic {topic:?}",
262 descriptor.full_name(),
263 ))
264 })?;
265 let bytes: Arc<[u8]> = Arc::from(encode_indexes(&indexes));
266 self.prefixes
267 .lock()
268 .expect("message-index prefix mutex poisoned")
269 .insert(
270 topic.to_owned(),
271 IndexPrefix {
272 schema_id: schema.id(),
273 bytes: Arc::clone(&bytes),
274 },
275 );
276 Ok(bytes)
277 }
278}
279
280/// Publish middleware putting the Confluent envelope on a `prost` message's own bytes.
281///
282/// It is the encode-side half of the pair [`decode_confluent`] completes on the read side: with
283/// both in place a Protobuf handler returns the message and nothing else. Added app-wide with
284/// `RustStream::publish_layer`.
285///
286/// The envelope needs a schema id, and a value cannot fetch one: `Serialized::wire_bytes` is
287/// synchronous and has only `&self` to work with. The publish path can, because it knows the
288/// destination topic, which is exactly what names the subject - so the value writes bare
289/// Protobuf through the core's own `#[wire(encode = ::prost::Message::encode)]` and this layer
290/// puts the id and the message-index path in front of it. That division is why nothing here
291/// needs a process-wide registry singleton keyed by message type, which is the other way a
292/// value could have reached an id.
293///
294/// # What it touches, and what it leaves alone
295///
296/// A publish is framed only when its topic's subject is registered **and holds a Protobuf
297/// schema**. Everything else passes through untouched: a topic with no subject (mixed
298/// registry/plain topologies need no configuration), and a topic whose subject is Avro or JSON,
299/// whose payload some other path already framed. That is what lets one app carry an Avro codec,
300/// a JSON codec and Protobuf at once with this layer installed app-wide.
301///
302/// A payload that already carries an envelope also passes through, so a message that came in
303/// framed and goes out again is not framed twice. The check is exact rather than a guess: a bare
304/// `prost` message begins with a field tag, whose field number is at least 1, so its first byte
305/// is never the zero magic byte.
306///
307/// It is an alternative to [`SchemaFrame`](crate::SchemaFrame) rather than a companion. That
308/// layer's contract is "the payload is a JSON document, transcode it to the subject's flavor";
309/// this one's is "the payload is already the subject's datum, put the envelope on". Install one
310/// or the other.
311///
312/// # The layer does not see a `reply(..)` reply
313///
314/// A handler's outgoing message reaches this layer when it leaves through an
315/// [`Out`](ruststream::runtime::Out) slot, and **not** when it is returned as the reply of a
316/// `reply(..)` mount. That is the core's own division rather than a gap here: a byte-for-byte
317/// reply goes straight to its paired publisher, deliberately, so a value that owns its bytes
318/// leaves exactly as it wrote them.
319///
320/// So there are two shapes, and which one to reach for is a question of where the framing should
321/// be declared. A handler that replies takes
322/// [`KafkaPublish::framed`](crate::KafkaPublish::framed) at its mount site, which frames on the
323/// way through the reply's own publisher and keeps the body a plain
324/// `async fn confirm(order: &Order) -> Confirmation`. This layer is for setting framing once for
325/// a whole app, where publishes leave through slots and through publishers the mount sites never
326/// name.
327///
328/// The two compose rather than conflict: whichever runs first frames the payload, and the other
329/// sees an envelope already there and leaves it alone.
330///
331/// # Examples
332///
333/// ```
334/// use ruststream_rdkafka::prelude::*;
335/// use ruststream_rdkafka::{ProtobufFrame, SchemaRegistry};
336///
337/// #[derive(Clone, PartialEq, prost::Message, Deserialized)]
338/// #[wire(decode = ruststream_rdkafka::protobuf::decode_confluent)]
339/// struct Order {
340/// #[prost(int64, tag = "1")]
341/// id: i64,
342/// }
343///
344/// #[derive(Clone, PartialEq, prost::Message, Serialized, Outgoing)]
345/// #[wire(encode = ::prost::Message::encode)]
346/// #[outgoing(name = "audit")]
347/// struct Audit {
348/// #[prost(int64, tag = "1")]
349/// order_id: i64,
350/// }
351///
352/// #[derive(OutSlot)]
353/// #[publishes(Audit)]
354/// struct Journal;
355///
356/// /// The audit record leaves through a slot, so the app-wide layer frames it.
357/// #[subscriber("orders")]
358/// async fn place(order: &Order, Out(journal): Out<impl Publisher, Journal>) -> HandlerOutcome {
359/// if journal.message(&Audit { order_id: order.id }).publish().await.is_err() {
360/// return HandlerOutcome::retry();
361/// }
362/// HandlerOutcome::ack()
363/// }
364///
365/// fn app() -> impl App {
366/// let registry = SchemaRegistry::new("http://localhost:8081");
367/// RustStream::new(AppInfo::new("orders", "0.1.0"))
368/// .publish_layer(ProtobufFrame::new(registry))
369/// .with_broker(
370/// KafkaBroker::new(["localhost:9092"]).default_group("orders-svc"),
371/// |b| {
372/// b.include(place).out(Journal, Publish::default()).build();
373/// },
374/// )
375/// }
376/// # let _ = app;
377/// ```
378#[derive(Clone, Debug)]
379pub struct ProtobufFrame {
380 framing: ProtobufFraming,
381}
382
383impl ProtobufFrame {
384 /// Builds the framing middleware over `registry`. Subjects default to the Confluent
385 /// `TopicName` strategy (`{topic}-value`).
386 #[must_use]
387 pub fn new(registry: SchemaRegistry) -> Self {
388 Self {
389 framing: ProtobufFraming::new(registry),
390 }
391 }
392
393 /// How destination topics map onto subjects (default: [`SubjectStrategy::TopicName`]).
394 #[must_use]
395 pub fn subject_strategy(mut self, strategy: SubjectStrategy) -> Self {
396 self.framing.set_strategy(strategy);
397 self
398 }
399
400 /// Pins `topic`'s subject explicitly, overriding the strategy.
401 #[must_use]
402 pub fn subject(mut self, topic: impl Into<String>, subject: impl Into<String>) -> Self {
403 self.framing.pin_subject(topic.into(), subject.into());
404 self
405 }
406
407 /// Pins the message `topic`'s payloads are, by fully qualified name (package included).
408 ///
409 /// The message-index path says which message of the schema was written, so it has to be the
410 /// one the publishing type actually is. Without this pin the layer takes the schema's first
411 /// top-level message, which is the common case and the one Confluent optimises to a single
412 /// zero byte - so a single-message `.proto`, and a multi-message one whose published type
413 /// is declared first, need no pin. Anything else does: naming the wrong message puts a
414 /// mis-addressed path on the wire, which consumers report as a decode failure.
415 #[must_use]
416 pub fn message(mut self, topic: impl Into<String>, message: impl Into<String>) -> Self {
417 self.framing.pin_message(topic.into(), message.into());
418 self
419 }
420}
421
422impl PublishLayer for ProtobufFrame {
423 async fn on_publish<'a, N: PublishPipeline, P: Publisher>(
424 &'a self,
425 out: &'a mut Outgoing<'a>,
426 next: PublishNext<'a, N, P>,
427 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
428 // Every "nothing to do" is a pass-through here: this layer sees every topic the app
429 // publishes to, most of which are not Protobuf, so refusing one would be refusing the
430 // ordinary case. The mount-site policy, pointed at a single destination, refuses instead.
431 if let Framing::Framed(framed) = self.framing.frame(out.name(), out.payload()).await? {
432 let payload = out.payload_mut();
433 payload.clear();
434 payload.extend_from_slice(&framed);
435 }
436 next.run(out).await
437 }
438}
439
440/// The publish policy of a Protobuf producer that frames what it sends: pure declaration, no
441/// connection, no publish surface, like every other policy here.
442///
443/// Built with [`KafkaPublish::framed`](crate::KafkaPublish::framed), and named where a policy is
444/// named - which is what makes the short reply form work:
445///
446/// ```no_run
447/// # use ruststream::prelude::*;
448/// # use ruststream::runtime::{AppInfo, Reply, RustStream};
449/// # use ruststream_rdkafka::{KafkaBroker, KafkaPublish, SchemaRegistry};
450/// # #[derive(Clone, PartialEq, prost::Message, Deserialized)]
451/// # #[wire(decode = ruststream_rdkafka::protobuf::decode_confluent)]
452/// # struct Order { #[prost(int64, tag = "1")] id: i64 }
453/// #[derive(Clone, PartialEq, prost::Message, Serialized, Outgoing)]
454/// #[wire(encode = ::prost::Message::encode)]
455/// struct Confirmation {
456/// #[prost(int64, tag = "1")]
457/// id: i64,
458/// #[prost(bool, tag = "2")]
459/// accepted: bool,
460/// }
461///
462/// #[subscriber("orders", reply("confirmations"))]
463/// async fn confirm(order: &Order) -> Confirmation {
464/// Confirmation { id: order.id, accepted: true }
465/// }
466///
467/// let registry = SchemaRegistry::new("http://localhost:8081");
468/// let app = RustStream::new(AppInfo::new("orders", "0.1.0"))
469/// .with_broker(KafkaBroker::new(["localhost:9092"]), |b| {
470/// b.include(confirm).out_reply(KafkaPublish::framed(®istry));
471/// });
472/// # let _ = app;
473/// ```
474///
475/// The handler returns its reply and nothing else: no slot parameter, no `publish().await`, no
476/// error branch in the body. The reply type declares no destination of its own - its `Outgoing`
477/// derive names nothing, so the address comes from the `reply("confirmations")` clause - and
478/// beyond that it carries only `#[derive(Serialized)]` and the encode half of `#[wire(..)]`.
479///
480/// The same policy works wherever else one is named, an [`Out`](ruststream::runtime::Out) slot
481/// included, so a handler that publishes several messages frames them the same way.
482///
483/// # It refuses what the layer passes through
484///
485/// Naming this policy at a mount site **declares** that destination registry-backed, so a
486/// subject the registry does not hold, or one holding another format's schema, fails the publish
487/// and says which subject and which format. That is the opposite of
488/// [`ProtobufFrame`]'s reaction, deliberately and for the same reason the Avro codec's
489/// `MissingSubject::Refuse` is its default: the layer sees every topic an app publishes to and
490/// most of them are not Protobuf, so passing those through is the ordinary case, while this
491/// policy was pointed at one destination and silence there would put records on the topic that no
492/// registry-backed consumer can read.
493///
494/// A payload that already carries an envelope is still left alone, so a handler that framed its
495/// own message, or a `ProtobufFrame` earlier in the pipeline, does not frame it twice.
496///
497/// # When the subject is resolved
498///
499/// On the first publish to each destination, and cached from then on. It cannot be at startup:
500/// [`PublishPolicy::pair`] is where a policy could do I/O, and the destination topic is not known
501/// there - it comes from the mount's `reply(..)` clause, or from the call site of a slot
502/// publish, neither of which a policy is handed. So a missing subject surfaces as a failed
503/// publish rather than a failed startup, which the handler's failure policy then settles.
504#[derive(Clone, Debug)]
505#[must_use]
506pub struct KafkaFramedPublish {
507 publish: KafkaPublish,
508 framing: ProtobufFraming,
509}
510
511impl KafkaFramedPublish {
512 /// Frames what `publish` sends, resolving subjects through `registry`.
513 ///
514 /// [`KafkaPublish::framed`](crate::KafkaPublish::framed) is the short form of this over
515 /// [`KafkaPublish::default`]; take this one to carry the producer settings a configured
516 /// [`KafkaPublish`] holds.
517 ///
518 /// # Examples
519 ///
520 /// ```
521 /// use std::time::Duration;
522 ///
523 /// use ruststream_rdkafka::prelude::*;
524 /// use ruststream_rdkafka::{KafkaFramedPublish, SchemaRegistry};
525 /// # #[derive(Clone, PartialEq, prost::Message, Deserialized)]
526 /// # #[wire(decode = ruststream_rdkafka::protobuf::decode_confluent)]
527 /// # struct Order {
528 /// # #[prost(int64, tag = "1")]
529 /// # id: i64,
530 /// # }
531 /// # #[derive(Clone, PartialEq, prost::Message, Serialized, Outgoing)]
532 /// # #[wire(encode = ::prost::Message::encode)]
533 /// # #[outgoing(name = "confirmations")]
534 /// # struct Confirmation {
535 /// # #[prost(int64, tag = "1")]
536 /// # id: i64,
537 /// # }
538 /// # #[subscriber("orders", reply)]
539 /// # async fn confirm(order: &Order) -> Confirmation {
540 /// # Confirmation { id: order.id }
541 /// # }
542 ///
543 /// fn app() -> RustStream {
544 /// let registry = SchemaRegistry::new("http://localhost:8081");
545 /// let replies = KafkaFramedPublish::over(
546 /// Publish::default().queue_timeout(Duration::from_secs(5)),
547 /// ®istry,
548 /// );
549 /// RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
550 /// KafkaBroker::new(["localhost:9092"]).default_group("orders-svc"),
551 /// |b| {
552 /// b.include(confirm).out_reply(replies);
553 /// },
554 /// )
555 /// }
556 /// # let _ = app;
557 /// ```
558 pub fn over(publish: KafkaPublish, registry: &SchemaRegistry) -> Self {
559 Self {
560 publish,
561 framing: ProtobufFraming::new(registry.clone()),
562 }
563 }
564
565 /// How destination topics map onto subjects (default: [`SubjectStrategy::TopicName`]).
566 pub fn subject_strategy(mut self, strategy: SubjectStrategy) -> Self {
567 self.framing.set_strategy(strategy);
568 self
569 }
570
571 /// Pins `topic`'s subject explicitly, overriding the strategy.
572 pub fn subject(mut self, topic: impl Into<String>, subject: impl Into<String>) -> Self {
573 self.framing.pin_subject(topic.into(), subject.into());
574 self
575 }
576
577 /// Pins the message `topic`'s payloads are, by fully qualified name (package included).
578 ///
579 /// The default is the schema's first top-level message, which Confluent optimises to a
580 /// single zero byte; see [`ProtobufFrame::message`] for when that is not the one you want.
581 pub fn message(mut self, topic: impl Into<String>, message: impl Into<String>) -> Self {
582 self.framing.pin_message(topic.into(), message.into());
583 self
584 }
585}
586
587impl KafkaFramedPublish {
588 /// What the messages this policy publishes say about their schema: the Confluent envelope
589 /// puts the schema id in the payload, and the naming strategy says which subject it was
590 /// registered under.
591 #[cfg(feature = "asyncapi")]
592 pub(crate) fn message_bindings(&self) -> Bindings {
593 crate::bindings::message(self.framing.strategy())
594 }
595}
596
597impl PublishPolicy<ConnectedKafkaBroker> for KafkaFramedPublish {
598 type Live = KafkaFramedPublisher<KafkaPublisher>;
599
600 async fn pair(self, connected: &ConnectedKafkaBroker) -> Result<Self::Live, PairError> {
601 Ok(KafkaFramedPublisher::new(
602 self.publish.pair(connected).await?,
603 self.framing,
604 ))
605 }
606
607 /// The destination topic of this publish, which is what the channel stands for.
608 #[cfg(feature = "asyncapi")]
609 fn channel_bindings(&self, channel: &str) -> Bindings {
610 crate::bindings::channel(channel)
611 }
612
613 /// The framing says where the schema id lives, which the destination does not change.
614 #[cfg(feature = "asyncapi")]
615 fn message_bindings(&self, _channel: &str) -> Bindings {
616 Self::message_bindings(self)
617 }
618}
619
620/// The live half of [`KafkaFramedPublish`]: a publisher that puts the Confluent envelope on each
621/// message before it goes on the topic.
622///
623/// Generic over the publisher underneath; pairing
624/// [`KafkaPublish::framed`](crate::KafkaPublish::framed) puts it over the broker's
625/// [`KafkaPublisher`], in production and in a test's in-process mode alike.
626#[derive(Clone, Debug)]
627pub struct KafkaFramedPublisher<P = KafkaPublisher> {
628 inner: P,
629 framing: ProtobufFraming,
630}
631
632impl<P> KafkaFramedPublisher<P> {
633 pub(crate) const fn new(inner: P, framing: ProtobufFraming) -> Self {
634 Self { inner, framing }
635 }
636}
637
638impl<P: Publisher<Error = KafkaError> + Send + Sync> Publisher for KafkaFramedPublisher<P> {
639 // Framing writes a payload of its own and hands it to the publisher underneath, so the form
640 // is whatever that one declared.
641 type Payload = P::Payload;
642 type Error = KafkaError;
643 // Framing is a payload transform: the record's own settings are whatever the publisher
644 // underneath speaks, passed through untouched.
645 type Options = P::Options;
646
647 /// Frames `msg` by its destination topic's subject and publishes it.
648 ///
649 /// # Errors
650 ///
651 /// Returns [`KafkaError::SchemaRegistry`] when the registry is unreachable or holds no
652 /// subject for the destination, [`KafkaError::InvalidOptions`] when the subject holds
653 /// another format's schema or does not declare the message the index path must address, and
654 /// whatever [`KafkaPublisher`] reports for the send itself.
655 ///
656 /// # Cancel safety
657 ///
658 /// Not cancel safe, for the same reason [`KafkaPublisher::publish`] is not.
659 async fn publish(
660 &self,
661 msg: OutgoingFor<'_, Self::Payload>,
662 options: Option<&Self::Options>,
663 ) -> Result<(), Self::Error> {
664 let framed = match self.framing.frame(msg.name(), msg.payload()).await? {
665 Framing::Framed(framed) => framed,
666 Framing::AlreadyFramed => return self.inner.publish(msg, options).await,
667 Framing::NoSubject => {
668 return Err(KafkaError::SchemaRegistry(
669 format!(
670 "topic {:?} publishes through a framing publisher, but the registry \
671 holds no subject {:?} to frame its records with. Register the schema, \
672 point the policy at the subject that carries it with `.subject(..)`, or \
673 publish through a plain KafkaPublish if this topic is not \
674 registry-backed.",
675 msg.name(),
676 self.framing.subject_for(msg.name()),
677 )
678 .into(),
679 ));
680 }
681 Framing::OtherFormat(format) => {
682 return Err(KafkaError::InvalidOptions(format!(
683 "subject {:?}, which topic {:?} publishes under, holds a {format:?} schema; a \
684 framing publisher writes Protobuf. Use the codec for that format, or the \
685 transcoding SchemaFrame layer.",
686 self.framing.subject_for(msg.name()),
687 msg.name(),
688 )));
689 }
690 };
691 // The message is rebuilt around the framed payload, so the map the publish filled moves
692 // into the new one instead of being copied into it.
693 let (topic, _payload, headers) = msg.into_parts();
694 let framed = OutgoingMessage::new(topic, &framed).with_headers(headers);
695 self.inner.publish(framed, options).await
696 }
697}
698
699/// Decodes a framed Protobuf datum (indexes + message) against its registry schema and
700/// re-encodes it as JSON.
701pub(crate) fn protobuf_to_json(
702 registry: &SchemaRegistry,
703 schema: &RegisteredSchema,
704 datum: &[u8],
705) -> Result<Vec<u8>, KafkaError> {
706 let pool = registry.parsed_proto(schema)?;
707 let (indexes, message_bytes) = decode_indexes(datum).ok_or_else(|| {
708 KafkaError::InvalidOptions("malformed message-indexes in the Protobuf envelope".to_owned())
709 })?;
710 let descriptor = message_by_indexes(&pool, &indexes).ok_or_else(|| {
711 KafkaError::InvalidOptions(format!(
712 "message indexes {indexes:?} do not address a message in schema id {}",
713 schema.id(),
714 ))
715 })?;
716 let dynamic =
717 DynamicMessage::decode(descriptor, message_bytes).map_err(KafkaError::schema_registry)?;
718 let options = prost_reflect::SerializeOptions::new()
719 .use_proto_field_name(true)
720 .skip_default_fields(false)
721 .stringify_64_bit_integers(false);
722 let mut json = serde_json::Serializer::new(Vec::new());
723 dynamic
724 .serialize_with_options(&mut json, &options)
725 .map_err(KafkaError::schema_registry)?;
726 Ok(json.into_inner())
727}
728
729/// Serializes a JSON document as a framed Protobuf datum (indexes + message) against the
730/// subject's registry schema; `message` picks the message within it (`None` = the first
731/// top-level one).
732pub(crate) fn json_to_protobuf(
733 registry: &SchemaRegistry,
734 schema: &RegisteredSchema,
735 message: Option<&str>,
736 payload: &[u8],
737) -> Result<Vec<u8>, KafkaError> {
738 let pool = registry.parsed_proto(schema)?;
739 let descriptor = match message {
740 Some(name) => pool.get_message_by_name(name).ok_or_else(|| {
741 KafkaError::InvalidOptions(format!(
742 "message {name:?} is not in the subject's schema (use the fully qualified \
743 name, package included)",
744 ))
745 })?,
746 None => registry_file(&pool)
747 .and_then(|file| file.messages().next())
748 .ok_or_else(|| {
749 KafkaError::InvalidOptions("the subject's schema declares no messages".to_owned())
750 })?,
751 };
752 let indexes = message_indexes(&descriptor).ok_or_else(|| {
753 KafkaError::InvalidOptions(format!(
754 "message {:?} is not declared by the registered schema file",
755 descriptor.full_name(),
756 ))
757 })?;
758
759 let json: serde_json::Value =
760 serde_json::from_slice(payload).map_err(KafkaError::schema_registry)?;
761 let dynamic =
762 DynamicMessage::deserialize(descriptor, json).map_err(KafkaError::schema_registry)?;
763 let mut datum = encode_indexes(&indexes);
764 datum.extend_from_slice(&dynamic.encode_to_vec());
765 Ok(datum)
766}
767
768/// The registry's own file (imports precede it in the compiled set).
769fn registry_file(pool: &DescriptorPool) -> Option<prost_reflect::FileDescriptor> {
770 pool.files().last()
771}
772
773/// The message's index path within its file: `[i]` for the i-th top-level message, deeper
774/// entries for nested declarations.
775fn message_indexes(descriptor: &MessageDescriptor) -> Option<Vec<i32>> {
776 let mut path = Vec::new();
777 let mut current = descriptor.clone();
778 while let Some(parent) = current.parent_message() {
779 let index = parent.child_messages().position(|child| child == current)?;
780 path.push(i32::try_from(index).ok()?);
781 current = parent;
782 }
783 let file = current.parent_file();
784 let index = file.messages().position(|message| message == current)?;
785 path.push(i32::try_from(index).ok()?);
786 path.reverse();
787 Some(path)
788}
789
790/// Resolves the message a Confluent index path addresses, against the registry's own file.
791fn message_by_indexes(pool: &DescriptorPool, indexes: &[i32]) -> Option<MessageDescriptor> {
792 let file = registry_file(pool)?;
793 let mut iter = indexes.iter();
794 let first = usize::try_from(*iter.next()?).ok()?;
795 let mut message = file.messages().nth(first)?;
796 for index in iter {
797 let index = usize::try_from(*index).ok()?;
798 let child = message.child_messages().nth(index)?;
799 message = child;
800 }
801 Some(message)
802}
803
804/// Encodes a message-index path the Confluent way: zigzag varints, with `[0]` compacted to a
805/// single zero byte.
806fn encode_indexes(indexes: &[i32]) -> Vec<u8> {
807 if indexes == [0] {
808 return vec![0];
809 }
810 let mut out = Vec::with_capacity(1 + indexes.len());
811 write_zigzag(&mut out, i64::try_from(indexes.len()).unwrap_or(i64::MAX));
812 for index in indexes {
813 write_zigzag(&mut out, i64::from(*index));
814 }
815 out
816}
817
818/// Steps over a Confluent message-index path, returning the message bytes after it.
819///
820/// The path itself is only needed to address a message inside a schema; a generated type has
821/// already made that choice, so the reading lane skips the path rather than materializing it.
822fn skip_indexes(datum: &[u8]) -> Option<&[u8]> {
823 let (count, mut rest) = read_zigzag(datum)?;
824 if count == 0 {
825 return Some(rest);
826 }
827 for _ in 0..count {
828 let (_, tail) = read_zigzag(rest)?;
829 rest = tail;
830 }
831 Some(rest)
832}
833
834/// Decodes a Confluent message-index path, returning it and the message bytes after it.
835fn decode_indexes(datum: &[u8]) -> Option<(Vec<i32>, &[u8])> {
836 let (count, mut rest) = read_zigzag(datum)?;
837 if count == 0 {
838 return Some((vec![0], rest));
839 }
840 let count = usize::try_from(count).ok()?;
841 let mut indexes = Vec::with_capacity(count);
842 for _ in 0..count {
843 let (index, tail) = read_zigzag(rest)?;
844 indexes.push(i32::try_from(index).ok()?);
845 rest = tail;
846 }
847 Some((indexes, rest))
848}
849
850fn write_zigzag(out: &mut Vec<u8>, value: i64) {
851 #[allow(clippy::cast_sign_loss)] // zigzag mapping is the point
852 let mut encoded = ((value << 1) ^ (value >> 63)) as u64;
853 loop {
854 let byte = (encoded & 0x7f) as u8;
855 encoded >>= 7;
856 if encoded == 0 {
857 out.push(byte);
858 return;
859 }
860 out.push(byte | 0x80);
861 }
862}
863
864fn read_zigzag(bytes: &[u8]) -> Option<(i64, &[u8])> {
865 let mut encoded: u64 = 0;
866 let mut shift = 0u32;
867 for (position, byte) in bytes.iter().enumerate() {
868 encoded |= u64::from(byte & 0x7f) << shift;
869 if byte & 0x80 == 0 {
870 #[allow(clippy::cast_possible_wrap)] // zigzag mapping is the point
871 let value = ((encoded >> 1) as i64) ^ -((encoded & 1) as i64);
872 return Some((value, &bytes[position + 1..]));
873 }
874 shift += 7;
875 if shift >= 64 {
876 return None;
877 }
878 }
879 None
880}
881
882#[cfg(test)]
883mod tests {
884 use wiremock::matchers::{method, path};
885 use wiremock::{Mock, MockServer, ResponseTemplate};
886
887 use super::*;
888 use crate::schema_registry::SchemaType;
889
890 const ORDERS_PROTO: &str = r#"
891syntax = "proto3";
892package acme;
893
894message Ignored {
895 string noise = 1;
896}
897
898message Order {
899 int64 id = 1;
900 string item = 2;
901
902 message Line {
903 string sku = 1;
904 int32 quantity = 2;
905 }
906}
907"#;
908
909 async fn registry_with_orders(id: u32) -> (MockServer, SchemaRegistry, RegisteredSchema) {
910 let server = MockServer::start().await;
911 Mock::given(method("POST"))
912 .and(path("/subjects/orders-value/versions"))
913 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": id })))
914 .mount(&server)
915 .await;
916 Mock::given(method("GET"))
917 .and(path("/subjects/orders-value/versions/latest"))
918 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
919 "id": id,
920 "version": 1,
921 "schema": ORDERS_PROTO,
922 "schemaType": "PROTOBUF",
923 })))
924 .mount(&server)
925 .await;
926 let registry = SchemaRegistry::new(server.uri());
927 registry
928 .register("orders-value", SchemaType::Protobuf, ORDERS_PROTO)
929 .await
930 .expect("register");
931 let schema = registry.cached_subject("orders-value").expect("cached");
932 (server, registry, (*schema).clone())
933 }
934
935 #[tokio::test]
936 async fn json_protobuf_json_roundtrips_with_index_paths() {
937 let (_server, registry, schema) = registry_with_orders(5).await;
938 let json = br#"{"id":42,"item":"anvil"}"#;
939
940 // Order is the second top-level message: a real index path, not the compact zero.
941 let datum = json_to_protobuf(®istry, &schema, Some("acme.Order"), json).expect("encode");
942 assert_ne!(datum[0], 0, "a real index path must be encoded");
943
944 let back = protobuf_to_json(®istry, &schema, &datum).expect("decode");
945 let value: serde_json::Value = serde_json::from_slice(&back).expect("json");
946 assert_eq!(value["id"], 42);
947 assert_eq!(value["item"], "anvil");
948 }
949
950 #[tokio::test]
951 async fn nested_messages_address_by_index_path() {
952 let (_server, registry, schema) = registry_with_orders(6).await;
953 let json = br#"{"sku":"SKU-1","quantity":3}"#;
954
955 let datum =
956 json_to_protobuf(®istry, &schema, Some("acme.Order.Line"), json).expect("encode");
957 let back = protobuf_to_json(®istry, &schema, &datum).expect("decode");
958 let value: serde_json::Value = serde_json::from_slice(&back).expect("json");
959 assert_eq!(value["sku"], "SKU-1");
960 assert_eq!(value["quantity"], 3);
961 }
962
963 #[tokio::test]
964 async fn unknown_messages_error_clearly() {
965 let (_server, registry, schema) = registry_with_orders(7).await;
966 let err = json_to_protobuf(®istry, &schema, Some("acme.Missing"), b"{}")
967 .expect_err("unknown message");
968 assert!(err.to_string().contains("fully qualified"));
969 }
970
971 #[test]
972 fn a_truncated_index_path_is_rejected() {
973 // A path claiming two entries but carrying none.
974 let payload = [0x00, 0x00, 0x00, 0x00, 0x01, 0x04];
975
976 let err = decode_confluent::<LaneOrder>(&payload).expect_err("truncated");
977 assert!(err.to_string().contains("truncated"));
978 }
979
980 #[test]
981 fn skipping_a_path_lands_where_decoding_it_does() {
982 for path in [vec![0], vec![1], vec![0, 2], vec![3, 1, 4]] {
983 let mut encoded = encode_indexes(&path);
984 encoded.extend_from_slice(b"message");
985 let (_, decoded_rest) = decode_indexes(&encoded).expect("decodes");
986
987 assert_eq!(skip_indexes(&encoded).expect("skips"), decoded_rest);
988 }
989 }
990
991 #[test]
992 fn index_paths_roundtrip() {
993 for path in [vec![0], vec![1], vec![0, 2], vec![3, 1, 4]] {
994 let encoded = encode_indexes(&path);
995 let (decoded, rest) = decode_indexes(&encoded).expect("decodes");
996 assert_eq!(decoded, path);
997 assert_eq!(rest, b"");
998 }
999 assert_eq!(
1000 encode_indexes(&[0]),
1001 vec![0],
1002 "the compact single-zero form"
1003 );
1004 }
1005
1006 #[test]
1007 fn zigzag_varints_roundtrip() {
1008 for value in [0i64, 1, -1, 63, 64, -64, 300, i64::from(i32::MAX)] {
1009 let mut out = Vec::new();
1010 write_zigzag(&mut out, value);
1011 let (decoded, rest) = read_zigzag(&out).expect("decodes");
1012 assert_eq!(decoded, value);
1013 assert_eq!(rest, b"");
1014 }
1015 }
1016
1017 /// The shape a service declares: `prost` writes the bytes, this crate reads the envelope.
1018 #[derive(
1019 Clone, PartialEq, prost::Message, ruststream::Deserialized, ruststream::Serialized,
1020 )]
1021 #[wire(encode = ::prost::Message::encode, decode = decode_confluent)]
1022 struct LaneOrder {
1023 #[prost(int64, tag = "1")]
1024 id: i64,
1025 #[prost(string, tag = "2")]
1026 item: String,
1027 }
1028
1029 #[test]
1030 fn a_generated_type_reads_a_framed_delivery_with_no_registry() {
1031 use ruststream::runtime::{Deserialized, Serialized};
1032
1033 let order = LaneOrder {
1034 id: 42,
1035 item: "anvil".to_owned(),
1036 };
1037 // The encode half is bare prost: no envelope, no index path, no id.
1038 let mut buf = ruststream::BytesMut::new();
1039 let written = order.wire_bytes(&mut buf).expect("encode");
1040 let bare = written.of(&buf).to_vec();
1041 assert_eq!(bare, order.encode_to_vec());
1042
1043 // The framing a publish layer would add: id 9, the compact `[0]` path.
1044 let mut framed = vec![0x00, 0x00, 0x00, 0x00, 0x09, 0x00];
1045 framed.extend_from_slice(&bare);
1046 assert_eq!(LaneOrder::from_payload(&framed).expect("decode"), order);
1047
1048 // And the same bytes with no envelope at all, for a topic only some producers frame.
1049 assert_eq!(LaneOrder::from_payload(&bare).expect("decode"), order);
1050 }
1051
1052 #[test]
1053 fn a_bare_message_never_looks_like_an_envelope() {
1054 // The discriminator the publish layer relies on: a field number is at least 1, so the
1055 // first byte of a non-empty message is at least 0x08 and never the zero magic byte.
1056 for id in [i64::MIN, -1, 0, 1, i64::MAX] {
1057 let bare = LaneOrder {
1058 id,
1059 item: String::new(),
1060 }
1061 .encode_to_vec();
1062 assert!(parse_envelope(&bare).is_none(), "id {id} looked framed");
1063 }
1064 }
1065
1066 /// Builds the shared framing engine both public surfaces sit on.
1067 fn framing(registry: SchemaRegistry, message: Option<&str>) -> ProtobufFraming {
1068 let mut framing = ProtobufFraming::new(registry);
1069 if let Some(message) = message {
1070 framing.pin_message("orders".to_owned(), message.to_owned());
1071 }
1072 framing
1073 }
1074
1075 #[tokio::test]
1076 async fn the_prefix_addresses_the_pinned_message() {
1077 let (_server, registry, schema) = registry_with_orders(11).await;
1078 let framing = framing(registry.clone(), Some("acme.Order"));
1079
1080 let bare = LaneOrder {
1081 id: 42,
1082 item: "anvil".to_owned(),
1083 }
1084 .encode_to_vec();
1085 let Framing::Framed(framed) = framing.frame("orders", &bare).await.expect("frame") else {
1086 panic!("a Protobuf subject frames");
1087 };
1088 let (id, datum) = parse_envelope(&framed).expect("framed");
1089 assert_eq!(id, 11);
1090 // `acme.Order` is the second top-level message, so the path is real, not the compact
1091 // zero, and the message bytes follow it unchanged.
1092 let (indexes, message) = decode_indexes(datum).expect("decodes");
1093 assert_eq!(indexes, vec![1]);
1094 assert_eq!(message, bare.as_slice());
1095
1096 // The transcode reads the same bytes, so the path really addresses `acme.Order` rather
1097 // than only claiming to.
1098 let json = protobuf_to_json(®istry, &schema, datum).expect("transcode");
1099 let value: serde_json::Value = serde_json::from_slice(&json).expect("json");
1100 assert_eq!(value["id"], 42);
1101 assert_eq!(value["item"], "anvil");
1102 }
1103
1104 #[tokio::test]
1105 async fn an_unpinned_topic_takes_the_first_top_level_message() {
1106 let (_server, registry, _) = registry_with_orders(12).await;
1107 let framing = framing(registry, None);
1108
1109 let Framing::Framed(framed) = framing.frame("orders", &[0x08, 0x07]).await.expect("frame")
1110 else {
1111 panic!("a Protobuf subject frames");
1112 };
1113 // `acme.Ignored` is declared first, and `[0]` is Confluent's single-zero form.
1114 assert_eq!(parse_envelope(&framed).expect("framed").1[0], 0);
1115 }
1116
1117 #[tokio::test]
1118 async fn an_already_framed_payload_is_left_alone() {
1119 let (_server, registry, _) = registry_with_orders(14).await;
1120 let framing = framing(registry, Some("acme.Order"));
1121
1122 let already = [0x00, 0x00, 0x00, 0x00, 0x09, 0x00, 0x08, 0x07];
1123 assert!(matches!(
1124 framing.frame("orders", &already).await.expect("frame"),
1125 Framing::AlreadyFramed,
1126 ));
1127 }
1128
1129 #[tokio::test]
1130 async fn a_destination_the_registry_does_not_know_is_reported_not_guessed() {
1131 let (server, registry, _) = registry_with_orders(15).await;
1132 Mock::given(method("GET"))
1133 .and(path("/subjects/unknown-value/versions/latest"))
1134 .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
1135 "error_code": 40401,
1136 "message": "Subject not found",
1137 })))
1138 .mount(&server)
1139 .await;
1140 let framing = framing(registry, None);
1141
1142 assert!(matches!(
1143 framing
1144 .frame("unknown", &[0x08, 0x07])
1145 .await
1146 .expect("frame"),
1147 Framing::NoSubject,
1148 ));
1149 }
1150
1151 #[tokio::test]
1152 async fn a_message_the_schema_does_not_declare_is_named_in_the_error() {
1153 let (_server, registry, _) = registry_with_orders(13).await;
1154 let framing = framing(registry, Some("acme.Missing"));
1155
1156 let err = framing
1157 .frame("orders", &[0x08, 0x07])
1158 .await
1159 .expect_err("unknown message");
1160 let message = err.to_string();
1161 assert!(message.contains("acme.Missing"), "{message}");
1162 assert!(message.contains("orders"), "{message}");
1163 }
1164}