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(®istry, &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(®istry, &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(®istry, &schema, br#"{"id":"not-a-number"}"#)
360 .expect_err("a document violating the schema must fail");
361 assert!(matches!(err, KafkaError::SchemaRegistry(_)));
362 }
363}