use ruststream::asyncapi::{Binding, Bindings};
use serde::Serialize;
const BINDING_VERSION: &str = "0.5.0";
fn kafka<T: Serialize>(body: &T) -> Bindings {
Binding::new("kafka", BINDING_VERSION, body)
.map_or_else(|_| Bindings::new(), |binding| Bindings::new().with(binding))
}
#[derive(Debug, Serialize)]
struct Constant {
#[serde(rename = "type")]
kind: &'static str,
#[serde(rename = "enum")]
values: [String; 1],
}
impl Constant {
fn new(value: &str) -> Self {
Self {
kind: "string",
values: [value.to_owned()],
}
}
}
#[derive(Debug, Serialize)]
struct Server {
#[serde(rename = "schemaRegistryUrl")]
schema_registry_url: String,
#[serde(rename = "schemaRegistryVendor")]
schema_registry_vendor: &'static str,
}
#[derive(Debug, Serialize)]
struct Channel<'a> {
topic: &'a str,
}
#[derive(Debug, Serialize)]
struct Operation {
#[serde(rename = "groupId", skip_serializing_if = "Option::is_none")]
group_id: Option<Constant>,
#[serde(rename = "clientId", skip_serializing_if = "Option::is_none")]
client_id: Option<Constant>,
}
#[cfg(feature = "schema-registry")]
#[derive(Debug, Serialize)]
struct Message {
#[serde(rename = "schemaIdLocation")]
id_location: &'static str,
#[serde(rename = "schemaIdPayloadEncoding")]
id_payload_encoding: &'static str,
#[serde(rename = "schemaLookupStrategy")]
lookup_strategy: &'static str,
}
pub(crate) fn server(registry_url: Option<&str>) -> Bindings {
let Some(url) = registry_url else {
return Bindings::new();
};
kafka(&Server {
schema_registry_url: without_userinfo(url),
schema_registry_vendor: "confluent",
})
}
pub(crate) fn channel(topic: &str) -> Bindings {
kafka(&Channel { topic })
}
pub(crate) fn operation(group: Option<&str>, config: &[(String, String)]) -> Bindings {
let client_id = config
.iter()
.rev()
.find(|(key, _)| key == "client.id")
.map(|(_, value)| Constant::new(value));
let group_id = group.map(Constant::new);
if group_id.is_none() && client_id.is_none() {
return Bindings::new();
}
kafka(&Operation {
group_id,
client_id,
})
}
#[cfg(feature = "schema-registry")]
pub(crate) fn message(strategy: crate::schema_registry::SubjectStrategy) -> Bindings {
use crate::schema_registry::SubjectStrategy;
kafka(&Message {
id_location: "payload",
id_payload_encoding: "confluent",
lookup_strategy: match strategy {
SubjectStrategy::TopicName => "TopicNameStrategy",
SubjectStrategy::RecordName => "RecordNameStrategy",
SubjectStrategy::TopicRecordName => "TopicRecordNameStrategy",
},
})
}
fn without_userinfo(url: &str) -> String {
let (scheme, rest) = url
.split_once("://")
.map_or(("", url), |(scheme, rest)| (scheme, rest));
let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let trimmed = rest[..authority_end].rfind('@').map_or_else(
|| rest.to_owned(),
|at| format!("{}{}", &rest[at + 1..authority_end], &rest[authority_end..]),
);
if scheme.is_empty() {
trimmed
} else {
format!("{scheme}://{trimmed}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_registry_url_reaches_the_document_without_its_password() {
let bindings = server(Some("http://svc:hunter2@registry.internal:8081"));
let json = serde_json::to_string(&bindings).expect("bindings serialize");
assert!(
json.contains("http://registry.internal:8081"),
"the coordinate must survive, got: {json}"
);
assert!(
!json.contains("hunter2"),
"a published document must not carry the registry password, got: {json}"
);
}
#[test]
fn a_url_without_userinfo_is_reported_as_it_stands() {
assert_eq!(
without_userinfo("https://registry.internal:8081/apis/ccompat/v7"),
"https://registry.internal:8081/apis/ccompat/v7",
);
}
#[test]
fn a_descriptor_that_names_neither_a_group_nor_a_client_says_nothing() {
assert_eq!(operation(None, &[]), Bindings::new());
}
#[test]
fn the_last_client_id_wins_as_it_does_in_the_consumer_config() {
let config = [
("client.id".to_owned(), "first".to_owned()),
("client.id".to_owned(), "last".to_owned()),
];
let json = serde_json::to_string(&operation(Some("orders-svc"), &config))
.expect("bindings serialize");
assert!(json.contains("last"), "got: {json}");
assert!(!json.contains("first"), "got: {json}");
}
}