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 = ®istration.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}