use std::collections::{HashMap, HashSet};
use std::error::Error as StdError;
use std::fmt;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use ruststream::runtime::{Outgoing, PublishLayer, PublishNext, PublishPipeline};
use serde::Deserialize;
pub use schemars::JsonSchema;
use crate::error::KafkaError;
const WIRE_MAGIC: u8 = 0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SchemaType {
Avro,
Protobuf,
Json,
}
impl SchemaType {
fn as_api(self) -> &'static str {
match self {
Self::Avro => "AVRO",
Self::Protobuf => "PROTOBUF",
Self::Json => "JSON",
}
}
fn from_api(value: Option<&str>) -> Self {
match value {
Some("PROTOBUF") => Self::Protobuf,
Some("JSON") => Self::Json,
_ => Self::Avro,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RegisteredSchema {
id: u32,
schema_type: SchemaType,
definition: String,
}
impl RegisteredSchema {
#[must_use]
pub fn id(&self) -> u32 {
self.id
}
#[must_use]
pub fn schema_type(&self) -> SchemaType {
self.schema_type
}
#[must_use]
pub fn definition(&self) -> &str {
&self.definition
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum SubjectStrategy {
#[default]
TopicName,
RecordName,
TopicRecordName,
}
impl SubjectStrategy {
#[must_use]
pub fn subject(self, topic: &str, record: &str) -> String {
match self {
Self::TopicName => format!("{topic}-value"),
Self::RecordName => record.to_owned(),
Self::TopicRecordName => format!("{topic}-{record}"),
}
}
}
enum Auth {
None,
Basic { user: String, password: String },
Bearer(String),
}
struct RegistryInner {
base_url: String,
http: reqwest::Client,
auth: Auth,
by_id: Mutex<HashMap<u32, Arc<RegisteredSchema>>>,
by_subject: Mutex<HashMap<String, u32>>,
#[cfg(feature = "avro")]
parsed_avro: Mutex<HashMap<u32, Arc<apache_avro::Schema>>>,
#[cfg(feature = "protobuf")]
parsed_proto: Mutex<HashMap<u32, Arc<prost_reflect::DescriptorPool>>>,
}
#[derive(Clone)]
pub struct SchemaRegistry {
inner: Arc<RegistryInner>,
}
impl fmt::Debug for SchemaRegistry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SchemaRegistry")
.field("base_url", &self.inner.base_url)
.finish_non_exhaustive()
}
}
#[derive(Deserialize)]
struct SchemaByIdResponse {
schema: String,
#[serde(rename = "schemaType")]
schema_type: Option<String>,
}
#[derive(Deserialize)]
struct RegisterResponse {
id: u32,
}
#[derive(Deserialize)]
struct LatestVersionResponse {
id: u32,
schema: String,
#[serde(rename = "schemaType")]
schema_type: Option<String>,
}
impl SchemaRegistry {
#[must_use]
pub fn new(base_url: impl Into<String>) -> Self {
let mut base_url = base_url.into();
while base_url.ends_with('/') {
base_url.pop();
}
Self {
inner: Arc::new(RegistryInner {
base_url,
http: reqwest::Client::new(),
auth: Auth::None,
by_id: Mutex::new(HashMap::new()),
by_subject: Mutex::new(HashMap::new()),
#[cfg(feature = "avro")]
parsed_avro: Mutex::new(HashMap::new()),
#[cfg(feature = "protobuf")]
parsed_proto: Mutex::new(HashMap::new()),
}),
}
}
#[must_use]
pub fn basic_auth(self, user: impl Into<String>, password: impl Into<String>) -> Self {
self.with_auth(Auth::Basic {
user: user.into(),
password: password.into(),
})
}
#[must_use]
pub fn bearer_token(self, token: impl Into<String>) -> Self {
self.with_auth(Auth::Bearer(token.into()))
}
fn with_auth(self, auth: Auth) -> Self {
Self {
inner: Arc::new(RegistryInner {
base_url: self.inner.base_url.clone(),
http: self.inner.http.clone(),
auth,
by_id: Mutex::new(HashMap::new()),
by_subject: Mutex::new(HashMap::new()),
#[cfg(feature = "avro")]
parsed_avro: Mutex::new(HashMap::new()),
#[cfg(feature = "protobuf")]
parsed_proto: Mutex::new(HashMap::new()),
}),
}
}
fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
let url = format!("{}{path}", self.inner.base_url);
let request = self.inner.http.request(method, url);
match &self.inner.auth {
Auth::None => request,
Auth::Basic { user, password } => request.basic_auth(user, Some(password)),
Auth::Bearer(token) => request.bearer_auth(token),
}
}
async fn get_json<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T, KafkaError> {
let response = self
.request(reqwest::Method::GET, path)
.send()
.await
.map_err(KafkaError::schema_registry)?;
let response = response
.error_for_status()
.map_err(KafkaError::schema_registry)?;
response.json().await.map_err(KafkaError::schema_registry)
}
pub async fn schema_by_id(&self, id: u32) -> Result<Arc<RegisteredSchema>, KafkaError> {
if let Some(schema) = self.cached_schema(id) {
return Ok(schema);
}
let fetched: SchemaByIdResponse = self.get_json(&format!("/schemas/ids/{id}")).await?;
let schema = Arc::new(RegisteredSchema {
id,
schema_type: SchemaType::from_api(fetched.schema_type.as_deref()),
definition: fetched.schema,
});
self.inner
.by_id
.lock()
.expect("schema cache mutex poisoned")
.insert(id, Arc::clone(&schema));
Ok(schema)
}
pub async fn register(
&self,
subject: &str,
schema_type: SchemaType,
definition: impl Into<String>,
) -> Result<u32, KafkaError> {
let definition = definition.into();
let body = serde_json::json!({
"schema": definition,
"schemaType": schema_type.as_api(),
});
let response = self
.request(
reqwest::Method::POST,
&format!("/subjects/{subject}/versions"),
)
.json(&body)
.send()
.await
.map_err(KafkaError::schema_registry)?
.error_for_status()
.map_err(KafkaError::schema_registry)?;
let registered: RegisterResponse =
response.json().await.map_err(KafkaError::schema_registry)?;
let schema = Arc::new(RegisteredSchema {
id: registered.id,
schema_type,
definition,
});
self.cache(subject, &schema);
Ok(registered.id)
}
pub async fn warm(&self, subject: &str) -> Result<Arc<RegisteredSchema>, KafkaError> {
let fetched: LatestVersionResponse = self
.get_json(&format!("/subjects/{subject}/versions/latest"))
.await?;
let schema = Arc::new(RegisteredSchema {
id: fetched.id,
schema_type: SchemaType::from_api(fetched.schema_type.as_deref()),
definition: fetched.schema,
});
self.cache(subject, &schema);
Ok(schema)
}
pub(crate) async fn latest(
&self,
subject: &str,
) -> Result<Option<Arc<RegisteredSchema>>, KafkaError> {
let response = self
.request(
reqwest::Method::GET,
&format!("/subjects/{subject}/versions/latest"),
)
.send()
.await
.map_err(KafkaError::schema_registry)?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
let response = response
.error_for_status()
.map_err(KafkaError::schema_registry)?;
let fetched: LatestVersionResponse =
response.json().await.map_err(KafkaError::schema_registry)?;
let schema = Arc::new(RegisteredSchema {
id: fetched.id,
schema_type: SchemaType::from_api(fetched.schema_type.as_deref()),
definition: fetched.schema,
});
self.cache(subject, &schema);
Ok(Some(schema))
}
fn cache(&self, subject: &str, schema: &Arc<RegisteredSchema>) {
self.inner
.by_id
.lock()
.expect("schema cache mutex poisoned")
.insert(schema.id, Arc::clone(schema));
self.inner
.by_subject
.lock()
.expect("subject cache mutex poisoned")
.insert(subject.to_owned(), schema.id);
}
#[must_use]
pub fn cached_schema(&self, id: u32) -> Option<Arc<RegisteredSchema>> {
self.inner
.by_id
.lock()
.expect("schema cache mutex poisoned")
.get(&id)
.cloned()
}
#[must_use]
pub fn cached_subject(&self, subject: &str) -> Option<Arc<RegisteredSchema>> {
let id = *self
.inner
.by_subject
.lock()
.expect("subject cache mutex poisoned")
.get(subject)?;
self.cached_schema(id)
}
pub async fn register_json<T: JsonSchema>(&self, subject: &str) -> Result<u32, KafkaError> {
let schema = schemars::SchemaGenerator::default().into_root_schema_for::<T>();
let definition = serde_json::to_string(&schema).map_err(KafkaError::schema_registry)?;
self.register(subject, SchemaType::Json, definition).await
}
#[cfg(feature = "avro")]
pub async fn register_avro<T: apache_avro::AvroSchema>(
&self,
subject: &str,
) -> Result<u32, KafkaError> {
let schema = T::get_schema();
self.register(subject, SchemaType::Avro, schema.canonical_form())
.await
}
#[cfg(feature = "avro")]
pub(crate) fn parsed_avro(
&self,
schema: &RegisteredSchema,
) -> Result<Arc<apache_avro::Schema>, KafkaError> {
if let Some(parsed) = self
.inner
.parsed_avro
.lock()
.expect("parsed schema cache mutex poisoned")
.get(&schema.id)
{
return Ok(Arc::clone(parsed));
}
let parsed = Arc::new(
apache_avro::Schema::parse_str(&schema.definition)
.map_err(KafkaError::schema_registry)?,
);
self.inner
.parsed_avro
.lock()
.expect("parsed schema cache mutex poisoned")
.insert(schema.id, Arc::clone(&parsed));
Ok(parsed)
}
#[cfg(feature = "protobuf")]
pub(crate) fn parsed_proto(
&self,
schema: &RegisteredSchema,
) -> Result<Arc<prost_reflect::DescriptorPool>, KafkaError> {
if let Some(parsed) = self
.inner
.parsed_proto
.lock()
.expect("descriptor cache mutex poisoned")
.get(&schema.id)
{
return Ok(Arc::clone(parsed));
}
let pool = compile_proto(&schema.definition).map_err(KafkaError::schema_registry)?;
let pool = Arc::new(pool);
self.inner
.parsed_proto
.lock()
.expect("descriptor cache mutex poisoned")
.insert(schema.id, Arc::clone(&pool));
Ok(pool)
}
pub(crate) async fn incoming_to_json(&self, payload: &[u8]) -> Option<Vec<u8>> {
let (id, datum) = parse_envelope(payload)?;
let schema = match self.schema_by_id(id).await {
Ok(schema) => schema,
Err(err) => {
tracing::warn!(
target: "ruststream_rdkafka",
schema_id = id,
error = %err,
"schema fetch failed; the delivery passes through un-transcoded until \
the registry recovers",
);
return None;
}
};
let transcoded = incoming_datum_to_json(self, &schema, datum);
match transcoded {
Ok(json) => Some(json),
Err(err) => {
tracing::warn!(
target: "ruststream_rdkafka",
schema_id = id,
error = %err,
"transcoding a framed delivery failed; the delivery passes through \
un-transcoded",
);
None
}
}
}
}
#[cfg(feature = "protobuf")]
fn compile_proto(source: &str) -> Result<prost_reflect::DescriptorPool, protox::Error> {
use protox::file::{ChainFileResolver, File, FileResolver, GoogleFileResolver};
struct Single(String);
impl FileResolver for Single {
fn open_file(&self, name: &str) -> Result<File, protox::Error> {
if name == "registry.proto" {
File::from_source(name, &self.0)
} else {
Err(protox::Error::file_not_found(name))
}
}
}
let mut resolver = ChainFileResolver::new();
resolver.add(GoogleFileResolver::new());
resolver.add(Single(source.to_owned()));
let mut compiler = protox::Compiler::with_file_resolver(resolver);
compiler.include_imports(true);
compiler.open_file("registry.proto")?;
let set = compiler.file_descriptor_set();
prost_reflect::DescriptorPool::from_file_descriptor_set(set)
.map_err(|err| protox::Error::new(err.to_string()))
}
fn incoming_datum_to_json(
registry: &SchemaRegistry,
schema: &RegisteredSchema,
datum: &[u8],
) -> Result<Vec<u8>, KafkaError> {
let _ = registry;
match schema.schema_type {
SchemaType::Json => Ok(datum.to_vec()),
#[cfg(feature = "avro")]
SchemaType::Avro => crate::avro::avro_to_json(registry, schema, datum),
#[cfg(feature = "protobuf")]
SchemaType::Protobuf => crate::protobuf::protobuf_to_json(registry, schema, datum),
#[allow(unreachable_patterns)] other => Err(KafkaError::InvalidOptions(format!(
"schema id {} is {other:?}, but the matching cargo feature is not enabled on \
ruststream-rdkafka; enable it to consume this topic",
schema.id,
))),
}
}
fn outgoing_json_to_datum(
registry: &SchemaRegistry,
schema: &RegisteredSchema,
message: Option<&str>,
payload: &[u8],
) -> Result<Vec<u8>, KafkaError> {
let _ = (registry, message);
match schema.schema_type {
SchemaType::Json => Ok(payload.to_vec()),
#[cfg(feature = "avro")]
SchemaType::Avro => crate::avro::json_to_avro(registry, schema, payload),
#[cfg(feature = "protobuf")]
SchemaType::Protobuf => {
crate::protobuf::json_to_protobuf(registry, schema, message, payload)
}
#[allow(unreachable_patterns)] other => Err(KafkaError::InvalidOptions(format!(
"the subject's schema (id {}) is {other:?}, but the matching cargo feature is \
not enabled on ruststream-rdkafka; enable it to publish to this topic",
schema.id,
))),
}
}
#[derive(Clone)]
pub struct SchemaFrame {
registry: SchemaRegistry,
strategy: SubjectStrategy,
subjects: HashMap<String, String>,
messages: HashMap<String, String>,
skipped: Arc<Mutex<HashSet<String>>>,
}
impl fmt::Debug for SchemaFrame {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SchemaFrame")
.field("strategy", &self.strategy)
.field("subjects", &self.subjects)
.finish_non_exhaustive()
}
}
impl SchemaFrame {
#[must_use]
pub fn new(registry: SchemaRegistry) -> Self {
Self {
registry,
strategy: SubjectStrategy::default(),
subjects: HashMap::new(),
messages: HashMap::new(),
skipped: Arc::new(Mutex::new(HashSet::new())),
}
}
#[must_use]
pub fn subject_strategy(mut self, strategy: SubjectStrategy) -> Self {
self.strategy = strategy;
self
}
#[must_use]
pub fn subject(mut self, topic: impl Into<String>, subject: impl Into<String>) -> Self {
self.subjects.insert(topic.into(), subject.into());
self
}
#[must_use]
pub fn message(mut self, topic: impl Into<String>, message: impl Into<String>) -> Self {
self.messages.insert(topic.into(), message.into());
self
}
fn subject_for(&self, topic: &str) -> String {
self.subjects
.get(topic)
.cloned()
.unwrap_or_else(|| self.strategy.subject(topic, ""))
}
fn is_skipped(&self, subject: &str) -> bool {
self.skipped
.lock()
.expect("skipped-subjects mutex poisoned")
.contains(subject)
}
fn skip(&self, topic: &str, subject: &str) {
let mut skipped = self
.skipped
.lock()
.expect("skipped-subjects mutex poisoned");
if skipped.insert(subject.to_owned()) {
tracing::info!(
target: "ruststream_rdkafka",
topic,
subject,
"no schema registered for the topic's subject; its publishes go out un-framed",
);
}
}
async fn frame(&self, out: &mut Outgoing<'_>) -> Result<(), KafkaError> {
let topic = out.name().to_owned();
let subject = self.subject_for(&topic);
let cached = self.registry.cached_subject(&subject);
let schema = if let Some(schema) = cached {
schema
} else {
if self.is_skipped(&subject) {
return Ok(());
}
let Some(schema) = self.registry.latest(&subject).await? else {
self.skip(&topic, &subject);
return Ok(());
};
schema
};
let message = self.messages.get(&topic).map(String::as_str);
let datum = outgoing_json_to_datum(&self.registry, &schema, message, out.payload())?;
let framed = encode_envelope(schema.id, &datum);
let payload = out.payload_mut();
payload.clear();
payload.extend_from_slice(&framed);
Ok(())
}
}
impl PublishLayer for SchemaFrame {
fn on_publish<'a, N: PublishPipeline>(
&'a self,
out: &'a mut Outgoing<'a>,
next: PublishNext<'a, N>,
) -> Pin<Box<dyn Future<Output = Result<(), Box<dyn StdError + Send + Sync>>> + Send + 'a>>
{
Box::pin(async move {
self.frame(out).await?;
next.run(out).await
})
}
}
#[must_use]
pub fn parse_envelope(payload: &[u8]) -> Option<(u32, &[u8])> {
let (&magic, rest) = payload.split_first()?;
if magic != WIRE_MAGIC || rest.len() < 4 {
return None;
}
let (id_bytes, datum) = rest.split_at(4);
let id = u32::from_be_bytes(id_bytes.try_into().expect("4-byte slice"));
Some((id, datum))
}
#[must_use]
pub fn encode_envelope(schema_id: u32, datum: &[u8]) -> Vec<u8> {
let mut framed = Vec::with_capacity(1 + 4 + datum.len());
framed.push(WIRE_MAGIC);
framed.extend_from_slice(&schema_id.to_be_bytes());
framed.extend_from_slice(datum);
framed
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn envelope_roundtrips() {
let framed = encode_envelope(1234, b"datum");
let (id, datum) = parse_envelope(&framed).expect("framed");
assert_eq!(id, 1234);
assert_eq!(datum, b"datum");
}
#[test]
fn non_framed_payloads_are_recognized() {
assert!(parse_envelope(b"").is_none());
assert!(parse_envelope(b"{\"json\":1}").is_none());
assert!(parse_envelope(&[0, 1, 2]).is_none(), "short id");
}
#[tokio::test]
async fn frame_wraps_registered_json_subjects() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/subjects/orders-value/versions/latest"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 11,
"version": 1,
"schema": "{\"type\":\"object\"}",
"schemaType": "JSON",
})))
.expect(1)
.mount(&server)
.await;
let frame = SchemaFrame::new(SchemaRegistry::new(server.uri()));
let json = br#"{"id":7}"#;
let mut out = Outgoing::new("orders", json.as_slice());
frame.frame(&mut out).await.expect("frame");
let (id, datum) = parse_envelope(out.payload()).expect("framed");
assert_eq!(id, 11);
assert_eq!(datum, json);
let mut again = Outgoing::new("orders", json.as_slice());
frame.frame(&mut again).await.expect("frame cached");
assert_eq!(again.payload(), out.payload());
}
#[tokio::test]
async fn unregistered_subjects_pass_through_and_cache_the_miss() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/subjects/plain-value/versions/latest"))
.respond_with(ResponseTemplate::new(404))
.expect(1)
.mount(&server)
.await;
let frame = SchemaFrame::new(SchemaRegistry::new(server.uri()));
let json = br#"{"plain":true}"#;
for _ in 0..2 {
let mut out = Outgoing::new("plain", json.as_slice());
frame.frame(&mut out).await.expect("pass through");
assert_eq!(out.payload(), json, "un-framed");
}
}
#[tokio::test]
async fn registry_outages_fail_the_publish() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/subjects/orders-value/versions/latest"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let frame = SchemaFrame::new(SchemaRegistry::new(server.uri()));
let mut out = Outgoing::new("orders", br#"{"id":7}"#.as_slice());
let err = frame.frame(&mut out).await.expect_err("outage");
assert!(matches!(err, KafkaError::SchemaRegistry(_)));
}
#[tokio::test]
async fn pinned_subjects_override_the_strategy() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/subjects/com.acme.Order/versions/latest"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 3,
"version": 1,
"schema": "{\"type\":\"object\"}",
"schemaType": "JSON",
})))
.expect(1)
.mount(&server)
.await;
let frame =
SchemaFrame::new(SchemaRegistry::new(server.uri())).subject("orders", "com.acme.Order");
let mut out = Outgoing::new("orders", br#"{"id":1}"#.as_slice());
frame.frame(&mut out).await.expect("frame");
let (id, _) = parse_envelope(out.payload()).expect("framed");
assert_eq!(id, 3);
}
#[test]
fn subject_strategies_name_confluent_style() {
assert_eq!(
SubjectStrategy::TopicName.subject("orders", "com.acme.Order"),
"orders-value"
);
assert_eq!(
SubjectStrategy::RecordName.subject("orders", "com.acme.Order"),
"com.acme.Order"
);
assert_eq!(
SubjectStrategy::TopicRecordName.subject("orders", "com.acme.Order"),
"orders-com.acme.Order"
);
}
}