Skip to main content

ruststream_rdkafka/avro/
codec.rs

1//! The Avro codec: a schema held by the codec, and serde types riding it unchanged.
2
3use std::collections::HashMap;
4use std::sync::{Arc, Mutex};
5
6use apache_avro::reader::datum::GenericDatumReader;
7use apache_avro::writer::datum::GenericDatumWriter;
8use apache_avro::{AvroSchema, Schema};
9use bytes::BufMut;
10use ruststream::BytesMut;
11use ruststream::codec::{Codec, CodecError};
12use serde::Serialize;
13use serde::de::DeserializeOwned;
14
15use crate::error::KafkaError;
16use crate::schema_registry::{
17    MissingSubject, Registration, SchemaPrefetch, SchemaRegistry, SchemaType, WIRE_MAGIC,
18    parse_envelope, serde_name,
19};
20
21/// One schema prepared for both directions, built once and borrowed for the process's life.
22///
23/// `apache-avro` resolves a schema's names when a datum writer or reader is built, and both
24/// borrow the schema they were built from. Leaking the schema is what lets the built pair be
25/// kept: the set is bounded by the schemas a service actually meets, and building either per
26/// message would put a schema-driven format's setup cost on every delivery.
27struct Prepared {
28    /// The schema the wire carries: what a value is resolved against on the way out, and what a
29    /// datum is read with on the way in.
30    schema: &'static Schema,
31    writer: GenericDatumWriter<'static>,
32    reader: GenericDatumReader<'static>,
33}
34
35impl Prepared {
36    fn build(writer_schema: Schema, reader_schema: Option<Schema>) -> Result<Self, KafkaError> {
37        let schema: &'static Schema = Box::leak(Box::new(writer_schema));
38        let reader_schema: Option<&'static Schema> =
39            reader_schema.map(|reader| &*Box::leak(Box::new(reader)));
40        Ok(Self {
41            schema,
42            writer: GenericDatumWriter::builder(schema)
43                .build()
44                .map_err(KafkaError::wire_format)?,
45            reader: GenericDatumReader::builder(schema)
46                .maybe_reader_schema(reader_schema)
47                .build()
48                .map_err(KafkaError::wire_format)?,
49        })
50    }
51}
52
53/// Where the codec's schema comes from.
54///
55/// An enum rather than a registry plus an optional local schema: the two sources answer
56/// different questions (one schema for every message, or one per delivery named by the wire),
57/// they put a different thing on the wire, and no combination of them is meaningful.
58#[derive(Clone)]
59enum SchemaSource {
60    /// One schema, known at construction. A bare datum on the wire, and no registry anywhere.
61    Local(&'static Prepared),
62    /// The subjects this codec publishes under, and the envelope's id on the way back.
63    Registry {
64        registry: SchemaRegistry,
65        /// Subject by message type, keyed by the name serde gives the type.
66        ///
67        /// **Publish only.** Decoding needs nothing from it: the writer schema comes from the
68        /// id in the envelope, so one codec decodes every type a subscription carries without
69        /// being told about any of them. That asymmetry is what lets a router full of handlers
70        /// share one codec on the consume side and register per type only on the publish side.
71        subjects: Arc<HashMap<&'static str, Registered>>,
72        /// The schemas met so far, keyed by registry id. Keyed per codec rather than per
73        /// process, because an id means nothing outside the registry that issued it - and shared
74        /// by clones, because the mount machinery clones a codec once per registration and those
75        /// are the same codec by every measure that matters here.
76        prepared: Arc<Mutex<HashMap<u32, &'static Prepared>>>,
77        on_missing: MissingSubject,
78    },
79}
80
81/// What one `register::<T>(subject)` left on the codec.
82struct Registered {
83    subject: String,
84    /// `T`'s own schema, prepared. Used only when the policy says to publish unframed: a framed
85    /// datum is always written with the schema its id names, never with this one.
86    local: &'static Prepared,
87}
88
89impl Clone for Registered {
90    fn clone(&self) -> Self {
91        Self {
92            subject: self.subject.clone(),
93            local: self.local,
94        }
95    }
96}
97
98/// The record name an Avro schema carries, which is what registration can read from a type with
99/// no value of it - and, for every type whose Avro name comes from serde, the same string the
100/// publish-side probe reads off a value.
101fn avro_record_name(schema: &Schema) -> Option<&'static str> {
102    match schema {
103        Schema::Record(record) => Some(String::leak(record.name.name().to_string())),
104        _ => None,
105    }
106}
107
108/// An Avro [`Codec`]: the schema lives in the codec, and the messages riding it are ordinary
109/// serde types.
110///
111/// Avro is a schema-driven format with a serde front end, so the codec position is where it
112/// belongs: `apache-avro` converts a value through its own dynamic representation in both
113/// directions, guided by the schema the codec holds, and asks nothing of the message type beyond
114/// `Serialize` / `Deserialize`. A handler on this codec is an ordinary handler and its models are
115/// ordinary structs - no derive of ours, no wire type in the signature.
116///
117/// # The two schema sources
118///
119/// [`local`](Self::local) pins one schema at construction. The wire carries a bare Avro datum,
120/// nothing reaches a registry, and the whole path is I/O-free - which is what a topic with a
121/// fixed schema, and every unit test, wants.
122///
123/// [`registry`](Self::registry) speaks the Confluent wire format: the encode side frames with the
124/// id its subject holds, and the decode side reads each delivery with the writer schema that
125/// delivery's envelope names, so a producer still on an older version stays readable. It needs a
126/// [`SchemaPrefetch`] attached to the broker, because those lookups are `async` and a
127/// [`Codec`] is not; see that type for why the split falls where it does.
128///
129/// # Schema evolution
130///
131/// Reading a datum with its writer schema recovers what the writer wrote, and no more. A field
132/// the writer never had is filled from a *reader* schema's default, which is Avro's own schema
133/// resolution, so a consumer that has moved ahead of its producers names the schema it expects
134/// with [`resolve_onto`](Self::resolve_onto). Without it a model with a field the writer lacks
135/// fails to deserialize, which is the honest outcome: the value is genuinely not on the wire.
136///
137/// # Examples
138///
139/// A topic with one fixed schema and no registry, consumed through an ordinary handler:
140///
141/// ```
142/// use apache_avro::AvroSchema;
143/// use ruststream_rdkafka::KafkaError;
144/// use ruststream_rdkafka::avro::AvroCodec;
145/// use ruststream_rdkafka::prelude::*;
146/// use serde::Deserialize;
147///
148/// #[derive(Deserialize, AvroSchema)]
149/// struct Order {
150///     id: i64,
151///     item: String,
152/// }
153///
154/// #[subscriber("orders")]
155/// async fn place(order: &Order) -> HandlerOutcome {
156///     tracing::info!(order.id, item = %order.item, "placed");
157///     HandlerOutcome::ack()
158/// }
159///
160/// fn app() -> Result<RustStream, KafkaError> {
161///     let codec = AvroCodec::local(Order::get_schema())?;
162///     let broker = KafkaBroker::new(["localhost:9092"]).default_group("orders-svc");
163///     Ok(RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker_codec(broker, codec, |b| {
164///         b.include(place);
165///     }))
166/// }
167/// # let _ = app;
168/// ```
169#[derive(Clone)]
170pub struct AvroCodec {
171    source: SchemaSource,
172    /// The schema the reading side expects, when it is not the writer's. Applies to decoding
173    /// only: encoding always writes the schema the wire is supposed to carry.
174    reader_schema: Option<Arc<Schema>>,
175    /// Kept so `register` can record on it. Dropped from a local codec, which registers nothing.
176    prefetch: Option<SchemaPrefetch>,
177}
178
179impl std::fmt::Debug for AvroCodec {
180    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181        let mut out = f.debug_struct("AvroCodec");
182        match &self.source {
183            SchemaSource::Local(_) => out.field("source", &"local"),
184            SchemaSource::Registry { subjects, .. } => out.field(
185                "subjects",
186                &subjects.values().map(|r| &r.subject).collect::<Vec<_>>(),
187            ),
188        };
189        out.finish_non_exhaustive()
190    }
191}
192
193impl AvroCodec {
194    /// A codec over one schema, known here and used for every message.
195    ///
196    /// Nothing on this path reaches a registry and nothing frames an envelope: the wire carries
197    /// the datum alone. Pass the schema the wire is supposed to carry - `T::get_schema()` when
198    /// the model defines it, a parsed `.avsc` when the schema is the source of truth.
199    ///
200    /// # Errors
201    ///
202    /// Returns [`KafkaError::WireFormat`] when the schema's own names cannot be resolved (a
203    /// reference to a record the schema does not define).
204    pub fn local(schema: Schema) -> Result<Self, KafkaError> {
205        Ok(Self {
206            source: SchemaSource::Local(Box::leak(Box::new(Prepared::build(schema, None)?))),
207            reader_schema: None,
208            prefetch: None,
209        })
210    }
211
212    /// A codec on the Confluent wire format, publishing what
213    /// [`register`](Self::register) records.
214    ///
215    /// Construction is synchronous and does no I/O; the subjects are resolved when the broker
216    /// connects, and each delivery's writer schema on its consume path, both by the `prefetch`
217    /// this is built from.
218    ///
219    /// # Examples
220    ///
221    /// Each delivery decodes with the writer schema its envelope names, so a datum an older
222    /// producer wrote stays readable; each reply is framed with its subject's id:
223    ///
224    /// ```
225    /// # #[cfg(feature = "json")]
226    /// # mod demo {
227    /// use apache_avro::AvroSchema;
228    /// use ruststream_rdkafka::avro::AvroCodec;
229    /// use ruststream_rdkafka::prelude::*;
230    /// use ruststream_rdkafka::{SchemaPrefetch, SchemaRegistry};
231    /// use serde::{Deserialize, Serialize};
232    ///
233    /// #[derive(Serialize, Deserialize, AvroSchema)]
234    /// struct Order {
235    ///     id: i64,
236    /// }
237    ///
238    /// #[derive(Serialize, Deserialize, AvroSchema, Outgoing)]
239    /// struct Confirmation {
240    ///     id: i64,
241    /// }
242    ///
243    /// #[subscriber("orders", reply("confirmations"))]
244    /// async fn confirm(order: &Order) -> Confirmation {
245    ///     Confirmation { id: order.id }
246    /// }
247    ///
248    /// fn app() -> RustStream {
249    ///     let prefetch = SchemaPrefetch::new(SchemaRegistry::new("http://localhost:8081"));
250    ///     let codec = AvroCodec::registry(&prefetch).register::<Confirmation>("confirmations-value");
251    ///     let broker = KafkaBroker::new(["localhost:9092"])
252    ///         .default_group("orders-svc")
253    ///         .schema_prefetch(prefetch);
254    ///     // The scope's codec decodes every delivery; the reply names it again, at its own
255    ///     // position.
256    ///     let replies = codec.clone();
257    ///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker_codec(broker, codec, |b| {
258    ///         b.include(confirm).out_reply(Publish::default()).codec(replies);
259    ///     })
260    /// }
261    /// # }
262    /// # fn main() {}
263    /// ```
264    #[must_use]
265    pub fn registry(prefetch: &SchemaPrefetch) -> Self {
266        Self {
267            source: SchemaSource::Registry {
268                registry: prefetch.registry().clone(),
269                subjects: Arc::new(HashMap::new()),
270                prepared: Arc::new(Mutex::new(HashMap::new())),
271                on_missing: prefetch.missing_subject(),
272            },
273            reader_schema: None,
274            prefetch: Some(prefetch.clone()),
275        }
276    }
277
278    /// Records that values of `T` publish under `subject`.
279    ///
280    /// One codec serves as many message types as a router mounts through it, so this is a list
281    /// rather than a second codec each time. The subject is looked up per publish by the name
282    /// serde gives the value's type, which is the only identity
283    /// [`Codec::encode`] can recover - `TypeId` needs `T: 'static`, and `encode` bounds `T` by
284    /// `Serialize` alone.
285    ///
286    /// `T::get_schema()` is captured too, and used for exactly one thing: putting the subject
287    /// back under [`MissingSubject::AutoRegister`]. What a datum is *written* with is always
288    /// the schema the framed id names, so a drifted local model cannot silently produce records
289    /// the id contradicts.
290    ///
291    /// # Panics
292    ///
293    /// Panics when two registered types share a serde name, which would make the subject of a
294    /// publish ambiguous. That is a startup failure by design: the alternative is picking one of
295    /// them per message and writing records under the wrong subject. Rename one with
296    /// `#[serde(rename = "..")]`. Also panics when the prefetch's internal mutex is poisoned, or
297    /// when `T`'s own schema cannot be serialized.
298    ///
299    /// # Examples
300    ///
301    /// Registration is the publish side: decoding needs none of it, since the writer schema
302    /// comes off the envelope's id. The registry is named once, building the prefetch every codec
303    /// is minted from:
304    ///
305    /// ```
306    /// # #[cfg(feature = "json")]
307    /// # mod demo {
308    /// use apache_avro::AvroSchema;
309    /// use ruststream_rdkafka::avro::AvroCodec;
310    /// use ruststream_rdkafka::prelude::*;
311    /// use ruststream_rdkafka::schema_registry::{SchemaPrefetch, SchemaRegistry};
312    /// use serde::{Deserialize, Serialize};
313    ///
314    /// #[derive(Serialize, Deserialize, AvroSchema)]
315    /// pub struct Order {
316    ///     pub id: i64,
317    /// }
318    ///
319    /// #[derive(Serialize, Deserialize, AvroSchema, Outgoing)]
320    /// #[outgoing(name = "confirmations")]
321    /// pub struct Confirmation {
322    ///     pub id: i64,
323    /// }
324    ///
325    /// #[subscriber("orders", reply)]
326    /// async fn confirm(order: &Order) -> Confirmation {
327    ///     Confirmation { id: order.id }
328    /// }
329    ///
330    /// pub fn app() -> RustStream {
331    ///     let prefetch = SchemaPrefetch::new(SchemaRegistry::new("http://localhost:8081"));
332    ///     let codec = AvroCodec::registry(&prefetch).register::<Confirmation>("confirmations-value");
333    ///     let broker = KafkaBroker::new(["localhost:9092"])
334    ///         .default_group("orders-svc")
335    ///         .schema_prefetch(prefetch);
336    ///     // The scope's codec decodes every delivery; the reply names it again, at its own
337    ///     // position.
338    ///     let replies = codec.clone();
339    ///     RustStream::new(AppInfo::new("orders", "0.1.0"))
340    ///         .with_broker_codec(broker, codec, |b| {
341    ///             b.include(confirm).out_reply(Publish::default()).codec(replies);
342    ///         })
343    /// }
344    /// # }
345    /// # fn main() {}
346    /// ```
347    #[must_use]
348    pub fn register<T: Serialize + AvroSchema>(mut self, subject: impl Into<String>) -> Self {
349        let subject = subject.into();
350        let schema = T::get_schema();
351        let name = avro_record_name(&schema).unwrap_or_else(|| {
352            panic!(
353                "the Avro schema of a registered type is not a named record, so nothing \
354                 identifies its values on the publish path; register a record type under \
355                 {subject:?}"
356            )
357        });
358        let definition = crate::avro::schema_json(&schema).unwrap_or_else(|err| {
359            panic!(
360                "the schema of the type registered under {subject:?} \
361                                          cannot be serialized: {err}"
362            )
363        });
364
365        let SchemaSource::Registry { subjects, .. } = &mut self.source else {
366            panic!("register is for a registry codec; a local one publishes a bare datum");
367        };
368        let table = Arc::get_mut(subjects).expect("not yet shared with a mount");
369        let local = Box::leak(Box::new(Prepared::build(schema, None).unwrap_or_else(
370            |err| panic!("the schema registered under {subject:?}: {err}"),
371        )));
372        if let Some(existing) = table.insert(
373            name,
374            Registered {
375                subject: subject.clone(),
376                local,
377            },
378        ) && existing.subject != subject
379        {
380            panic!(
381                "two types registered on this codec are both called {name:?} to serde, so a \
382                 publish could not tell {:?} from {subject:?}; give one of them a distinct \
383                 `#[serde(rename = \"..\")]`",
384                existing.subject,
385            );
386        }
387        if let Some(prefetch) = &self.prefetch {
388            prefetch.record(Registration {
389                subject,
390                schema_type: SchemaType::Avro,
391                definition,
392            });
393        }
394        self
395    }
396
397    /// Reads every delivery onto `schema` instead of the schema it was written with, so Avro's
398    /// resolution fills the fields this consumer expects and the writer never wrote.
399    ///
400    /// This is the reader schema of Avro's resolution rules, and it is the decode side only: a
401    /// publish writes what the wire is supposed to carry, which is the writer schema either way.
402    ///
403    /// # Errors
404    ///
405    /// Returns [`KafkaError::WireFormat`] when the schema cannot be prepared against the local
406    /// writer schema. A registry codec's writer schemas arrive later, so a pairing that cannot
407    /// resolve is reported by the decode that meets it.
408    pub fn resolve_onto(mut self, schema: Schema) -> Result<Self, KafkaError> {
409        let schema = Arc::new(schema);
410        if let SchemaSource::Local(prepared) = &self.source {
411            // Rebuilt against the local writer schema so a reader that cannot resolve against it
412            // is reported here, at wiring time, rather than on the first delivery.
413            self.source = SchemaSource::Local(Box::leak(Box::new(Prepared::build(
414                prepared.schema.clone(),
415                Some((*schema).clone()),
416            )?)));
417        }
418        self.reader_schema = Some(schema);
419        Ok(self)
420    }
421
422    /// The prepared pair for a registry schema id, built once per codec.
423    fn prepared_for(&self, id: u32) -> Result<&'static Prepared, CodecError> {
424        let SchemaSource::Registry {
425            registry, prepared, ..
426        } = &self.source
427        else {
428            unreachable!("a local codec never looks a schema id up");
429        };
430        if let Some(found) = prepared
431            .lock()
432            .expect("prepared schema cache mutex poisoned")
433            .get(&id)
434        {
435            return Ok(found);
436        }
437        // Only ever the cache: the prefetch resolved this id on the delivery path (or the
438        // subject at connect), and a synchronous codec cannot await a miss without blocking a
439        // runtime worker - so a miss is reported, and the subscription's decode failure policy
440        // settles the delivery.
441        let schema = registry.cached_schema(id).ok_or_else(|| {
442            decode_error(format!(
443                "no schema is cached for id {id}: the delivery's writer schema was not resolved \
444                 before it reached the codec. Attach a SchemaPrefetch to the broker \
445                 (KafkaBroker::schema_prefetch), and check the registry is reachable - a \
446                 synchronous codec cannot fetch it here"
447            ))
448        })?;
449        let parsed = registry.parsed_avro(&schema).map_err(decode_source)?;
450        let built: &'static Prepared = Box::leak(Box::new(
451            Prepared::build((*parsed).clone(), self.reader_schema.as_deref().cloned())
452                .map_err(decode_source)?,
453        ));
454        Ok(prepared
455            .lock()
456            .expect("prepared schema cache mutex poisoned")
457            .entry(id)
458            .or_insert(built))
459    }
460}
461
462impl Codec for AvroCodec {
463    fn encode<T: Serialize>(&self, value: &T) -> Result<BytesMut, CodecError> {
464        let mut buf = BytesMut::new();
465        let prepared = match &self.source {
466            SchemaSource::Local(prepared) => *prepared,
467            SchemaSource::Registry {
468                registry,
469                subjects,
470                on_missing,
471                ..
472            } => {
473                // The one identity `encode` can recover from `T: Serialize`; see `serde_name`.
474                let name = serde_name(value).ok_or_else(|| {
475                    encode_error(
476                        "this codec frames by the name serde gives a value's type, and this \
477                         value has none - a bare number, a sequence or a map. Publish a named \
478                         struct, or use a codec that needs no subject"
479                            .to_owned(),
480                    )
481                })?;
482                let registered = subjects.get(name).ok_or_else(|| {
483                    encode_error(format!(
484                        "no subject is registered for messages named {name:?}, so this codec has \
485                         no id to frame them with. Add `.register::<{name}>(\"..\")` to it; the \
486                         types it does carry are {:?}. A type that sets an Avro record name \
487                         differing from its serde name registers under the Avro one and arrives \
488                         here under the serde one, which also looks like this",
489                        subjects.keys().collect::<Vec<_>>(),
490                    ))
491                })?;
492                match registry.cached_subject(&registered.subject) {
493                    Some(schema) => {
494                        buf.put_u8(WIRE_MAGIC);
495                        buf.put_u32(schema.id());
496                        self.prepared_for(schema.id())
497                            .map_err(|err| encode_error(err.to_string()))?
498                    }
499                    // Startup already applied the policy to a subject the registry did not hold;
500                    // reaching here means it went missing afterwards, or was never resolved.
501                    None if *on_missing == MissingSubject::PublishUnframed => registered.local,
502                    None => {
503                        return Err(encode_error(format!(
504                            "no schema is cached for subject {:?}, which messages named \
505                             {name:?} publish under, so there is no id to frame them with. \
506                             Attach the SchemaPrefetch this codec was built from to the broker \
507                             (KafkaBroker::schema_prefetch) so connect resolves it; if it was \
508                             resolved and has since gone, the subject was deleted from the \
509                             registry and MissingSubject says what to do about that",
510                            registered.subject,
511                        )));
512                    }
513                }
514            }
515        };
516        // Through the format's dynamic value: serde produces it, the schema resolves it, and the
517        // writer emits the bytes. Nothing here needs anything of `T` but `Serialize`, which is
518        // what lets one codec instance carry a schema for message types it has never seen.
519        let value = apache_avro::to_value(value)
520            .map_err(encode_source)?
521            .resolve(prepared.schema)
522            .map_err(|err| {
523                encode_error(format!(
524                    "the value does not fit the schema this codec writes: {err}"
525                ))
526            })?;
527        prepared
528            .writer
529            .write_value(&mut (&mut buf).writer(), value)
530            .map_err(encode_source)?;
531        Ok(buf)
532    }
533
534    fn decode<T: DeserializeOwned>(&self, bytes: &[u8]) -> Result<T, CodecError> {
535        let (prepared, mut datum) = match &self.source {
536            SchemaSource::Local(prepared) => (*prepared, bytes),
537            SchemaSource::Registry { .. } => {
538                let (id, datum) = parse_envelope(bytes).ok_or_else(|| {
539                    decode_error(format!(
540                        "the delivery does not carry the Confluent wire format (a zero magic \
541                         byte and a 4-byte schema id), so no writer schema can be named for it; \
542                         its first bytes are {:02x?}",
543                        &bytes[..bytes.len().min(8)],
544                    ))
545                })?;
546                (self.prepared_for(id)?, datum)
547            }
548        };
549        let value = prepared
550            .reader
551            .read_value(&mut datum)
552            .map_err(decode_source)?;
553        apache_avro::from_value(&value).map_err(decode_source)
554    }
555}
556
557fn encode_error(message: String) -> CodecError {
558    CodecError::Encode(Box::new(KafkaError::malformed(message)))
559}
560
561fn encode_source(err: impl std::error::Error + Send + Sync + 'static) -> CodecError {
562    CodecError::Encode(Box::new(err))
563}
564
565fn decode_error(message: String) -> CodecError {
566    CodecError::Decode(Box::new(KafkaError::malformed(message)))
567}
568
569fn decode_source(err: impl std::error::Error + Send + Sync + 'static) -> CodecError {
570    CodecError::Decode(Box::new(err))
571}
572
573#[cfg(test)]
574mod tests {
575    use apache_avro::AvroSchema;
576    use serde::Deserialize;
577    use wiremock::matchers::{method, path};
578    use wiremock::{Mock, MockServer, ResponseTemplate};
579
580    use super::*;
581    use crate::schema_registry::SchemaType;
582
583    #[derive(Debug, PartialEq, Serialize, Deserialize, AvroSchema)]
584    #[serde(rename = "CodecOrder")]
585    struct Order {
586        id: i64,
587        item: String,
588    }
589
590    /// The same record one version on: a field added, carrying the default that lets a reader
591    /// project an older writer's datum onto it.
592    #[derive(Debug, PartialEq, Serialize, Deserialize, AvroSchema)]
593    #[serde(rename = "CodecOrder")]
594    struct OrderV2 {
595        id: i64,
596        item: String,
597        #[avro(default = r#""none""#)]
598        note: String,
599    }
600
601    fn order() -> Order {
602        Order {
603            id: 42,
604            item: "anvil".to_owned(),
605        }
606    }
607
608    #[test]
609    fn a_local_schema_round_trips_with_no_registry() {
610        let codec = AvroCodec::local(Order::get_schema()).expect("local");
611        let bytes = codec.encode(&order()).expect("encode");
612
613        assert_ne!(
614            &bytes[..],
615            br#"{"id":42,"item":"anvil"}"#,
616            "the wire is Avro"
617        );
618        assert_eq!(codec.decode::<Order>(&bytes).expect("decode"), order());
619    }
620
621    #[test]
622    fn a_local_codec_writes_no_envelope() {
623        let codec = AvroCodec::local(Order::get_schema()).expect("local");
624        let bytes = codec.encode(&order()).expect("encode");
625
626        assert!(
627            parse_envelope(&bytes).is_none() || bytes[0] != WIRE_MAGIC,
628            "a local codec puts a bare datum on the wire",
629        );
630    }
631
632    #[test]
633    fn a_value_the_schema_does_not_describe_is_refused() {
634        #[derive(Serialize)]
635        struct Other {
636            nope: String,
637        }
638        let codec = AvroCodec::local(Order::get_schema()).expect("local");
639
640        let err = codec
641            .encode(&Other {
642                nope: "x".to_owned(),
643            })
644            .expect_err("a value of another shape");
645        assert!(err.to_string().contains("does not fit the schema"));
646    }
647
648    #[test]
649    fn a_reader_schema_fills_what_the_writer_never_wrote() {
650        let writing = AvroCodec::local(Order::get_schema()).expect("writer");
651        let bytes = writing.encode(&order()).expect("encode");
652
653        // Without a reader schema the field is genuinely absent, and serde says so.
654        let reading = AvroCodec::local(Order::get_schema()).expect("reader");
655        assert!(reading.decode::<OrderV2>(&bytes).is_err());
656
657        let resolving = AvroCodec::local(Order::get_schema())
658            .expect("reader")
659            .resolve_onto(OrderV2::get_schema())
660            .expect("resolve");
661        assert_eq!(
662            resolving.decode::<OrderV2>(&bytes).expect("resolve"),
663            OrderV2 {
664                id: 42,
665                item: "anvil".to_owned(),
666                note: "none".to_owned(),
667            },
668        );
669    }
670
671    async fn registry_with_order(id: u32) -> (MockServer, SchemaPrefetch) {
672        let server = MockServer::start().await;
673        let definition = crate::avro::schema_json(&Order::get_schema()).expect("json");
674        Mock::given(method("GET"))
675            .and(path("/subjects/orders-value/versions/latest"))
676            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
677                "id": id,
678                "version": 1,
679                "schema": definition,
680                "schemaType": "AVRO",
681            })))
682            .mount(&server)
683            .await;
684        let prefetch = SchemaPrefetch::new(SchemaRegistry::new(server.uri()));
685        (server, prefetch)
686    }
687
688    #[tokio::test]
689    async fn a_registry_codec_frames_with_the_subjects_id() {
690        let (_server, prefetch) = registry_with_order(11).await;
691        let codec = AvroCodec::registry(&prefetch).register::<Order>("orders-value");
692        prefetch.warm_subjects().await.expect("warm");
693
694        let bytes = codec.encode(&order()).expect("encode");
695        let (id, datum) = parse_envelope(&bytes).expect("the wire format");
696        assert_eq!(id, 11);
697        assert_ne!(datum, b"");
698
699        // And it reads its own frame back through the id the envelope names.
700        assert_eq!(codec.decode::<Order>(&bytes).expect("decode"), order());
701    }
702
703    /// One codec, two message types, each under its own subject - which is the shape a router
704    /// full of handlers needs and a codec per type could not give.
705    #[tokio::test]
706    async fn one_codec_frames_each_registered_type_under_its_own_subject() {
707        #[derive(Debug, PartialEq, Serialize, Deserialize, AvroSchema)]
708        #[serde(rename = "CodecShipment")]
709        struct Shipment {
710            id: i64,
711        }
712
713        let (server, prefetch) = registry_with_order(11).await;
714        Mock::given(method("GET"))
715            .and(path("/subjects/shipments-value/versions/latest"))
716            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
717                "id": 12,
718                "version": 1,
719                "schema": crate::avro::schema_json(&Shipment::get_schema()).expect("json"),
720                "schemaType": "AVRO",
721            })))
722            .mount(&server)
723            .await;
724
725        let codec = AvroCodec::registry(&prefetch)
726            .register::<Order>("orders-value")
727            .register::<Shipment>("shipments-value");
728        prefetch.warm_subjects().await.expect("warm");
729
730        let (id, _) = parse_envelope(&codec.encode(&order()).expect("encode")).expect("framed");
731        assert_eq!(id, 11);
732        let shipped = codec.encode(&Shipment { id: 3 }).expect("encode");
733        let (id, _) = parse_envelope(&shipped).expect("framed");
734        assert_eq!(id, 12, "the second type framed under its own subject");
735        assert_eq!(
736            codec.decode::<Shipment>(&shipped).expect("decode"),
737            Shipment { id: 3 },
738        );
739    }
740
741    #[tokio::test]
742    async fn an_unregistered_type_names_itself_and_what_the_codec_carries() {
743        #[derive(Serialize)]
744        #[serde(rename = "Stranger")]
745        struct Stranger {
746            id: i64,
747        }
748
749        let (_server, prefetch) = registry_with_order(11).await;
750        let codec = AvroCodec::registry(&prefetch).register::<Order>("orders-value");
751        prefetch.warm_subjects().await.expect("warm");
752
753        let err = codec
754            .encode(&Stranger { id: 1 })
755            .expect_err("not registered");
756        assert!(err.to_string().contains("Stranger"));
757        assert!(err.to_string().contains("CodecOrder"), "{err}");
758    }
759
760    #[tokio::test]
761    async fn an_unnamed_value_is_refused_rather_than_guessed_at() {
762        let (_server, prefetch) = registry_with_order(11).await;
763        let codec = AvroCodec::registry(&prefetch).register::<Order>("orders-value");
764
765        let err = codec.encode(&7i64).expect_err("no name to key by");
766        assert!(err.to_string().contains("has none"));
767    }
768
769    #[tokio::test]
770    async fn an_unresolved_subject_names_the_attachment_it_is_missing() {
771        let (_server, prefetch) = registry_with_order(11).await;
772        // Never warmed: the codec was built, but the prefetch never reached a broker's connect.
773        let codec = AvroCodec::registry(&prefetch).register::<Order>("orders-value");
774
775        let err = codec.encode(&order()).expect_err("cold subject");
776        assert!(err.to_string().contains("schema_prefetch"));
777    }
778
779    #[tokio::test]
780    async fn an_unresolved_id_names_the_attachment_it_is_missing() {
781        let (_server, prefetch) = registry_with_order(11).await;
782        let codec = AvroCodec::registry(&prefetch).register::<Order>("orders-value");
783        let framed = [0u8, 0, 0, 0, 99, 1];
784
785        let err = codec.decode::<Order>(&framed).expect_err("cold id");
786        assert!(err.to_string().contains("no schema is cached for id 99"));
787        assert!(err.to_string().contains("SchemaPrefetch"));
788    }
789
790    #[tokio::test]
791    async fn an_unframed_delivery_on_a_registry_codec_is_refused() {
792        let (_server, prefetch) = registry_with_order(11).await;
793        let codec = AvroCodec::registry(&prefetch).register::<Order>("orders-value");
794
795        let err = codec
796            .decode::<Order>(br#"{"id":42}"#)
797            .expect_err("not framed");
798        assert!(err.to_string().contains("Confluent wire format"));
799    }
800
801    #[tokio::test]
802    async fn the_prefetch_resolves_a_deliverys_writer_schema() {
803        let (server, prefetch) = registry_with_order(11).await;
804        let definition = crate::avro::schema_json(&Order::get_schema()).expect("json");
805        Mock::given(method("GET"))
806            .and(path("/schemas/ids/11"))
807            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
808                "schema": definition,
809                "schemaType": "AVRO",
810            })))
811            .expect(1)
812            .mount(&server)
813            .await;
814
815        let codec = AvroCodec::registry(&prefetch).register::<Order>("orders-value");
816        let framed = [0u8, 0, 0, 0, 11, 84, 10, 97, 110, 118, 105, 108];
817
818        // Cold: the sync codec refuses rather than reaching for the network.
819        assert!(codec.decode::<Order>(&framed).is_err());
820
821        // The async edge resolves it, and the same bytes now decode with no I/O of their own.
822        prefetch.warm_delivery(&framed).await;
823        assert_eq!(codec.decode::<Order>(&framed).expect("decode"), order());
824    }
825
826    #[tokio::test]
827    async fn a_missing_subject_fails_startup_rather_than_the_first_publish() {
828        let server = MockServer::start().await;
829        Mock::given(method("GET"))
830            .and(path("/subjects/absent-value/versions/latest"))
831            .respond_with(ResponseTemplate::new(404))
832            .mount(&server)
833            .await;
834        let prefetch = SchemaPrefetch::new(SchemaRegistry::new(server.uri()));
835        let _codec = AvroCodec::registry(&prefetch).register::<Order>("absent-value");
836
837        let err = prefetch.warm_subjects().await.expect_err("absent subject");
838        assert!(err.to_string().contains("absent-value"));
839    }
840
841    /// The one shape a registry schema cannot take from a Rust model: `apache-avro` maps an
842    /// unsigned Rust integer onto its own fixed logical type, which does not resolve against the
843    /// `long` a registered schema declares. It is reported, not silently mis-encoded.
844    #[tokio::test]
845    async fn an_unsigned_field_against_a_long_schema_is_reported() {
846        // Named as the registered type, so the publish reaches the schema rather than stopping
847        // at the subject lookup.
848        #[derive(Serialize)]
849        #[serde(rename = "CodecOrder")]
850        struct Wide {
851            id: u64,
852            item: String,
853        }
854        let (_server, prefetch) = registry_with_order(11).await;
855        let codec = AvroCodec::registry(&prefetch).register::<Order>("orders-value");
856        prefetch.warm_subjects().await.expect("warm");
857
858        let err = codec
859            .encode(&Wide {
860                id: 7,
861                item: "anvil".to_owned(),
862            })
863            .expect_err("u64 against a long schema");
864        assert!(err.to_string().contains("does not fit the schema"));
865    }
866
867    #[tokio::test]
868    async fn the_registered_flavor_is_checked_before_it_is_parsed() {
869        let server = MockServer::start().await;
870        Mock::given(method("GET"))
871            .and(path("/subjects/json-value/versions/latest"))
872            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
873                "id": 3,
874                "version": 1,
875                "schema": "{\"type\":\"object\"}",
876                "schemaType": "JSON",
877            })))
878            .mount(&server)
879            .await;
880        let prefetch = SchemaPrefetch::new(SchemaRegistry::new(server.uri()));
881        let codec = AvroCodec::registry(&prefetch).register::<Order>("json-value");
882        prefetch.warm_subjects().await.expect("warm");
883
884        // A JSON Schema document is not an Avro schema, and the parse says so.
885        let err = codec.encode(&order()).expect_err("not an avro schema");
886        assert!(matches!(err, CodecError::Encode(_)));
887        let _ = SchemaType::Json;
888    }
889}