Skip to main content

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(&registry));
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    ///         &registry,
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(&registry, &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(&registry, &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(&registry, &schema, Some("acme.Order.Line"), json).expect("encode");
957        let back = protobuf_to_json(&registry, &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(&registry, &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(&registry, &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}