Skip to main content

ruststream_rdkafka/schema_registry/
mod.rs

1//! The Confluent Schema Registry: the client, the wire-format envelope, and the two ways a
2//! service speaks it.
3//!
4//! A registry-backed payload carries the Confluent wire format: a zero magic byte, a big-endian
5//! 4-byte schema id, then the encoded datum. The codec is the path to reach for first: the
6//! schema sits in [`AvroCodec`](crate::avro::AvroCodec), or in [`SchemaFramed`] over the core's
7//! `JsonCodec`, and handlers take and return their models. The transcode converts at the
8//! broker's edges instead, so handlers keep plain serde models on the default codec. A broker
9//! carrying the transcode rewrites every subscription it opens to JSON, so one broker uses one of
10//! the two. A `prost` message serializes itself and rides the byte lanes, with the envelope put
11//! on by [`protobuf`](crate::protobuf).
12//!
13//! - [Schema Registry][registry]: [`SchemaRegistry`], the client, with [`RegisteredSchema`] and
14//!   [`SchemaType`].
15//! - [Registration][registration]: [`SchemaRegistry::register`],
16//!   [`register_json`](SchemaRegistry::register_json) with [`JsonSchema`],
17//!   [`register_avro`](SchemaRegistry::register_avro), and [`warm`](SchemaRegistry::warm), which
18//!   resolves a subject without registering it.
19//! - [SchemaPrefetch][prefetch]: [`SchemaPrefetch`], attached with
20//!   [`KafkaBroker::schema_prefetch`](crate::KafkaBroker::schema_prefetch), warms what a codec
21//!   reads.
22//! - [MissingSubject][missing]: [`MissingSubject`], what a codec does when its subject is gone.
23//! - [Cache][cache]: [`SchemaCachePolicy`], [`MemorySchemaCache`] and the [`SchemaCache`] seam.
24//! - [RegistryClient][client]: [`RegistryClient`], with [`HttpRegistryClient`] as the default.
25//! - [SubjectStrategy][strategy]: [`SubjectStrategy`], how a topic names its subject.
26//! - [SchemaFramed][framed]: [`SchemaFramed`], a JSON codec under the envelope.
27//! - [JSON transcode][transcode]:
28//!   [`KafkaBroker::schema_registry`](crate::KafkaBroker::schema_registry) and [`SchemaFrame`].
29//! - [Envelope][envelope]: [`parse_envelope`] and [`encode_envelope`].
30//! - [Third-party clients][third-party]: the byte lanes carry what another registry stack framed.
31//!
32//! [registry]: https://powersemmi.github.io/ruststream-rdkafka/latest/components/schema-registry/
33//! [registration]: https://powersemmi.github.io/ruststream-rdkafka/latest/components/schema-registry/registration/
34//! [prefetch]: https://powersemmi.github.io/ruststream-rdkafka/latest/components/schema-registry/schema-prefetch/
35//! [missing]: https://powersemmi.github.io/ruststream-rdkafka/latest/components/schema-registry/missing-subject/
36//! [cache]: https://powersemmi.github.io/ruststream-rdkafka/latest/components/schema-registry/cache/
37//! [client]: https://powersemmi.github.io/ruststream-rdkafka/latest/components/schema-registry/registry-client/
38//! [strategy]: https://powersemmi.github.io/ruststream-rdkafka/latest/components/schema-registry/subject-strategy/
39//! [framed]: https://powersemmi.github.io/ruststream-rdkafka/latest/components/schema-registry/schema-framed/
40//! [transcode]: https://powersemmi.github.io/ruststream-rdkafka/latest/components/schema-registry/json-transcode/
41//! [envelope]: https://powersemmi.github.io/ruststream-rdkafka/latest/components/schema-registry/envelope/
42//! [third-party]: https://powersemmi.github.io/ruststream-rdkafka/latest/components/schema-registry/third-party/
43
44mod cache;
45mod client;
46mod name;
47
48use std::collections::{HashMap, HashSet};
49use std::error::Error as StdError;
50use std::fmt;
51use std::sync::{Arc, Mutex};
52use std::time::Duration;
53
54pub use cache::{MemorySchemaCache, SchemaCache, SchemaCachePolicy};
55pub use client::{HttpRegistryClient, RegistryClient};
56
57use client::{Auth, HttpConfig};
58pub(crate) use name::serde_name;
59use ruststream::codec::{Codec, CodecError};
60use ruststream::runtime::{Outgoing, PublishLayer, PublishNext, PublishPipeline};
61use ruststream::{BytesMut, Publisher};
62
63pub use schemars::JsonSchema;
64
65use crate::error::KafkaError;
66
67/// The zero magic byte opening every Confluent-framed payload.
68pub(crate) const WIRE_MAGIC: u8 = 0;
69
70/// The schema flavors the registry stores.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72#[non_exhaustive]
73pub enum SchemaType {
74    /// An Avro schema (the registry default; older registries omit the type field entirely).
75    Avro,
76    /// A Protobuf schema, stored as `.proto` source text.
77    Protobuf,
78    /// A JSON Schema document.
79    Json,
80}
81
82impl SchemaType {
83    fn as_api(self) -> &'static str {
84        match self {
85            Self::Avro => "AVRO",
86            Self::Protobuf => "PROTOBUF",
87            Self::Json => "JSON",
88        }
89    }
90
91    fn from_api(value: Option<&str>) -> Self {
92        match value {
93            Some("PROTOBUF") => Self::Protobuf,
94            Some("JSON") => Self::Json,
95            _ => Self::Avro,
96        }
97    }
98}
99
100/// One schema as the registry knows it: its id, flavor, and definition text.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct RegisteredSchema {
103    id: u32,
104    schema_type: SchemaType,
105    definition: String,
106}
107
108impl RegisteredSchema {
109    /// Builds one, for a [`RegistryClient`] implementation answering a lookup.
110    #[must_use]
111    pub fn new(id: u32, schema_type: SchemaType, definition: impl Into<String>) -> Self {
112        Self {
113            id,
114            schema_type,
115            definition: definition.into(),
116        }
117    }
118
119    /// The registry-assigned schema id (what the wire-format envelope carries).
120    #[must_use]
121    pub fn id(&self) -> u32 {
122        self.id
123    }
124
125    /// The schema flavor.
126    #[must_use]
127    pub fn schema_type(&self) -> SchemaType {
128        self.schema_type
129    }
130
131    /// The schema definition: Avro or JSON Schema as JSON text, Protobuf as `.proto` source.
132    #[must_use]
133    pub fn definition(&self) -> &str {
134        &self.definition
135    }
136}
137
138/// How a topic maps onto registry subjects, per Confluent's naming strategies.
139///
140/// The strategies name the subject a value schema is registered under;
141/// [`subject`](Self::subject) builds the name from the destination topic and the record's
142/// fully qualified name (unused by [`TopicName`](Self::TopicName)).
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
144#[non_exhaustive]
145pub enum SubjectStrategy {
146    /// `{topic}-value`: one value schema per topic (the Confluent default).
147    #[default]
148    TopicName,
149    /// `{record}`: one subject per record type, shared across topics.
150    RecordName,
151    /// `{topic}-{record}`: several record types per topic, each its own subject.
152    TopicRecordName,
153}
154
155impl SubjectStrategy {
156    /// The value subject for `topic` and `record` under this strategy.
157    #[must_use]
158    pub fn subject(self, topic: &str, record: &str) -> String {
159        match self {
160            Self::TopicName => format!("{topic}-value"),
161            Self::RecordName => record.to_owned(),
162            Self::TopicRecordName => format!("{topic}-{record}"),
163        }
164    }
165}
166
167struct RegistryInner {
168    /// What answers a registry question, and what remembers the answer. Both are seams: a
169    /// service that wants a different HTTP stack, a published client crate, a binding to a
170    /// non-Rust client, or a cache of its own replaces one without touching anything above.
171    client: Arc<dyn RegistryClient>,
172    cache: Arc<dyn SchemaCache>,
173    /// How the shipped HTTP client is configured, when that is what sits behind this facade, so
174    /// `basic_auth`, `bearer_token` and `request_timeout` can rebuild it without dropping the
175    /// settings they do not touch. `None` for a caller-supplied client.
176    shipped: Option<HttpConfig>,
177    /// The parsed forms of what the cache holds, which belong to the format features rather than
178    /// to the registry conversation, and are dropped alongside the entries they came from.
179    #[cfg(feature = "avro")]
180    parsed_avro: Mutex<HashMap<u32, Arc<apache_avro::Schema>>>,
181    #[cfg(feature = "protobuf")]
182    parsed_proto: Mutex<HashMap<u32, Arc<prost_reflect::DescriptorPool>>>,
183}
184
185/// The async Confluent Schema Registry client, shared by clones.
186///
187/// Construction is synchronous and does no I/O (the broker contract's lazy-startup shape);
188/// every lookup caches, so a schema id or subject resolves over the network once per process.
189///
190/// It is a facade over two seams, and everything else in this crate is written against it rather
191/// than against either: [`RegistryClient`] answers the registry's questions asynchronously, and
192/// [`SchemaCache`] remembers the answers synchronously. Replacing one - a different HTTP stack, a
193/// published client crate, a binding to a non-Rust client, a cache with different bounds - leaves
194/// the codecs, the byte-lane subjects, the prefetch and the transcoding middleware untouched.
195///
196/// # Examples
197///
198/// ```
199/// use ruststream_rdkafka::prelude::*;
200/// use ruststream_rdkafka::{SchemaFrame, SchemaRegistry};
201/// # #[derive(serde::Deserialize)]
202/// # struct Order { id: u64 }
203/// # #[subscriber("orders")]
204/// # async fn place(order: &Order) -> HandlerOutcome {
205/// #     tracing::info!(order.id, "accepted");
206/// #     HandlerOutcome::ack()
207/// # }
208///
209/// fn app() -> impl App {
210///     let registry = SchemaRegistry::new("http://localhost:8081").basic_auth("svc", "secret");
211///     // Consuming: subscriptions transcode framed deliveries to plain JSON through the client.
212///     let broker = KafkaBroker::new(["localhost:9092"])
213///         .default_group("orders-svc")
214///         .schema_registry(registry.clone());
215///     // Publishing: outgoing JSON is framed in the destination subject's registered flavor.
216///     RustStream::new(AppInfo::new("orders", "0.1.0"))
217///         .publish_layer(SchemaFrame::new(registry))
218///         .with_broker(broker, |b| {
219///             b.include(place);
220///         })
221/// }
222/// # let _ = app;
223/// ```
224#[derive(Clone)]
225pub struct SchemaRegistry {
226    inner: Arc<RegistryInner>,
227}
228
229impl fmt::Debug for SchemaRegistry {
230    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231        f.debug_struct("SchemaRegistry").finish_non_exhaustive()
232    }
233}
234
235impl SchemaRegistry {
236    /// Builds a client for the registry at `base_url` (for example `http://localhost:8081`).
237    /// No I/O happens until the first lookup.
238    #[must_use]
239    pub fn new(base_url: impl Into<String>) -> Self {
240        let mut base_url = base_url.into();
241        while base_url.ends_with('/') {
242            base_url.pop();
243        }
244        Self::shipping(HttpConfig::new(base_url))
245    }
246
247    /// A client over a [`RegistryClient`] of your own and the default cache.
248    ///
249    /// This is the seam for a registry this crate does not speak to: a different HTTP stack, a
250    /// published client crate, a binding to a non-Rust client, a fake in a test.
251    #[must_use]
252    pub fn with_client(client: Arc<dyn RegistryClient>) -> Self {
253        Self::assembled(
254            client,
255            Arc::new(MemorySchemaCache::new(SchemaCachePolicy::default())),
256        )
257    }
258
259    /// The registry's base URL, when this client is the shipped HTTP one. `None` for a
260    /// caller-supplied [`RegistryClient`], whose coordinate this crate does not know.
261    pub(crate) fn base_url(&self) -> Option<&str> {
262        self.inner
263            .shipped
264            .as_ref()
265            .map(|config| config.base_url.as_str())
266    }
267
268    /// Replaces what this client remembers with a [`SchemaCache`] of your own.
269    ///
270    /// Configure before handing the client out: clones share one cache.
271    #[must_use]
272    pub fn with_cache(self, cache: Arc<dyn SchemaCache>) -> Self {
273        let assembled = Self::assembled(Arc::clone(&self.inner.client), cache);
274        match self.inner.shipped.clone() {
275            Some(config) => assembled.remembering(config),
276            None => assembled,
277        }
278    }
279
280    /// How much of what it resolves this client remembers; see [`SchemaCachePolicy`].
281    ///
282    /// Sugar for [`with_cache`](Self::with_cache) over the default cache. Configure before
283    /// handing the client out: clones share one cache and the configuration it was made with.
284    #[must_use]
285    pub fn cache_policy(self, policy: SchemaCachePolicy) -> Self {
286        self.with_cache(Arc::new(MemorySchemaCache::new(policy)))
287    }
288
289    /// HTTP basic authentication for every registry request. Configure before handing the
290    /// client out: clones share the configuration they were made from.
291    ///
292    /// # Panics
293    ///
294    /// Panics when this client was built with [`with_client`](Self::with_client): the
295    /// credentials of a client this crate did not write are that client's own business.
296    #[must_use]
297    pub fn basic_auth(self, user: impl Into<String>, password: impl Into<String>) -> Self {
298        self.with_auth(Auth::Basic {
299            user: user.into(),
300            password: password.into(),
301        })
302    }
303
304    /// The deadline for one registry request, counted from the start of the request until its
305    /// response body is read. The default is ten seconds.
306    ///
307    /// Both edges resolve schemas while a delivery or a publish waits, so this is what bounds a
308    /// registry that accepts the connection and then goes silent. On expiry the request returns
309    /// [`KafkaError::SchemaRegistryTimeout`], which names the path and the deadline: a framed
310    /// delivery then reaches the handler un-transcoded with a warning, and a publish returns the
311    /// error so the publishing handler asks for a redelivery.
312    ///
313    /// Configure before handing the client out.
314    ///
315    /// # Panics
316    ///
317    /// As [`basic_auth`](Self::basic_auth): the deadline configures the HTTP client this crate
318    /// ships, and a client supplied with [`with_client`](Self::with_client) bounds its own
319    /// requests.
320    ///
321    /// # Examples
322    ///
323    /// ```
324    /// use std::time::Duration;
325    ///
326    /// use ruststream_rdkafka::SchemaRegistry;
327    /// use ruststream_rdkafka::prelude::*;
328    /// # #[derive(serde::Deserialize)]
329    /// # struct Order { id: u64 }
330    /// # #[subscriber("orders")]
331    /// # async fn place(order: &Order) -> HandlerOutcome {
332    /// #     tracing::info!(order.id, "accepted");
333    /// #     HandlerOutcome::ack()
334    /// # }
335    ///
336    /// fn app() -> RustStream {
337    ///     // A registry that stops answering fails the lookup in two seconds, not ten.
338    ///     let registry = SchemaRegistry::new("http://localhost:8081")
339    ///         .request_timeout(Duration::from_secs(2));
340    ///     let broker = KafkaBroker::new(["localhost:9092"])
341    ///         .default_group("orders-svc")
342    ///         .schema_registry(registry);
343    ///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(broker, |b| {
344    ///         b.include(place);
345    ///     })
346    /// }
347    /// # let _ = app;
348    /// ```
349    #[must_use]
350    pub fn request_timeout(self, timeout: Duration) -> Self {
351        let mut config = self.shipped_config();
352        config.timeout = timeout;
353        Self::shipping(config)
354    }
355
356    /// Bearer-token authentication for every registry request. Configure before handing the
357    /// client out.
358    ///
359    /// # Panics
360    ///
361    /// As [`basic_auth`](Self::basic_auth).
362    #[must_use]
363    pub fn bearer_token(self, token: impl Into<String>) -> Self {
364        self.with_auth(Auth::Bearer(token.into()))
365    }
366
367    /// Rebuilds the shipped HTTP client with new credentials, and empties the caches: what the
368    /// previous credentials could see is not an answer these ones may give.
369    fn with_auth(self, auth: Auth) -> Self {
370        let mut config = self.shipped_config();
371        config.auth = auth;
372        Self::shipping(config)
373    }
374
375    /// The configuration of the shipped HTTP client behind this facade.
376    ///
377    /// # Panics
378    ///
379    /// Panics when the facade holds a caller-supplied client, which configures itself.
380    fn shipped_config(&self) -> HttpConfig {
381        self.inner.shipped.clone().expect(
382            "basic_auth, bearer_token and request_timeout configure the HTTP client this crate \
383             ships; a RegistryClient supplied with `with_client` carries its own credentials and \
384             bounds its own requests",
385        )
386    }
387
388    /// A facade over a freshly built shipped HTTP client and an empty default cache.
389    fn shipping(config: HttpConfig) -> Self {
390        Self::assembled(
391            Arc::new(HttpRegistryClient::new(config.clone())),
392            Arc::new(MemorySchemaCache::new(SchemaCachePolicy::default())),
393        )
394        .remembering(config)
395    }
396
397    fn assembled(client: Arc<dyn RegistryClient>, cache: Arc<dyn SchemaCache>) -> Self {
398        Self {
399            inner: Arc::new(RegistryInner {
400                client,
401                cache,
402                shipped: None,
403                #[cfg(feature = "avro")]
404                parsed_avro: Mutex::new(HashMap::new()),
405                #[cfg(feature = "protobuf")]
406                parsed_proto: Mutex::new(HashMap::new()),
407            }),
408        }
409    }
410
411    /// Records that the client behind this facade is the shipped HTTP one, and how it is
412    /// configured, so the settings builders can rebuild it. Absent for a caller-supplied client.
413    fn remembering(self, config: HttpConfig) -> Self {
414        let mut inner = self.inner;
415        let slot = Arc::get_mut(&mut inner).expect("freshly assembled, not yet shared");
416        slot.shipped = Some(config);
417        Self { inner }
418    }
419
420    /// Records a freshly resolved schema, and drops the parsed forms of anything the cache let go
421    /// of to make room.
422    fn remember(&self, subject: Option<&str>, schema: &Arc<RegisteredSchema>) {
423        self.inner.cache.store(subject, schema);
424        let evicted = self.inner.cache.evicted_ids();
425        if !evicted.is_empty() {
426            self.purge_parsed(&evicted);
427        }
428    }
429
430    /// The schema registered under `id`, from the cache or the registry.
431    ///
432    /// # Errors
433    ///
434    /// Returns [`KafkaError::SchemaRegistry`] when the registry is unreachable, rejects the
435    /// request, or does not know the id.
436    ///
437    /// # Panics
438    ///
439    /// Panics when the internal cache mutex is poisoned, which requires a prior panic inside
440    /// the client (an invariant violation, not an operational failure).
441    pub async fn schema_by_id(&self, id: u32) -> Result<Arc<RegisteredSchema>, KafkaError> {
442        if let Some(schema) = self.cached_schema(id) {
443            return Ok(schema);
444        }
445        let schema = self.inner.client.schema_by_id(id).await?;
446        // No subject: a lookup by id says nothing about which subject points at it.
447        self.remember(None, &schema);
448        Ok(schema)
449    }
450
451    /// Registers `definition` under `subject` (idempotent registry-side: an identical schema
452    /// keeps its id) and caches the subject, so the framing middleware resolves it locally.
453    ///
454    /// # Errors
455    ///
456    /// Returns [`KafkaError::SchemaRegistry`] when the registry is unreachable, rejects the
457    /// request, or refuses the schema (compatibility, syntax).
458    ///
459    /// # Panics
460    ///
461    /// Panics when the internal cache mutex is poisoned, which requires a prior panic inside
462    /// the client (an invariant violation, not an operational failure).
463    pub async fn register(
464        &self,
465        subject: &str,
466        schema_type: SchemaType,
467        definition: impl Into<String>,
468    ) -> Result<u32, KafkaError> {
469        let definition = definition.into();
470        let id = self
471            .inner
472            .client
473            .register(subject, schema_type, definition.clone())
474            .await?;
475        self.remember(
476            Some(subject),
477            &Arc::new(RegisteredSchema::new(id, schema_type, definition)),
478        );
479        Ok(id)
480    }
481
482    /// Whether the registry would register `definition` under `subject`, judged against the
483    /// versions the subject's compatibility level names, or `None` when the client behind this
484    /// facade cannot answer.
485    ///
486    /// # Errors
487    ///
488    /// Returns [`KafkaError::SchemaRegistry`] when the registry is unreachable, rejects the
489    /// request, or reports the schemas incompatible - that message carries the registry's own
490    /// account of what differs.
491    pub async fn is_compatible(
492        &self,
493        subject: &str,
494        schema_type: SchemaType,
495        definition: impl Into<String>,
496    ) -> Result<Option<bool>, KafkaError> {
497        self.inner
498            .client
499            .is_compatible(subject, schema_type, definition.into())
500            .await
501    }
502
503    /// The id the registry already gave exactly this schema under `subject`, registering
504    /// nothing.
505    ///
506    /// This is the lookup a producer that owns its schema needs and [`warm`](Self::warm) cannot
507    /// answer: `warm` resolves *the subject's latest* schema, while a byte-lane producer writes
508    /// datums under *its own*, and framing one with the other's id puts an unreadable record on
509    /// the topic. Nothing is cached, for the same reason: the answer is about a schema the
510    /// caller brought, not about what the subject currently holds.
511    ///
512    /// # Errors
513    ///
514    /// Returns [`KafkaError::SchemaRegistry`] when the registry is unreachable or rejects the
515    /// request, and [`KafkaError::InvalidOptions`] when the subject does not hold this schema.
516    pub async fn lookup_id(
517        &self,
518        subject: &str,
519        schema_type: SchemaType,
520        definition: impl Into<String>,
521    ) -> Result<u32, KafkaError> {
522        self.inner
523            .client
524            .lookup_id(subject, schema_type, definition.into())
525            .await
526    }
527
528    /// Resolves `subject`'s latest version and caches it - the warm-only alternative to
529    /// [`register`](Self::register) when producers must not create schemas, and a startup
530    /// probe that a required subject exists (the framing middleware itself resolves subjects
531    /// lazily).
532    ///
533    /// # Errors
534    ///
535    /// Returns [`KafkaError::SchemaRegistry`] when the registry is unreachable, rejects the
536    /// request, or has no such subject.
537    ///
538    /// # Panics
539    ///
540    /// Panics when the internal cache mutex is poisoned, which requires a prior panic inside
541    /// the client (an invariant violation, not an operational failure).
542    pub async fn warm(&self, subject: &str) -> Result<Arc<RegisteredSchema>, KafkaError> {
543        self.latest(subject).await?.ok_or_else(|| {
544            KafkaError::SchemaRegistry(format!("the registry has no subject {subject:?}").into())
545        })
546    }
547
548    /// Like [`warm`](Self::warm), but a missing subject resolves to `None` instead of an
549    /// error - the framing middleware treats it as "this topic is not registry-backed".
550    pub(crate) async fn latest(
551        &self,
552        subject: &str,
553    ) -> Result<Option<Arc<RegisteredSchema>>, KafkaError> {
554        let Some(schema) = self.inner.client.latest(subject).await? else {
555            return Ok(None);
556        };
557        self.remember(Some(subject), &schema);
558        Ok(Some(schema))
559    }
560
561    /// Drops the parsed forms of schemas the id cache has just evicted: they are derived from
562    /// the definitions that went with them, so keeping them would outlive their source.
563    #[cfg(any(feature = "avro", feature = "protobuf"))]
564    fn purge_parsed(&self, evicted: &[u32]) {
565        for id in evicted {
566            #[cfg(feature = "avro")]
567            self.inner
568                .parsed_avro
569                .lock()
570                .expect("parsed schema cache mutex poisoned")
571                .remove(id);
572            #[cfg(feature = "protobuf")]
573            self.inner
574                .parsed_proto
575                .lock()
576                .expect("descriptor cache mutex poisoned")
577                .remove(id);
578        }
579    }
580
581    /// No parsed forms are kept without a format feature, so there is nothing to purge.
582    #[cfg(not(any(feature = "avro", feature = "protobuf")))]
583    #[allow(clippy::unused_self)]
584    fn purge_parsed(&self, _evicted: &[u32]) {}
585
586    /// The cached schema for `id`, when an earlier lookup resolved it (the subscription's
587    /// transcoding keeps ids it has seen warm).
588    ///
589    /// # Panics
590    ///
591    /// Panics when the internal cache mutex is poisoned, which requires a prior panic inside
592    /// the client (an invariant violation, not an operational failure).
593    #[must_use]
594    pub fn cached_schema(&self, id: u32) -> Option<Arc<RegisteredSchema>> {
595        self.inner.cache.schema(id)
596    }
597
598    /// The cached schema for `subject`, when [`register`](Self::register), [`warm`](Self::warm),
599    /// or the framing middleware's lazy resolution cached it.
600    ///
601    /// # Panics
602    ///
603    /// Panics when the internal cache mutex is poisoned, which requires a prior panic inside
604    /// the client (an invariant violation, not an operational failure).
605    #[must_use]
606    pub fn cached_subject(&self, subject: &str) -> Option<Arc<RegisteredSchema>> {
607        self.inner.cache.subject(subject)
608    }
609
610    /// Registers the JSON Schema generated from `T` (via schemars) under `subject` - the
611    /// typed shorthand for [`register`](Self::register) with a hand-written document.
612    ///
613    /// # Errors
614    ///
615    /// As [`register`](Self::register).
616    pub async fn register_json<T: JsonSchema>(&self, subject: &str) -> Result<u32, KafkaError> {
617        let schema = schemars::SchemaGenerator::default().into_root_schema_for::<T>();
618        let definition = serde_json::to_string(&schema).map_err(KafkaError::schema_registry)?;
619        self.register(subject, SchemaType::Json, definition).await
620    }
621
622    /// Registers the Avro schema derived from `T` under `subject` - the typed shorthand for
623    /// [`register`](Self::register) with `T::get_schema()`.
624    ///
625    /// The full schema JSON is registered, not its Parsing Canonical Form: canonicalization
626    /// keeps only what makes two schemas *the same* and drops field defaults, aliases, docs and
627    /// logical types - and a field default is precisely what lets a later reader resolve an
628    /// earlier writer's datum, so a subject registered canonically can never carry an evolution.
629    ///
630    /// # Errors
631    ///
632    /// As [`register`](Self::register), plus [`KafkaError::WireFormat`] when the derived schema
633    /// cannot be serialized.
634    #[cfg(feature = "avro")]
635    pub async fn register_avro<T: apache_avro::AvroSchema>(
636        &self,
637        subject: &str,
638    ) -> Result<u32, KafkaError> {
639        let definition = crate::avro::schema_json(&T::get_schema())?;
640        self.register(subject, SchemaType::Avro, definition).await
641    }
642
643    /// The parsed Avro schema for a cached registered schema, parsed once and shared.
644    ///
645    /// # Errors
646    ///
647    /// Returns [`KafkaError::SchemaRegistry`] when the definition is not valid Avro.
648    ///
649    /// # Panics
650    ///
651    /// Panics when the internal cache mutex is poisoned, which requires a prior panic inside
652    /// the client (an invariant violation, not an operational failure).
653    #[cfg(feature = "avro")]
654    pub(crate) fn parsed_avro(
655        &self,
656        schema: &RegisteredSchema,
657    ) -> Result<Arc<apache_avro::Schema>, KafkaError> {
658        if let Some(parsed) = self
659            .inner
660            .parsed_avro
661            .lock()
662            .expect("parsed schema cache mutex poisoned")
663            .get(&schema.id)
664        {
665            return Ok(Arc::clone(parsed));
666        }
667        let parsed = Arc::new(
668            apache_avro::Schema::parse_str(&schema.definition)
669                .map_err(KafkaError::schema_registry)?,
670        );
671        self.inner
672            .parsed_avro
673            .lock()
674            .expect("parsed schema cache mutex poisoned")
675            .insert(schema.id, Arc::clone(&parsed));
676        Ok(parsed)
677    }
678
679    /// The compiled descriptor pool for a cached Protobuf schema, compiled once (protox,
680    /// with the well-known types available) and shared. Registry schema references are not
681    /// resolved; a schema importing anything beyond the well-known types fails to compile.
682    ///
683    /// # Errors
684    ///
685    /// Returns [`KafkaError::SchemaRegistry`] when the source does not compile.
686    ///
687    /// # Panics
688    ///
689    /// Panics when the internal cache mutex is poisoned, which requires a prior panic inside
690    /// the client (an invariant violation, not an operational failure).
691    #[cfg(feature = "protobuf")]
692    pub(crate) fn parsed_proto(
693        &self,
694        schema: &RegisteredSchema,
695    ) -> Result<Arc<prost_reflect::DescriptorPool>, KafkaError> {
696        if let Some(parsed) = self
697            .inner
698            .parsed_proto
699            .lock()
700            .expect("descriptor cache mutex poisoned")
701            .get(&schema.id)
702        {
703            return Ok(Arc::clone(parsed));
704        }
705        let pool = compile_proto(&schema.definition).map_err(KafkaError::schema_registry)?;
706        let pool = Arc::new(pool);
707        self.inner
708            .parsed_proto
709            .lock()
710            .expect("descriptor cache mutex poisoned")
711            .insert(schema.id, Arc::clone(&pool));
712        Ok(pool)
713    }
714
715    /// Transcodes a Confluent-framed payload to plain JSON, so deliveries reach handlers in
716    /// the default codec's format: the JSON flavor loses its envelope, Avro and Protobuf
717    /// datums (with their features enabled) convert through their registry schema. Non-framed
718    /// payloads pass through untouched (`None`); a failed fetch or transcode warns and passes
719    /// the payload through, so the handler's decode failure carries the delivery's fate.
720    pub(crate) async fn incoming_to_json(&self, payload: &[u8]) -> Option<Vec<u8>> {
721        let (id, datum) = parse_envelope(payload)?;
722        let schema = match self.schema_by_id(id).await {
723            Ok(schema) => schema,
724            Err(err) => {
725                tracing::warn!(
726                    target: "ruststream_rdkafka",
727                    schema_id = id,
728                    error = %err,
729                    "schema fetch failed; the delivery passes through un-transcoded until \
730                     the registry recovers",
731                );
732                return None;
733            }
734        };
735        let transcoded = incoming_datum_to_json(self, &schema, datum);
736        match transcoded {
737            Ok(json) => Some(json),
738            Err(err) => {
739                tracing::warn!(
740                    target: "ruststream_rdkafka",
741                    schema_id = id,
742                    error = %err,
743                    "transcoding a framed delivery failed; the delivery passes through \
744                     un-transcoded",
745                );
746                None
747            }
748        }
749    }
750}
751
752#[cfg(feature = "protobuf")]
753fn compile_proto(source: &str) -> Result<prost_reflect::DescriptorPool, protox::Error> {
754    use protox::file::{ChainFileResolver, File, FileResolver, GoogleFileResolver};
755
756    /// Serves the registry schema as a single in-memory file next to the well-known types.
757    struct Single(String);
758    impl FileResolver for Single {
759        fn open_file(&self, name: &str) -> Result<File, protox::Error> {
760            if name == "registry.proto" {
761                File::from_source(name, &self.0)
762            } else {
763                Err(protox::Error::file_not_found(name))
764            }
765        }
766    }
767
768    let mut resolver = ChainFileResolver::new();
769    resolver.add(GoogleFileResolver::new());
770    resolver.add(Single(source.to_owned()));
771    let mut compiler = protox::Compiler::with_file_resolver(resolver);
772    compiler.include_imports(true);
773    compiler.open_file("registry.proto")?;
774    let set = compiler.file_descriptor_set();
775    prost_reflect::DescriptorPool::from_file_descriptor_set(set)
776        .map_err(|err| protox::Error::new(err.to_string()))
777}
778
779/// Converts an incoming framed datum to plain JSON by its schema's flavor. The `avro` and
780/// `protobuf` features extend the match; without them those flavors error.
781fn incoming_datum_to_json(
782    registry: &SchemaRegistry,
783    schema: &RegisteredSchema,
784    datum: &[u8],
785) -> Result<Vec<u8>, KafkaError> {
786    let _ = registry;
787    match schema.schema_type {
788        SchemaType::Json => Ok(datum.to_vec()),
789        #[cfg(feature = "avro")]
790        SchemaType::Avro => crate::avro::avro_to_json(registry, schema, datum),
791        #[cfg(feature = "protobuf")]
792        SchemaType::Protobuf => crate::protobuf::protobuf_to_json(registry, schema, datum),
793        #[allow(unreachable_patterns)] // the disabled-format arms
794        other => Err(KafkaError::InvalidOptions(format!(
795            "schema id {} is {other:?}, but the matching cargo feature is not enabled on \
796             ruststream-rdkafka; enable it to consume this topic",
797            schema.id,
798        ))),
799    }
800}
801
802/// Converts an outgoing plain-JSON payload to the wire datum for the subject's schema
803/// flavor. The `avro` and `protobuf` features extend the match; without them those flavors
804/// error.
805fn outgoing_json_to_datum(
806    registry: &SchemaRegistry,
807    schema: &RegisteredSchema,
808    message: Option<&str>,
809    payload: &[u8],
810) -> Result<Vec<u8>, KafkaError> {
811    let _ = (registry, message);
812    match schema.schema_type {
813        SchemaType::Json => Ok(payload.to_vec()),
814        #[cfg(feature = "avro")]
815        SchemaType::Avro => crate::avro::json_to_avro(registry, schema, payload),
816        #[cfg(feature = "protobuf")]
817        SchemaType::Protobuf => {
818            crate::protobuf::json_to_protobuf(registry, schema, message, payload)
819        }
820        #[allow(unreachable_patterns)] // the disabled-format arms
821        other => Err(KafkaError::InvalidOptions(format!(
822            "the subject's schema (id {}) is {other:?}, but the matching cargo feature is \
823             not enabled on ruststream-rdkafka; enable it to publish to this topic",
824            schema.id,
825        ))),
826    }
827}
828
829/// The async half of a registry-backed codec: it resolves, on the broker's async edges, every
830/// schema the synchronous codec will read.
831///
832/// A [`Codec`] is synchronous on both ends and a registry lookup is
833/// not, and no amount of arranging makes those meet inside `encode` or `decode`: blocking a
834/// runtime worker from a sync function is not an option and guessing a schema is corruption. So
835/// the lookups move to the two places that are already `async` and already know when they have to
836/// happen - the broker's `connect`, for the subjects a codec publishes under, and each
837/// subscription's delivery path, for the writer schema an arriving envelope names. By the time a
838/// codec runs, what it needs is in the shared cache, and its own work is pure computation.
839///
840/// This is a distinct attachment from
841/// [`KafkaBroker::schema_registry`](crate::KafkaBroker::schema_registry) on purpose. That one
842/// *rewrites* deliveries into JSON, which is the transcoding compatibility path; this one leaves
843/// every payload exactly as it arrived and only fills a cache. Attaching both to one broker is
844/// contradictory - the transcode would hand a JSON document to a codec expecting the Confluent
845/// wire format - and the prefetch runs first so it still sees the envelope.
846///
847/// # Examples
848///
849/// ```
850/// # #[cfg(feature = "avro")]
851/// # mod demo {
852/// use apache_avro::AvroSchema;
853/// use ruststream_rdkafka::avro::AvroCodec;
854/// use ruststream_rdkafka::prelude::*;
855/// use ruststream_rdkafka::{SchemaPrefetch, SchemaRegistry};
856/// use serde::{Deserialize, Serialize};
857///
858/// #[derive(Deserialize, AvroSchema)]
859/// struct Order {
860///     id: i64,
861/// }
862///
863/// #[derive(Serialize, AvroSchema, Outgoing)]
864/// #[outgoing(name = "shipments")]
865/// struct Shipment {
866///     order_id: i64,
867/// }
868///
869/// #[subscriber("orders", reply)]
870/// async fn ship(order: &Order) -> Shipment {
871///     Shipment { order_id: order.id }
872/// }
873///
874/// fn app() -> RustStream {
875///     let prefetch = SchemaPrefetch::new(SchemaRegistry::new("http://localhost:8081"));
876///     // Every type a codec registers records its subject here, so `connect` resolves it and a
877///     // subject that is missing is settled by the prefetch's policy rather than at first publish.
878///     let codec = AvroCodec::registry(&prefetch).register::<Shipment>("shipments-value");
879///     let broker = KafkaBroker::new(["localhost:9092"])
880///         .default_group("shipping-svc")
881///         .schema_prefetch(prefetch);
882///     // The scope's codec decodes every delivery; the reply names it again, at its own position.
883///     let replies = codec.clone();
884///     RustStream::new(AppInfo::new("shipping", "0.1.0")).with_broker_codec(broker, codec, |b| {
885///         b.include(ship).out_reply(Publish::default()).codec(replies);
886///     })
887/// }
888/// # }
889/// ```
890#[derive(Clone)]
891pub struct SchemaPrefetch {
892    registry: SchemaRegistry,
893    /// What the codecs built from this prefetch publish, and the schema each was registered
894    /// with. Shared with those codecs' clones, so registering a type once puts it on this list.
895    registrations: Arc<Mutex<Vec<Registration>>>,
896    on_missing: MissingSubject,
897    check_compatibility: bool,
898}
899
900/// One `register::<T>(subject)` call: what to resolve at connect, and what to put back if the
901/// registry no longer holds it.
902#[derive(Debug, Clone)]
903pub(crate) struct Registration {
904    pub(crate) subject: String,
905    pub(crate) schema_type: SchemaType,
906    /// The schema the registering type carries. Kept so
907    /// [`MissingSubject::AutoRegister`] has something to register, and never used to encode:
908    /// what a datum is written with is the schema the framed id names.
909    pub(crate) definition: String,
910}
911
912/// What a registered subject's absence from the registry means, and what to do about it.
913///
914/// The vocabulary is Confluent's, because an operator who knows Kafka already has these settings
915/// in their head. Their serializers cover this ground with three: `auto.register.schemas`
916/// (register the local schema when the subject does not have it), `use.latest.version` (only
917/// when auto-registration is off - serialize against the subject's latest registered version
918/// rather than the local schema), and `latest.compatibility.strict` (check the local schema
919/// against that latest version).
920///
921/// Two of those describe this crate's *normal* path rather than this enum. Encoding always
922/// writes with the schema the framed id names, which is `use.latest.version` semantics, and
923/// [`SchemaPrefetch::check_compatibility`] stands where `latest.compatibility.strict` does,
924/// judged by the subject's own compatibility level, so a `_TRANSITIVE` level checks the whole
925/// history as registration would. What is left for this enum is the one question those settings
926/// answer between them: when the subject is not there at all, does the producer create it,
927/// refuse, or go without.
928///
929/// It is an enum rather than the three booleans Confluent uses because the booleans are not
930/// independent - `use.latest.version` means nothing while auto-registration is on - and a
931/// combination that means nothing is exactly what an enum keeps unrepresentable.
932///
933/// # What a deletion actually does
934///
935/// Confluent's two deletions differ, and this was checked against a live registry rather than
936/// assumed. A **soft** delete (`DELETE /subjects/{s}`) hides the subject - its `versions/latest`
937/// answers 404 - while `GET /schemas/ids/{id}` still returns the schema, so **consumers keep
938/// working** and only the producer is stuck. A **permanent** delete (`?permanent=true`) removes
939/// the id too, and then nothing can decode a record that names it: no policy here helps a
940/// consumer, because the schema is gone for everyone. Re-registering the same schema afterwards
941/// mints a *new* id, so records already on the topic stay unreadable.
942#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
943#[non_exhaustive]
944pub enum MissingSubject {
945    /// Fail, and say which subject is gone. The default.
946    ///
947    /// Confluent's `auto.register.schemas=false` with `use.latest.version=true`: the producer
948    /// writes only what the registry already holds, so a subject that is not there stops it.
949    /// Creating subjects in someone else's registry as a side effect of starting up is worse
950    /// than not starting: a schema registry is a shared contract, and a producer that quietly
951    /// writes into it takes a decision that belongs to whoever owns the topic.
952    #[default]
953    Refuse,
954    /// Register the schema the type carries under the subject, and warn.
955    ///
956    /// Confluent's `auto.register.schemas=true`, which is their own producer default, for a
957    /// deployment where the producer does own its subjects and an absent one is an accident to
958    /// repair rather than a signal to stop.
959    AutoRegister,
960    /// Publish the datum with no envelope, and warn.
961    ///
962    /// No Confluent counterpart: their serializers have no "carry on without the registry" mode.
963    /// For a topic that is expected to survive its registry being unavailable, at the cost of
964    /// records that no registry-backed consumer can decode.
965    PublishUnframed,
966}
967
968impl fmt::Debug for SchemaPrefetch {
969    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
970        f.debug_struct("SchemaPrefetch")
971            .field("registry", &self.registry)
972            .field("on_missing", &self.on_missing)
973            .finish_non_exhaustive()
974    }
975}
976
977impl SchemaPrefetch {
978    /// Builds the prefetch over `registry`. Construction is synchronous and does no I/O; the
979    /// resolving happens on the broker's async edges.
980    #[must_use]
981    pub fn new(registry: SchemaRegistry) -> Self {
982        Self {
983            registry,
984            registrations: Arc::new(Mutex::new(Vec::new())),
985            on_missing: MissingSubject::default(),
986            check_compatibility: true,
987        }
988    }
989
990    /// Whether `connect` checks each registered type's schema the way registering it would be
991    /// checked, and refuses to start when the registry says they are incompatible.
992    ///
993    /// The subject's compatibility level decides the versions the schema is held against: the
994    /// latest one for `BACKWARD`, `FORWARD` and `FULL`, every version for their `_TRANSITIVE`
995    /// forms. A schema this check passes is one the registry would accept.
996    ///
997    /// It stands where Confluent's `latest.compatibility.strict` does, and is on for the same
998    /// reason it is on by default there: a model that has drifted from its subject is a fact
999    /// worth learning while the app is starting rather than from a consumer that cannot read what
1000    /// it wrote. The registry's own account of what differs, down to the field, travels in the
1001    /// error.
1002    ///
1003    /// Turn it off for a registry whose compatibility level is deliberately `NONE`, or a client
1004    /// that cannot answer the question - a [`RegistryClient`] that does not implement
1005    /// `is_compatible` skips it regardless.
1006    #[must_use]
1007    pub fn check_compatibility(mut self, check: bool) -> Self {
1008        self.check_compatibility = check;
1009        self
1010    }
1011
1012    /// What to do about a registered subject the registry does not hold; see
1013    /// [`MissingSubject`]. Defaults to [`Refuse`](MissingSubject::Refuse).
1014    ///
1015    /// Configure before building codecs from it: each copies the policy as it is built.
1016    #[must_use]
1017    pub fn on_missing_subject(mut self, policy: MissingSubject) -> Self {
1018        self.on_missing = policy;
1019        self
1020    }
1021
1022    /// The client the codecs share a cache with.
1023    #[must_use]
1024    pub fn registry(&self) -> &SchemaRegistry {
1025        &self.registry
1026    }
1027
1028    /// The policy codecs built from this prefetch carry.
1029    pub(crate) fn missing_subject(&self) -> MissingSubject {
1030        self.on_missing
1031    }
1032
1033    /// Records what a `register::<T>(subject)` call published, so
1034    /// [`warm_subjects`](Self::warm_subjects) resolves it.
1035    ///
1036    /// # Panics
1037    ///
1038    /// Panics when the internal mutex is poisoned, which requires a prior panic inside this
1039    /// type (an invariant violation, not an operational failure).
1040    pub(crate) fn record(&self, registration: Registration) {
1041        self.registrations
1042            .lock()
1043            .expect("prefetch registrations mutex poisoned")
1044            .push(registration);
1045    }
1046
1047    /// Resolves every registered subject. Called once, by the broker's `connect`, so an absent
1048    /// subject is settled while the app is still starting rather than at its first publish.
1049    pub(crate) async fn warm_subjects(&self) -> Result<(), KafkaError> {
1050        let registrations: Vec<Registration> = self
1051            .registrations
1052            .lock()
1053            .expect("prefetch registrations mutex poisoned")
1054            .clone();
1055        for registration in registrations {
1056            let subject = &registration.subject;
1057            if self.registry.latest(subject).await?.is_some() {
1058                if self.check_compatibility {
1059                    self.registry
1060                        .is_compatible(
1061                            subject,
1062                            registration.schema_type,
1063                            registration.definition.clone(),
1064                        )
1065                        .await?;
1066                }
1067                continue;
1068            }
1069            match self.on_missing {
1070                MissingSubject::Refuse => {
1071                    return Err(KafkaError::SchemaRegistry(
1072                        format!(
1073                            "subject {subject:?} is registered by a codec in this app but the \
1074                             registry does not hold it, so there is no id to frame its records \
1075                             with. It was most likely deleted; re-register it, point the codec \
1076                             at the subject that carries this schema, or choose a \
1077                             MissingSubject policy that repairs or bypasses it."
1078                        )
1079                        .into(),
1080                    ));
1081                }
1082                MissingSubject::AutoRegister => {
1083                    let id = self
1084                        .registry
1085                        .register(
1086                            subject,
1087                            registration.schema_type,
1088                            registration.definition.clone(),
1089                        )
1090                        .await?;
1091                    tracing::warn!(
1092                        target: "ruststream_rdkafka",
1093                        subject = %subject,
1094                        schema_id = id,
1095                        schema_type = ?registration.schema_type,
1096                        definition = %registration.definition,
1097                        "the registry did not hold a subject this app publishes under, so it \
1098                         was registered again from the schema the message type carries; records \
1099                         written before the deletion keep the id they were framed with, which a \
1100                         permanent delete has already made unreadable",
1101                    );
1102                }
1103                MissingSubject::PublishUnframed => {
1104                    tracing::warn!(
1105                        target: "ruststream_rdkafka",
1106                        subject = %subject,
1107                        schema_type = ?registration.schema_type,
1108                        definition = %registration.definition,
1109                        "the registry did not hold a subject this app publishes under, so its \
1110                         records go out with no Confluent envelope; a registry-backed consumer \
1111                         cannot decode them",
1112                    );
1113                }
1114            }
1115        }
1116        Ok(())
1117    }
1118
1119    /// Resolves the writer schema an arriving envelope names, so the delivery's own decode finds
1120    /// it in the cache. The payload is not touched.
1121    ///
1122    /// A payload carrying no envelope needs nothing, and a lookup that fails is only logged: the
1123    /// decode that follows reports it as a decode failure, which the subscription's failure
1124    /// policy settles - one delivery's fate decided in one place, rather than half of it here.
1125    pub(crate) async fn warm_delivery(&self, payload: &[u8]) {
1126        let Some((id, _)) = parse_envelope(payload) else {
1127            return;
1128        };
1129        if self.registry.cached_schema(id).is_some() {
1130            return;
1131        }
1132        if let Err(err) = self.registry.schema_by_id(id).await {
1133            tracing::warn!(
1134                target: "ruststream_rdkafka",
1135                schema_id = id,
1136                error = %err,
1137                "the writer schema of an arriving delivery could not be resolved; its decode \
1138                 will report the miss and the subscription's failure policy settles it",
1139            );
1140        }
1141    }
1142}
1143
1144/// The Confluent envelope around another codec, for a payload that does not need its schema to
1145/// be read.
1146///
1147/// The envelope is separable exactly when the datum inside it is self-describing. A JSON
1148/// document is: the id says which schema it claims to conform to, and the document parses
1149/// without it, so framing decomposes cleanly into "put the envelope on, take the envelope off"
1150/// with an inner codec doing the rest. An Avro datum is not: the id names the schema the datum
1151/// *cannot be read without*, and no wrapper can hand that per-delivery schema through a
1152/// [`Codec`] method - which is why [`AvroCodec`](crate::avro::AvroCodec) owns its envelope
1153/// instead of riding this one. That line is the design rule, not an accident of the two formats.
1154///
1155/// The wrapper frames and does not validate. A registry's JSON Schema is a compatibility
1156/// contract the registry itself enforces between versions, and checking every message against it
1157/// would mean carrying a JSON Schema validator and paying it per delivery, which Confluent's own
1158/// serializer makes optional for the same reason. Validation is the inner codec's business, and
1159/// the inner codec is named right here at the call site: a service that wants it passes a codec
1160/// that validates instead of a plain one.
1161///
1162/// Only encoding needs the registry, for the subject's id; decoding strips the envelope and
1163/// hands the bytes on, so a consumer needs no [`SchemaPrefetch`] resolved for it at all.
1164///
1165/// # Examples
1166///
1167/// ```
1168/// use ruststream::codec::JsonCodec;
1169/// use ruststream_rdkafka::prelude::*;
1170/// use ruststream_rdkafka::{SchemaFramed, SchemaPrefetch, SchemaRegistry};
1171/// use schemars::JsonSchema;
1172/// use serde::{Deserialize, Serialize};
1173///
1174/// #[derive(Serialize, Deserialize, JsonSchema, Outgoing)]
1175/// #[outgoing(name = "invoices")]
1176/// struct Invoice {
1177///     order_id: i64,
1178/// }
1179///
1180/// #[subscriber("orders", reply)]
1181/// async fn invoice(order: &Invoice) -> Invoice {
1182///     Invoice { order_id: order.order_id }
1183/// }
1184///
1185/// fn app() -> RustStream {
1186///     let prefetch = SchemaPrefetch::new(SchemaRegistry::new("http://localhost:8081"));
1187///     // Confluent-framed JSON: the core's own codec, under the envelope of a registered subject.
1188///     let codec = SchemaFramed::new(&prefetch, JsonCodec).register::<Invoice>("invoices-value");
1189///     let broker = KafkaBroker::new(["localhost:9092"])
1190///         .default_group("billing-svc")
1191///         .schema_prefetch(prefetch);
1192///     // The scope's codec decodes every delivery; the reply names it again, at its own position.
1193///     let replies = codec.clone();
1194///     RustStream::new(AppInfo::new("billing", "0.1.0")).with_broker_codec(broker, codec, |b| {
1195///         b.include(invoice).out_reply(Publish::default()).codec(replies);
1196///     })
1197/// }
1198/// # let _ = app;
1199/// ```
1200#[derive(Debug, Clone)]
1201pub struct SchemaFramed<C> {
1202    registry: SchemaRegistry,
1203    /// Subject by message type, keyed by the name serde gives it - the same shape and the same
1204    /// key the Avro codec uses, and publish-only for the same reason: decoding strips the
1205    /// envelope and hands the bytes on, so it needs to know no type at all.
1206    subjects: Arc<HashMap<&'static str, String>>,
1207    on_missing: MissingSubject,
1208    prefetch: SchemaPrefetch,
1209    inner: C,
1210}
1211
1212impl<C> SchemaFramed<C> {
1213    /// Frames what [`register`](Self::register) records, with `inner` producing the payload.
1214    ///
1215    /// Construction is synchronous and does no I/O; the subjects are resolved when the broker
1216    /// connects.
1217    #[must_use]
1218    pub fn new(prefetch: &SchemaPrefetch, inner: C) -> Self {
1219        Self {
1220            registry: prefetch.registry().clone(),
1221            subjects: Arc::new(HashMap::new()),
1222            on_missing: prefetch.missing_subject(),
1223            prefetch: prefetch.clone(),
1224            inner,
1225        }
1226    }
1227
1228    /// Records that values of `T` publish under `subject`.
1229    ///
1230    /// The JSON Schema of `T` is captured with it, via `schemars`, for the one thing that needs
1231    /// it: putting the subject back under [`MissingSubject::AutoRegister`]. A self-describing
1232    /// payload under this envelope is a JSON Schema subject in practice, which is why that is
1233    /// the bound; a wrapper over some other self-describing codec registers its subject
1234    /// separately and this one still frames it.
1235    ///
1236    /// # Panics
1237    ///
1238    /// Panics when two registered types share a serde name, which would make the subject of a
1239    /// publish ambiguous - a startup failure by design, since the alternative is writing records
1240    /// under the wrong subject. Rename one with `#[serde(rename = "..")]`.
1241    #[must_use]
1242    pub fn register<T: serde::Serialize + JsonSchema>(
1243        mut self,
1244        subject: impl Into<String>,
1245    ) -> Self {
1246        let subject = subject.into();
1247        let name: &'static str = String::leak(T::schema_name().to_string());
1248        let definition = serde_json::to_string(
1249            &schemars::SchemaGenerator::default().into_root_schema_for::<T>(),
1250        )
1251        .unwrap_or_else(|err| panic!("the JSON Schema of the type under {subject:?}: {err}"));
1252
1253        let table = Arc::get_mut(&mut self.subjects).expect("not yet shared with a mount");
1254        if let Some(existing) = table.insert(name, subject.clone())
1255            && existing != subject
1256        {
1257            panic!(
1258                "two types registered on this codec are both called {name:?} to serde, so a \
1259                 publish could not tell {existing:?} from {subject:?}; give one of them a \
1260                 distinct `#[serde(rename = \"..\")]`"
1261            );
1262        }
1263        self.prefetch.record(Registration {
1264            subject,
1265            schema_type: SchemaType::Json,
1266            definition,
1267        });
1268        self
1269    }
1270
1271    /// The codec whose payloads travel inside the envelope.
1272    pub fn inner(&self) -> &C {
1273        &self.inner
1274    }
1275}
1276
1277impl<C: Codec> Codec for SchemaFramed<C> {
1278    fn encode<T: serde::Serialize>(&self, value: &T) -> Result<BytesMut, CodecError> {
1279        let name = serde_name(value).ok_or_else(|| {
1280            CodecError::Encode(Box::new(KafkaError::malformed(
1281                "this codec frames by the name serde gives a value's type, and this value has \
1282                 none - a bare number, a sequence or a map. Publish a named struct, or use a \
1283                 codec that needs no subject"
1284                    .to_owned(),
1285            )))
1286        })?;
1287        let subject = self.subjects.get(name).ok_or_else(|| {
1288            CodecError::Encode(Box::new(KafkaError::malformed(format!(
1289                "no subject is registered for messages named {name:?}, so this codec has no id \
1290                 to frame them with; add `.register::<{name}>(\"..\")` to it. The types it does \
1291                 carry are {:?}",
1292                self.subjects.keys().collect::<Vec<_>>(),
1293            ))))
1294        })?;
1295        let datum = self.inner.encode(value)?;
1296        let Some(schema) = self.registry.cached_subject(subject) else {
1297            if self.on_missing == MissingSubject::PublishUnframed {
1298                return Ok(datum);
1299            }
1300            return Err(CodecError::Encode(Box::new(KafkaError::malformed(
1301                format!(
1302                    "no schema is cached for subject {subject:?}, which messages named {name:?} \
1303                 publish under. Attach the SchemaPrefetch this codec was built from to the \
1304                 broker (KafkaBroker::schema_prefetch) so connect resolves it; if it was \
1305                 resolved and has since gone, the subject was deleted from the registry and \
1306                 MissingSubject says what to do about that"
1307                ),
1308            ))));
1309        };
1310        let mut framed = BytesMut::with_capacity(1 + 4 + datum.len());
1311        framed.extend_from_slice(&[WIRE_MAGIC]);
1312        framed.extend_from_slice(&schema.id().to_be_bytes());
1313        framed.extend_from_slice(&datum);
1314        Ok(framed)
1315    }
1316
1317    fn decode<T: serde::de::DeserializeOwned>(&self, bytes: &[u8]) -> Result<T, CodecError> {
1318        // A payload with no envelope is refused rather than passed through: on a registry-backed
1319        // topic an unframed record is a producer that did not frame, and reading it anyway would
1320        // make that look like it worked.
1321        let (_, datum) = parse_envelope(bytes).ok_or_else(|| {
1322            CodecError::Decode(Box::new(KafkaError::malformed(format!(
1323                "the delivery does not carry the Confluent wire format (a zero magic byte and a \
1324                 4-byte schema id) that a registry-backed topic publishes under; its first bytes \
1325                 are {:02x?}",
1326                &bytes[..bytes.len().min(8)],
1327            ))))
1328        })?;
1329        self.inner.decode(datum)
1330    }
1331}
1332
1333/// The topic-to-subject half a publish-side framing layer needs: the naming strategy, the
1334/// per-topic pins, and the memory of which subjects the registry does not hold.
1335///
1336/// Every framing layer has to answer the same question - "what schema does this topic publish
1337/// under, and is there one at all" - and only differs in what it then does with the answer, so
1338/// the question is answered in one place. The miss in particular is remembered here, which is
1339/// what keeps a topic that is not registry-backed from paying a round-trip per publish.
1340#[derive(Clone)]
1341pub(crate) struct SubjectMap {
1342    registry: SchemaRegistry,
1343    strategy: SubjectStrategy,
1344    /// Subjects pinned per topic, overriding the strategy.
1345    pins: HashMap<String, String>,
1346    /// Subjects the registry answered 404 for: their topics publish un-framed without
1347    /// re-querying.
1348    skipped: Arc<Mutex<HashSet<String>>>,
1349}
1350
1351impl SubjectMap {
1352    pub(crate) fn new(registry: SchemaRegistry) -> Self {
1353        Self {
1354            registry,
1355            strategy: SubjectStrategy::default(),
1356            pins: HashMap::new(),
1357            skipped: Arc::new(Mutex::new(HashSet::new())),
1358        }
1359    }
1360
1361    pub(crate) fn registry(&self) -> &SchemaRegistry {
1362        &self.registry
1363    }
1364
1365    pub(crate) fn strategy(&self) -> SubjectStrategy {
1366        self.strategy
1367    }
1368
1369    pub(crate) fn set_strategy(&mut self, strategy: SubjectStrategy) {
1370        self.strategy = strategy;
1371    }
1372
1373    pub(crate) fn pins(&self) -> &HashMap<String, String> {
1374        &self.pins
1375    }
1376
1377    pub(crate) fn pin(&mut self, topic: String, subject: String) {
1378        self.pins.insert(topic, subject);
1379    }
1380
1381    /// The subject `topic` publishes under.
1382    pub(crate) fn subject_for(&self, topic: &str) -> String {
1383        self.pins
1384            .get(topic)
1385            .cloned()
1386            .unwrap_or_else(|| self.strategy.subject(topic, ""))
1387    }
1388
1389    /// The schema `topic` publishes under, or `None` when the registry has no such subject.
1390    ///
1391    /// A miss is remembered (and logged once), so a topic that is not registry-backed publishes
1392    /// untouched without re-querying; a subject registered later is picked up after a restart or
1393    /// an explicit [`SchemaRegistry::warm`].
1394    ///
1395    /// # Panics
1396    ///
1397    /// Panics when the internal mutex is poisoned, which requires a prior panic inside this
1398    /// type (an invariant violation, not an operational failure).
1399    pub(crate) async fn schema_for(
1400        &self,
1401        topic: &str,
1402    ) -> Result<Option<Arc<RegisteredSchema>>, KafkaError> {
1403        let subject = self.subject_for(topic);
1404        if let Some(schema) = self.registry.cached_subject(&subject) {
1405            return Ok(Some(schema));
1406        }
1407        if self.is_skipped(&subject) {
1408            return Ok(None);
1409        }
1410        let Some(schema) = self.registry.latest(&subject).await? else {
1411            self.skip(topic, &subject);
1412            return Ok(None);
1413        };
1414        Ok(Some(schema))
1415    }
1416
1417    fn is_skipped(&self, subject: &str) -> bool {
1418        self.skipped
1419            .lock()
1420            .expect("skipped-subjects mutex poisoned")
1421            .contains(subject)
1422    }
1423
1424    /// Remembers (and logs, once) that `subject` is unregistered, so `topic` publishes
1425    /// un-framed without re-querying the registry.
1426    fn skip(&self, topic: &str, subject: &str) {
1427        let mut skipped = self
1428            .skipped
1429            .lock()
1430            .expect("skipped-subjects mutex poisoned");
1431        if skipped.insert(subject.to_owned()) {
1432            tracing::info!(
1433                target: "ruststream_rdkafka",
1434                topic,
1435                subject,
1436                "no schema registered for the topic's subject; its publishes go out un-framed",
1437            );
1438        }
1439    }
1440}
1441
1442/// Publish middleware framing outgoing JSON in the Confluent wire format - the publish-side
1443/// half of the registry integration, added app-wide with `RustStream::publish_layer`.
1444///
1445/// For every publish it resolves the destination topic's value subject (lazily: the shared
1446/// cache first, the registry once on a miss) and frames the codec's plain-JSON payload by
1447/// the subject's registered flavor: JSON keeps its bytes under the envelope, Avro and
1448/// Protobuf transcode against the schema (with their cargo features). A topic with no
1449/// registered subject publishes untouched - mixed registry/plain topologies need no
1450/// configuration. The miss is cached (and logged) once per subject, so plain topics pay no
1451/// per-publish registry round-trip; a subject registered later is picked up after a restart
1452/// or an explicit [`SchemaRegistry::warm`]. A registry outage or a payload that does not fit
1453/// the schema fails the publish, so a publishing handler nacks and the delivery is retried
1454/// rather than a mis-framed record reaching the topic.
1455///
1456/// Subjects follow [`SubjectStrategy::TopicName`] by default;
1457/// [`subject_strategy`](Self::subject_strategy) switches the naming, and
1458/// [`subject`](Self::subject) pins a topic's subject explicitly (the `RecordName` layouts,
1459/// where the topic alone cannot name the subject).
1460///
1461/// # Examples
1462///
1463/// ```
1464/// use ruststream_rdkafka::prelude::*;
1465/// use ruststream_rdkafka::{SchemaFrame, SchemaRegistry};
1466/// use serde::{Deserialize, Serialize};
1467///
1468/// #[derive(Deserialize)]
1469/// struct Order {
1470///     id: u64,
1471/// }
1472///
1473/// #[derive(Serialize, Outgoing)]
1474/// #[outgoing(name = "confirmations")]
1475/// struct Confirmation {
1476///     id: u64,
1477/// }
1478///
1479/// /// Returns plain JSON; the layer frames it for `confirmations-value` on the way out.
1480/// #[subscriber("orders", reply)]
1481/// async fn confirm(order: &Order) -> Confirmation {
1482///     Confirmation { id: order.id }
1483/// }
1484///
1485/// fn app() -> impl App {
1486///     let registry = SchemaRegistry::new("http://localhost:8081");
1487///     RustStream::new(AppInfo::new("orders", "0.1.0"))
1488///         .publish_layer(SchemaFrame::new(registry))
1489///         .with_broker(
1490///             KafkaBroker::new(["localhost:9092"]).default_group("orders-svc"),
1491///             |b| {
1492///                 b.include(confirm);
1493///             },
1494///         )
1495/// }
1496/// # let _ = app;
1497/// ```
1498#[derive(Clone)]
1499pub struct SchemaFrame {
1500    subjects: SubjectMap,
1501    /// Per-topic Protobuf message names (fully qualified), for multi-message schemas.
1502    messages: HashMap<String, String>,
1503}
1504
1505impl fmt::Debug for SchemaFrame {
1506    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1507        f.debug_struct("SchemaFrame")
1508            .field("strategy", &self.subjects.strategy())
1509            .field("subjects", self.subjects.pins())
1510            .finish_non_exhaustive()
1511    }
1512}
1513
1514impl SchemaFrame {
1515    /// Builds the framing middleware over `registry`. Subjects default to the Confluent
1516    /// `TopicName` strategy (`{topic}-value`).
1517    #[must_use]
1518    pub fn new(registry: SchemaRegistry) -> Self {
1519        Self {
1520            subjects: SubjectMap::new(registry),
1521            messages: HashMap::new(),
1522        }
1523    }
1524
1525    /// How destination topics map onto subjects (default:
1526    /// [`SubjectStrategy::TopicName`]). The `RecordName` strategies need the record's name,
1527    /// which the publish path does not know; pin those subjects per topic with
1528    /// [`subject`](Self::subject) instead.
1529    #[must_use]
1530    pub fn subject_strategy(mut self, strategy: SubjectStrategy) -> Self {
1531        self.subjects.set_strategy(strategy);
1532        self
1533    }
1534
1535    /// Pins `topic`'s subject explicitly, overriding the strategy.
1536    #[must_use]
1537    pub fn subject(mut self, topic: impl Into<String>, subject: impl Into<String>) -> Self {
1538        self.subjects.pin(topic.into(), subject.into());
1539        self
1540    }
1541
1542    /// Pins the Protobuf message `topic`'s payloads serialize as, by fully qualified name
1543    /// (package included) - for subjects whose `.proto` schema declares several messages.
1544    /// Without it a Protobuf subject serializes as the schema's first top-level message.
1545    #[must_use]
1546    pub fn message(mut self, topic: impl Into<String>, message: impl Into<String>) -> Self {
1547        self.messages.insert(topic.into(), message.into());
1548        self
1549    }
1550
1551    /// Frames `out`'s payload in place when its topic's subject is registered.
1552    async fn frame(&self, out: &mut Outgoing<'_>) -> Result<(), KafkaError> {
1553        let topic = out.name().to_owned();
1554        let Some(schema) = self.subjects.schema_for(&topic).await? else {
1555            return Ok(());
1556        };
1557        let message = self.messages.get(&topic).map(String::as_str);
1558        let datum =
1559            outgoing_json_to_datum(self.subjects.registry(), &schema, message, out.payload())?;
1560        let framed = encode_envelope(schema.id, &datum);
1561        let payload = out.payload_mut();
1562        payload.clear();
1563        payload.extend_from_slice(&framed);
1564        Ok(())
1565    }
1566}
1567
1568impl PublishLayer for SchemaFrame {
1569    async fn on_publish<'a, N: PublishPipeline, P: Publisher>(
1570        &'a self,
1571        out: &'a mut Outgoing<'a>,
1572        next: PublishNext<'a, N, P>,
1573    ) -> Result<(), Box<dyn StdError + Send + Sync>> {
1574        self.frame(out).await?;
1575        next.run(out).await
1576    }
1577}
1578
1579/// Splits a Confluent-framed payload into its schema id and the datum after the envelope.
1580///
1581/// Returns `None` for payloads that do not carry the wire format (no zero magic byte or too
1582/// short), which callers treat as "not registry-framed" rather than an error.
1583///
1584/// # Panics
1585///
1586/// Does not panic: the id slice is length-checked before conversion (the internal `expect`
1587/// is unreachable).
1588#[must_use]
1589pub fn parse_envelope(payload: &[u8]) -> Option<(u32, &[u8])> {
1590    let (&magic, rest) = payload.split_first()?;
1591    if magic != WIRE_MAGIC || rest.len() < 4 {
1592        return None;
1593    }
1594    let (id_bytes, datum) = rest.split_at(4);
1595    let id = u32::from_be_bytes(id_bytes.try_into().expect("4-byte slice"));
1596    Some((id, datum))
1597}
1598
1599/// Frames `datum` with the Confluent wire format for `schema_id`.
1600#[must_use]
1601pub fn encode_envelope(schema_id: u32, datum: &[u8]) -> Vec<u8> {
1602    let mut framed = Vec::with_capacity(1 + 4 + datum.len());
1603    framed.push(WIRE_MAGIC);
1604    framed.extend_from_slice(&schema_id.to_be_bytes());
1605    framed.extend_from_slice(datum);
1606    framed
1607}
1608
1609#[cfg(test)]
1610mod tests {
1611    use super::*;
1612
1613    #[test]
1614    fn envelope_roundtrips() {
1615        let framed = encode_envelope(1234, b"datum");
1616        let (id, datum) = parse_envelope(&framed).expect("framed");
1617        assert_eq!(id, 1234);
1618        assert_eq!(datum, b"datum");
1619    }
1620
1621    #[test]
1622    fn non_framed_payloads_are_recognized() {
1623        assert!(parse_envelope(b"").is_none());
1624        assert!(parse_envelope(b"{\"json\":1}").is_none());
1625        assert!(parse_envelope(&[0, 1, 2]).is_none(), "short id");
1626    }
1627
1628    #[tokio::test]
1629    async fn frame_wraps_registered_json_subjects() {
1630        use wiremock::matchers::{method, path};
1631        use wiremock::{Mock, MockServer, ResponseTemplate};
1632
1633        let server = MockServer::start().await;
1634        Mock::given(method("GET"))
1635            .and(path("/subjects/orders-value/versions/latest"))
1636            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1637                "id": 11,
1638                "version": 1,
1639                "schema": "{\"type\":\"object\"}",
1640                "schemaType": "JSON",
1641            })))
1642            .expect(1)
1643            .mount(&server)
1644            .await;
1645
1646        let frame = SchemaFrame::new(SchemaRegistry::new(server.uri()));
1647        let json = br#"{"id":7}"#;
1648        let mut out = Outgoing::new("orders", json.as_slice());
1649        frame.frame(&mut out).await.expect("frame");
1650        let (id, datum) = parse_envelope(out.payload()).expect("framed");
1651        assert_eq!(id, 11);
1652        assert_eq!(datum, json);
1653
1654        // The second publish resolves from the cache (expect(1) verifies).
1655        let mut again = Outgoing::new("orders", json.as_slice());
1656        frame.frame(&mut again).await.expect("frame cached");
1657        assert_eq!(again.payload(), out.payload());
1658    }
1659
1660    #[tokio::test]
1661    async fn unregistered_subjects_pass_through_and_cache_the_miss() {
1662        use wiremock::matchers::{method, path};
1663        use wiremock::{Mock, MockServer, ResponseTemplate};
1664
1665        let server = MockServer::start().await;
1666        Mock::given(method("GET"))
1667            .and(path("/subjects/plain-value/versions/latest"))
1668            .respond_with(ResponseTemplate::new(404))
1669            .expect(1)
1670            .mount(&server)
1671            .await;
1672
1673        let frame = SchemaFrame::new(SchemaRegistry::new(server.uri()));
1674        let json = br#"{"plain":true}"#;
1675        for _ in 0..2 {
1676            // The miss is remembered: the registry sees one query (expect(1) verifies).
1677            let mut out = Outgoing::new("plain", json.as_slice());
1678            frame.frame(&mut out).await.expect("pass through");
1679            assert_eq!(out.payload(), json, "un-framed");
1680        }
1681    }
1682
1683    #[tokio::test]
1684    async fn registry_outages_fail_the_publish() {
1685        use wiremock::matchers::{method, path};
1686        use wiremock::{Mock, MockServer, ResponseTemplate};
1687
1688        let server = MockServer::start().await;
1689        Mock::given(method("GET"))
1690            .and(path("/subjects/orders-value/versions/latest"))
1691            .respond_with(ResponseTemplate::new(500))
1692            .mount(&server)
1693            .await;
1694
1695        let frame = SchemaFrame::new(SchemaRegistry::new(server.uri()));
1696        let mut out = Outgoing::new("orders", br#"{"id":7}"#.as_slice());
1697        let err = frame.frame(&mut out).await.expect_err("outage");
1698        assert!(matches!(err, KafkaError::SchemaRegistry(_)));
1699    }
1700
1701    #[tokio::test]
1702    async fn pinned_subjects_override_the_strategy() {
1703        use wiremock::matchers::{method, path};
1704        use wiremock::{Mock, MockServer, ResponseTemplate};
1705
1706        let server = MockServer::start().await;
1707        Mock::given(method("GET"))
1708            .and(path("/subjects/com.acme.Order/versions/latest"))
1709            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1710                "id": 3,
1711                "version": 1,
1712                "schema": "{\"type\":\"object\"}",
1713                "schemaType": "JSON",
1714            })))
1715            .expect(1)
1716            .mount(&server)
1717            .await;
1718
1719        let frame =
1720            SchemaFrame::new(SchemaRegistry::new(server.uri())).subject("orders", "com.acme.Order");
1721        let mut out = Outgoing::new("orders", br#"{"id":1}"#.as_slice());
1722        frame.frame(&mut out).await.expect("frame");
1723        let (id, _) = parse_envelope(out.payload()).expect("framed");
1724        assert_eq!(id, 3);
1725    }
1726
1727    /// The startup check asks the question registration asks: the subject's whole history under
1728    /// a transitive level, not only its latest version. The registry answers that at
1729    /// `/versions`; `/versions/latest` compares with one version whatever the level says.
1730    #[tokio::test]
1731    async fn the_startup_check_asks_what_registration_would() {
1732        use wiremock::matchers::{method, path, query_param};
1733        use wiremock::{Mock, MockServer, ResponseTemplate};
1734
1735        let server = MockServer::start().await;
1736        Mock::given(method("GET"))
1737            .and(path("/subjects/orders-value/versions/latest"))
1738            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1739                "id": 21,
1740                "version": 3,
1741                "schema": "{\"type\":\"object\"}",
1742                "schemaType": "JSON",
1743            })))
1744            .mount(&server)
1745            .await;
1746        Mock::given(method("POST"))
1747            .and(path("/compatibility/subjects/orders-value/versions"))
1748            .and(query_param("verbose", "true"))
1749            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1750                "is_compatible": false,
1751                "messages": ["incompatible with version 1"],
1752            })))
1753            .expect(1)
1754            .mount(&server)
1755            .await;
1756
1757        let prefetch = SchemaPrefetch::new(SchemaRegistry::new(server.uri()));
1758        prefetch.record(Registration {
1759            subject: "orders-value".to_owned(),
1760            schema_type: SchemaType::Json,
1761            definition: "{\"type\":\"object\"}".to_owned(),
1762        });
1763        let err = prefetch
1764            .warm_subjects()
1765            .await
1766            .expect_err("a schema the subject's history refuses must not start");
1767        assert!(
1768            err.to_string().contains("incompatible with version 1"),
1769            "the registry's own account travels in the error: {err}",
1770        );
1771    }
1772
1773    #[test]
1774    fn subject_strategies_name_confluent_style() {
1775        assert_eq!(
1776            SubjectStrategy::TopicName.subject("orders", "com.acme.Order"),
1777            "orders-value"
1778        );
1779        assert_eq!(
1780            SubjectStrategy::RecordName.subject("orders", "com.acme.Order"),
1781            "com.acme.Order"
1782        );
1783        assert_eq!(
1784            SubjectStrategy::TopicRecordName.subject("orders", "com.acme.Order"),
1785            "orders-com.acme.Order"
1786        );
1787    }
1788}