use std::fmt;
use std::sync::Arc;
use std::time::Duration;
use futures::future::BoxFuture;
use serde::Deserialize;
use super::{RegisteredSchema, SchemaType};
use crate::error::KafkaError;
pub(super) const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
#[diagnostic::on_unimplemented(
message = "`{Self}` is not a schema registry client",
note = "a client answers the four registry questions asynchronously: a schema by id, a \
subject's latest version, registering a schema, and looking one up without \
registering it"
)]
pub trait RegistryClient: Send + Sync + 'static {
fn schema_by_id(&self, id: u32) -> BoxFuture<'_, Result<Arc<RegisteredSchema>, KafkaError>>;
fn latest(
&self,
subject: &str,
) -> BoxFuture<'_, Result<Option<Arc<RegisteredSchema>>, KafkaError>>;
fn register(
&self,
subject: &str,
schema_type: SchemaType,
definition: String,
) -> BoxFuture<'_, Result<u32, KafkaError>>;
fn lookup_id(
&self,
subject: &str,
schema_type: SchemaType,
definition: String,
) -> BoxFuture<'_, Result<u32, KafkaError>>;
fn is_compatible(
&self,
subject: &str,
schema_type: SchemaType,
definition: String,
) -> BoxFuture<'_, Result<Option<bool>, KafkaError>> {
let _ = (subject, schema_type, definition);
Box::pin(async { Ok(None) })
}
}
#[derive(Clone)]
pub(super) enum Auth {
None,
Basic { user: String, password: String },
Bearer(String),
}
#[derive(Clone)]
pub(super) struct HttpConfig {
pub(super) base_url: String,
pub(super) auth: Auth,
pub(super) timeout: Duration,
}
impl HttpConfig {
pub(super) fn new(base_url: String) -> Self {
Self {
base_url,
auth: Auth::None,
timeout: DEFAULT_REQUEST_TIMEOUT,
}
}
}
#[derive(Deserialize)]
struct SchemaByIdResponse {
schema: String,
#[serde(rename = "schemaType")]
schema_type: Option<String>,
}
#[derive(Deserialize)]
struct RegisterResponse {
id: u32,
}
#[derive(Deserialize)]
struct CompatibilityResponse {
is_compatible: bool,
#[serde(default)]
messages: Vec<String>,
}
#[derive(Deserialize)]
struct LatestVersionResponse {
id: u32,
schema: String,
#[serde(rename = "schemaType")]
schema_type: Option<String>,
}
pub struct HttpRegistryClient {
config: HttpConfig,
http: reqwest::Client,
}
impl fmt::Debug for HttpRegistryClient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("HttpRegistryClient")
.field("base_url", &self.config.base_url)
.field("request_timeout", &self.config.timeout)
.finish_non_exhaustive()
}
}
impl HttpRegistryClient {
pub(super) fn new(config: HttpConfig) -> Self {
Self {
config,
http: reqwest::Client::new(),
}
}
fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
let url = format!("{}{path}", self.config.base_url);
let request = self.http.request(method, url).timeout(self.config.timeout);
match &self.config.auth {
Auth::None => request,
Auth::Basic { user, password } => request.basic_auth(user, Some(password)),
Auth::Bearer(token) => request.bearer_auth(token),
}
}
fn failed(&self, path: &str, err: reqwest::Error) -> KafkaError {
if err.is_timeout() {
return KafkaError::SchemaRegistryTimeout {
request: path.to_owned(),
timeout: self.config.timeout,
};
}
KafkaError::schema_registry(err)
}
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(|err| self.failed(path, err))?
.error_for_status()
.map_err(KafkaError::schema_registry)?;
response.json().await.map_err(|err| self.failed(path, err))
}
async fn post_schema(
&self,
path: String,
schema_type: SchemaType,
definition: String,
missing: Option<String>,
) -> Result<u32, KafkaError> {
let body = serde_json::json!({
"schema": definition,
"schemaType": schema_type.as_api(),
});
let response = self
.request(reqwest::Method::POST, &path)
.json(&body)
.send()
.await
.map_err(|err| self.failed(&path, err))?;
if let Some(message) = missing
&& response.status() == reqwest::StatusCode::NOT_FOUND
{
return Err(KafkaError::InvalidOptions(message));
}
let response = response
.error_for_status()
.map_err(KafkaError::schema_registry)?;
let registered: RegisterResponse = response
.json()
.await
.map_err(|err| self.failed(&path, err))?;
Ok(registered.id)
}
}
impl RegistryClient for HttpRegistryClient {
fn schema_by_id(&self, id: u32) -> BoxFuture<'_, Result<Arc<RegisteredSchema>, KafkaError>> {
Box::pin(async move {
let fetched: SchemaByIdResponse = self.get_json(&format!("/schemas/ids/{id}")).await?;
Ok(Arc::new(RegisteredSchema::new(
id,
SchemaType::from_api(fetched.schema_type.as_deref()),
fetched.schema,
)))
})
}
fn latest(
&self,
subject: &str,
) -> BoxFuture<'_, Result<Option<Arc<RegisteredSchema>>, KafkaError>> {
let path = format!("/subjects/{subject}/versions/latest");
Box::pin(async move {
let response = self
.request(reqwest::Method::GET, &path)
.send()
.await
.map_err(|err| self.failed(&path, err))?;
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(|err| self.failed(&path, err))?;
Ok(Some(Arc::new(RegisteredSchema::new(
fetched.id,
SchemaType::from_api(fetched.schema_type.as_deref()),
fetched.schema,
))))
})
}
fn register(
&self,
subject: &str,
schema_type: SchemaType,
definition: String,
) -> BoxFuture<'_, Result<u32, KafkaError>> {
let path = format!("/subjects/{subject}/versions");
Box::pin(self.post_schema(path, schema_type, definition, None))
}
fn lookup_id(
&self,
subject: &str,
schema_type: SchemaType,
definition: String,
) -> BoxFuture<'_, Result<u32, KafkaError>> {
let path = format!("/subjects/{subject}");
let missing = format!(
"the registry holds no such schema under subject {subject:?}; register it there \
first (the typed shorthands do it in one call) or point the producer at the \
subject that carries this schema",
);
Box::pin(self.post_schema(path, schema_type, definition, Some(missing)))
}
fn is_compatible(
&self,
subject: &str,
schema_type: SchemaType,
definition: String,
) -> BoxFuture<'_, Result<Option<bool>, KafkaError>> {
let path = format!("/compatibility/subjects/{subject}/versions?verbose=true");
let subject = subject.to_owned();
Box::pin(async move {
let body = serde_json::json!({
"schema": definition,
"schemaType": schema_type.as_api(),
});
let response = self
.request(reqwest::Method::POST, &path)
.json(&body)
.send()
.await
.map_err(|err| self.failed(&path, err))?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
let response = response
.error_for_status()
.map_err(KafkaError::schema_registry)?;
let verdict: CompatibilityResponse = response
.json()
.await
.map_err(|err| self.failed(&path, err))?;
if !verdict.is_compatible {
return Err(KafkaError::SchemaRegistry(
format!(
"the schema a codec publishes under subject {subject:?} is not \
compatible with the versions the subject's compatibility level \
checks it against: {}",
verdict.messages.join(" "),
)
.into(),
));
}
Ok(Some(true))
})
}
}