Skip to main content

ruststream_rdkafka/avro/
mod.rs

1//! Avro behind the codec.
2//!
3//! [`AvroCodec`] holds the schema where a serializer belongs, so handlers stay ordinary functions
4//! over ordinary serde structs and nothing about the wire reaches a signature.
5//!
6//! - [Avro][avro]: [`AvroCodec::registry`] reads each delivery with its writer schema and frames
7//!   each value with the id of the subject [`AvroCodec::register`] names; [`AvroSchema`] derives
8//!   a type's schema.
9//! - [Local][local]: [`AvroCodec::local`] pins one schema and writes a bare datum, with no
10//!   registry and no I/O; [`encode`] and [`decode`] are the same conversion as free functions.
11//! - [resolve_onto][resolve-onto]: [`AvroCodec::resolve_onto`] reads every delivery onto a reader
12//!   schema.
13//! - [Avro transcode][transcode]:
14//!   [`KafkaBroker::schema_registry`](crate::KafkaBroker::schema_registry) and
15//!   [`SchemaFrame`](crate::SchemaFrame) convert Avro to and from plain JSON at the broker's
16//!   edges, for handlers on the default codec.
17//!
18//! [avro]: https://powersemmi.github.io/ruststream-rdkafka/latest/components/avro/
19//! [local]: https://powersemmi.github.io/ruststream-rdkafka/latest/components/avro/local/
20//! [resolve-onto]: https://powersemmi.github.io/ruststream-rdkafka/latest/components/avro/resolve-onto/
21//! [transcode]: https://powersemmi.github.io/ruststream-rdkafka/latest/components/avro/transcode/
22
23mod codec;
24
25pub use apache_avro::AvroSchema;
26pub use codec::AvroCodec;
27
28use std::any::{Any, TypeId};
29use std::collections::HashMap;
30use std::sync::{OnceLock, RwLock};
31
32use apache_avro::Schema;
33use apache_avro::reader::datum::{GenericDatumReader, SpecificDatumReader};
34use apache_avro::types::Value as AvroValue;
35use apache_avro::writer::datum::GenericDatumWriter;
36use bytes::BufMut;
37use ruststream::BytesMut;
38use serde::Serialize;
39use serde::de::DeserializeOwned;
40
41use crate::error::KafkaError;
42use crate::schema_registry::{RegisteredSchema, SchemaRegistry};
43
44/// One message type's Avro artefacts, built once per type.
45///
46/// `AvroSchema::get_schema` is documented as expensive, and both a datum writer and a specific
47/// reader resolve the schema's names when they are built - so building either per message would
48/// put a schema-driven format's startup cost on the delivery path. The map memoizes a pure
49/// function of the type (its own schema); no configuration, no registry and nothing about a
50/// running app reaches it, so two apps in one process share it without interfering.
51struct Prepared<T: AvroSchema> {
52    writer: GenericDatumWriter<'static>,
53    reader: SpecificDatumReader<T>,
54}
55
56/// The prepared artefacts of every message type this process has encoded or decoded.
57static PREPARED: OnceLock<RwLock<HashMap<TypeId, &'static (dyn Any + Send + Sync)>>> =
58    OnceLock::new();
59
60fn prepared<T>() -> Result<&'static Prepared<T>, KafkaError>
61where
62    T: AvroSchema + Send + Sync + 'static,
63{
64    let cache = PREPARED.get_or_init(|| RwLock::new(HashMap::new()));
65    let key = TypeId::of::<T>();
66    if let Some(entry) = cache
67        .read()
68        .expect("prepared schema cache mutex poisoned")
69        .get(&key)
70    {
71        return Ok(downcast(*entry));
72    }
73
74    // Leaked rather than reference-counted: one entry per message type, so the set is bounded by
75    // the program's own types, and a `&'static Schema` is what a datum writer borrows for life.
76    let schema: &'static Schema = Box::leak(Box::new(T::get_schema()));
77    let writer = GenericDatumWriter::builder(schema)
78        .build()
79        .map_err(KafkaError::wire_format)?;
80    let reader = SpecificDatumReader::<T>::builder()
81        .build()
82        .map_err(KafkaError::wire_format)?;
83    let entry: &'static (dyn Any + Send + Sync) = Box::leak(Box::new(Prepared { writer, reader }));
84
85    // A racing thread may have inserted first; either entry is the same schema, so the one
86    // already in the map wins and this one is simply never looked up again.
87    let stored = *cache
88        .write()
89        .expect("prepared schema cache mutex poisoned")
90        .entry(key)
91        .or_insert(entry);
92    Ok(downcast(stored))
93}
94
95/// The map is keyed by the very `TypeId` the entry was built under, so the downcast is a type
96/// identity the insert established rather than a guess.
97fn downcast<T: AvroSchema + 'static>(
98    entry: &'static (dyn Any + Send + Sync),
99) -> &'static Prepared<T> {
100    entry
101        .downcast_ref()
102        .expect("the prepared entry of a type id is that type's")
103}
104
105/// Writes `value` as an Avro datum under the schema its own type declares.
106///
107/// The schema drives the encoding, so the value's Rust types reach the wire as the numeric and
108/// string types the schema names, with no JSON document in between. No registry is involved and
109/// no envelope is written: on a registry-backed topic the datum travels inside one, which
110/// [`AvroCodec::registry`] adds. A service publishes through a codec; this is for a tool that
111/// works on datum bytes outside an app's dispatch.
112///
113/// The buffer is written into rather than returned, so a caller that already holds one (a
114/// publish path, a batch) pays no intermediate allocation.
115///
116/// # Errors
117///
118/// Returns [`KafkaError::WireFormat`] when the type's schema cannot be built, or when the value
119/// does not fit the schema it declares.
120///
121/// # Examples
122///
123/// An export tool writing one bare datum per file, reusing one buffer for all of them:
124///
125/// ```
126/// use std::error::Error;
127/// use std::fs;
128/// use std::path::Path;
129///
130/// use apache_avro::AvroSchema;
131/// use ruststream::BytesMut;
132/// use ruststream_rdkafka::avro;
133/// use serde::Serialize;
134///
135/// #[derive(Serialize, AvroSchema)]
136/// struct Order {
137///     id: i64,
138/// }
139///
140/// fn export(orders: &[Order], dir: &Path) -> Result<(), Box<dyn Error>> {
141///     let mut buf = BytesMut::new();
142///     for order in orders {
143///         buf.clear();
144///         avro::encode(order, &mut buf)?;
145///         fs::write(dir.join(format!("{}.avro", order.id)), &buf)?;
146///     }
147///     Ok(())
148/// }
149/// # let _ = export;
150/// ```
151pub fn encode<T>(value: &T, buf: &mut BytesMut) -> Result<(), KafkaError>
152where
153    T: AvroSchema + Serialize + Send + Sync + 'static,
154{
155    let prepared = prepared::<T>()?;
156    prepared
157        .writer
158        .write_ser(&mut buf.writer(), value)
159        .map_err(KafkaError::wire_format)?;
160    Ok(())
161}
162
163/// Reads an Avro datum written with the reading type's own schema.
164///
165/// Writer and reader schema are the same one here, which is what a topic with no registry means:
166/// there is no second schema to resolve against. A Confluent-framed delivery goes through
167/// [`AvroCodec::registry`] instead, which resolves the writer schema the envelope names - reading
168/// a framed payload with this function would decode the envelope's own bytes as if they were the
169/// datum.
170///
171/// # Errors
172///
173/// Returns [`KafkaError::WireFormat`] when the type's schema cannot be built, or when the bytes
174/// are not a datum of that schema.
175///
176/// # Examples
177///
178/// An inspection tool printing an exported datum:
179///
180/// ```
181/// use std::error::Error;
182/// use std::fs;
183/// use std::path::Path;
184///
185/// use apache_avro::AvroSchema;
186/// use ruststream_rdkafka::avro;
187/// use serde::Deserialize;
188///
189/// #[derive(Deserialize, AvroSchema)]
190/// struct Order {
191///     id: i64,
192/// }
193///
194/// fn inspect(path: &Path) -> Result<(), Box<dyn Error>> {
195///     let datum = fs::read(path)?;
196///     let order: Order = avro::decode(&datum)?;
197///     tracing::info!(order.id, path = %path.display(), "decoded");
198///     Ok(())
199/// }
200/// # let _ = inspect;
201/// ```
202pub fn decode<T>(payload: &[u8]) -> Result<T, KafkaError>
203where
204    T: AvroSchema + DeserializeOwned + Send + Sync + 'static,
205{
206    let prepared = prepared::<T>()?;
207    let mut cursor = payload;
208    prepared
209        .reader
210        .read(&mut cursor)
211        .map_err(KafkaError::wire_format)
212}
213
214/// The JSON definition a schema is registered under.
215///
216/// Deliberately not `Schema::canonical_form`: the Parsing Canonical Form keeps only what two
217/// schemas must agree on to be *the same* schema, and drops field defaults, aliases, docs and
218/// logical types. Those are exactly what a reader resolves an older writer's datum with, so a
219/// subject registered in canonical form can never carry an evolution.
220pub(crate) fn schema_json(schema: &Schema) -> Result<String, KafkaError> {
221    serde_json::to_string(schema).map_err(KafkaError::wire_format)
222}
223
224/// Decodes an Avro datum against its registry schema and re-encodes it as JSON.
225pub(crate) fn avro_to_json(
226    registry: &SchemaRegistry,
227    schema: &RegisteredSchema,
228    datum: &[u8],
229) -> Result<Vec<u8>, KafkaError> {
230    let writer = registry.parsed_avro(schema)?;
231    let mut cursor = datum;
232    // No reader schema: the registry hands back the schema the datum was written with, and the
233    // handler sees plain JSON, so there is nothing to resolve the value onto.
234    let value = GenericDatumReader::builder(&writer)
235        .build()
236        .map_err(KafkaError::schema_registry)?
237        .read_value(&mut cursor)
238        .map_err(KafkaError::schema_registry)?;
239    let json: serde_json::Value =
240        apache_avro::from_value(&value).map_err(KafkaError::schema_registry)?;
241    serde_json::to_vec(&json).map_err(KafkaError::schema_registry)
242}
243
244/// Serializes a JSON document as an Avro datum against the subject's registry schema.
245pub(crate) fn json_to_avro(
246    registry: &SchemaRegistry,
247    schema: &RegisteredSchema,
248    payload: &[u8],
249) -> Result<Vec<u8>, KafkaError> {
250    let parsed = registry.parsed_avro(schema)?;
251    let json: serde_json::Value =
252        serde_json::from_slice(payload).map_err(KafkaError::schema_registry)?;
253    // The direct JSON conversion, not the serde one: serde sees every non-negative JSON integer
254    // as a `u64`, which apache-avro encodes as its `org.apache.avro.rust.u64` logical type (a
255    // Fixed of 8 bytes) and which then resolves against no numeric Avro schema. This conversion
256    // picks `int` or `long` by magnitude, which is what a registry schema declares. The lanes
257    // above have no such hazard: they never see a JSON document.
258    let value = AvroValue::try_from(json)
259        .map_err(KafkaError::schema_registry)?
260        .resolve(&parsed)
261        .map_err(KafkaError::schema_registry)?;
262    GenericDatumWriter::builder(&parsed)
263        .build()
264        .map_err(KafkaError::schema_registry)?
265        .write_value_to_vec(value)
266        .map_err(KafkaError::schema_registry)
267}
268
269#[cfg(test)]
270mod tests {
271    use serde::{Deserialize, Serialize};
272    use wiremock::matchers::{method, path};
273    use wiremock::{Mock, MockServer, ResponseTemplate};
274
275    use super::*;
276
277    #[derive(Debug, PartialEq, Serialize, Deserialize, AvroSchema)]
278    struct Order {
279        id: i64,
280        item: String,
281    }
282
283    async fn registry_with_order(id: u32) -> (MockServer, SchemaRegistry, RegisteredSchema) {
284        let server = MockServer::start().await;
285        Mock::given(method("POST"))
286            .and(path("/subjects/orders-value/versions"))
287            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": id })))
288            .mount(&server)
289            .await;
290        let registry = SchemaRegistry::new(server.uri());
291        registry
292            .register_avro::<Order>("orders-value")
293            .await
294            .expect("register");
295        let schema = registry.cached_subject("orders-value").expect("cached");
296        (server, registry, (*schema).clone())
297    }
298
299    #[test]
300    fn a_value_roundtrips_through_its_own_schema() {
301        let order = Order {
302            id: 42,
303            item: "anvil".to_owned(),
304        };
305        let mut buf = BytesMut::new();
306        encode(&order, &mut buf).expect("encode");
307
308        assert_ne!(
309            &buf[..],
310            br#"{"id":42,"item":"anvil"}"#,
311            "the wire form is Avro"
312        );
313        assert_eq!(decode::<Order>(&buf).expect("decode"), order);
314    }
315
316    /// The bug the JSON transcode carries and the codec cannot: through serde a JSON document's
317    /// every non-negative integer is a `u64`, which apache-avro writes as its own logical type.
318    /// A value that never becomes a JSON document has real Rust types all the way down.
319    #[test]
320    fn positive_integers_encode_as_the_declared_numeric_type() {
321        let mut buf = BytesMut::new();
322        encode(
323            &Order {
324                id: i64::from(u32::MAX),
325                item: String::new(),
326            },
327            &mut buf,
328        )
329        .expect("encode");
330
331        assert_eq!(
332            decode::<Order>(&buf).expect("decode").id,
333            i64::from(u32::MAX),
334        );
335    }
336
337    #[tokio::test]
338    async fn json_avro_json_roundtrips() {
339        let (_server, registry, schema) = registry_with_order(7).await;
340        let json = br#"{"id":42,"item":"anvil"}"#;
341
342        let datum = json_to_avro(&registry, &schema, json).expect("encode");
343        assert_ne!(datum.as_slice(), json, "the wire form is Avro, not JSON");
344
345        let back = avro_to_json(&registry, &schema, &datum).expect("decode");
346        let order: Order = serde_json::from_slice(&back).expect("deserialize");
347        assert_eq!(
348            order,
349            Order {
350                id: 42,
351                item: "anvil".to_owned(),
352            },
353        );
354    }
355
356    #[tokio::test]
357    async fn schema_mismatches_error_clearly() {
358        let (_server, registry, schema) = registry_with_order(7).await;
359        let err = json_to_avro(&registry, &schema, br#"{"id":"not-a-number"}"#)
360            .expect_err("a document violating the schema must fail");
361        assert!(matches!(err, KafkaError::SchemaRegistry(_)));
362    }
363}