use std::time::Duration;
use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper::Request;
use slog::Logger;
use tokio::net::TcpStream;
const CH_TIMEOUT: Duration = Duration::from_secs(30);
pub(crate) const CH_PREFIX: &str = "ch:";
pub(crate) const CLICKHOUSE_PREFIX: &str = "clickhouse:";
pub(crate) fn queue_target(queue_name: &str) -> Option<&str> {
queue_name.strip_prefix(CH_PREFIX)
.or_else(|| queue_name.strip_prefix(CLICKHOUSE_PREFIX))
.map(|t| t.trim())
.filter(|t| !t.is_empty())
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ChSink {
authority: String,
auth: Option<String>,
db: String,
}
impl ChSink {
pub(crate) fn new(url: &str, db: &str) -> Option<ChSink> {
let url = url.trim();
if url.is_empty() {
return None;
}
let rest = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
let rest = rest.split(['/', '?']).next().unwrap_or(rest);
let (userinfo, hostport) = match rest.rsplit_once('@') {
Some((u, h)) => (Some(u), h),
None => (None, rest),
};
if hostport.is_empty() {
return None;
}
let authority = if hostport.contains(':') {
hostport.to_string()
} else {
format!("{}:8123", hostport) };
let auth = userinfo.map(|u| format!("Basic {}", b64(u.as_bytes())));
let db = match db.trim() {
"" => "default".to_string(),
d => d.to_string(),
};
Some(ChSink { authority, auth, db })
}
fn target(&self, table: &str) -> String {
match table.split_once('.') {
Some((db, t)) => format!("{}.{}", quote_ident(db), quote_ident(t)),
None => format!("{}.{}", quote_ident(&self.db), quote_ident(table)),
}
}
pub(crate) async fn insert(&self, table: &str, rows: &[String], log: &Logger) -> Result<(), String> {
if rows.is_empty() {
return Ok(());
}
let target = self.target(table);
let uri = format!("/?query={}",
urlencode(&format!("INSERT INTO {} FORMAT JSONEachRow", target)));
let stream = TcpStream::connect(&self.authority).await
.map_err(|e| format!("connect {}: {}", self.authority, e))?;
let (mut sender, conn) = hyper::client::conn::http1::handshake(
hyper_util::rt::TokioIo::new(stream)).await
.map_err(|e| format!("handshake: {}", e))?;
let log_c = log.clone();
tokio::spawn(async move {
if let Err(e) = conn.await {
slog::trace!(log_c, "[ch] connection closed: {}", e);
}
});
let mut req = Request::post(&uri).header("Host", self.authority.as_str());
if let Some(a) = &self.auth {
req = req.header("Authorization", a.as_str());
}
let req = req.body(Full::new(Bytes::from(rows.join("\n"))))
.map_err(|e| format!("request: {}", e))?;
let resp = tokio::time::timeout(CH_TIMEOUT, sender.send_request(req)).await
.map_err(|_| format!("timed out after {:?}", CH_TIMEOUT))?
.map_err(|e| format!("send: {}", e))?;
let status = resp.status();
if status.is_success() {
return Ok(());
}
let msg = resp.into_body().collect().await
.map(|b| String::from_utf8_lossy(&b.to_bytes()).trim().to_string())
.unwrap_or_default();
Err(format!("{} {}", status, msg))
}
}
fn quote_ident(id: &str) -> String {
format!("`{}`", id.replace('`', "``"))
}
fn urlencode(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 16);
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' =>
out.push(b as char),
_ => out.push_str(&format!("%{:02X}", b)),
}
}
out
}
fn b64(input: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity((input.len() + 2) / 3 * 4);
for c in input.chunks(3) {
let b = [c[0], *c.get(1).unwrap_or(&0), *c.get(2).unwrap_or(&0)];
let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
out.push(T[(n >> 18 & 63) as usize] as char);
out.push(T[(n >> 12 & 63) as usize] as char);
out.push(if c.len() > 1 { T[(n >> 6 & 63) as usize] as char } else { '=' });
out.push(if c.len() > 2 { T[(n & 63) as usize] as char } else { '=' });
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn queue_name_selects_a_clickhouse_table() {
assert_eq!(queue_target("ch:events"), Some("events"));
assert_eq!(queue_target("clickhouse:events"), Some("events"));
assert_eq!(queue_target("ch:analytics.events"), Some("analytics.events"));
assert_eq!(queue_target("analytics"), None);
assert_eq!(queue_target("ch:"), None);
assert_eq!(queue_target(""), None);
}
#[test]
fn target_uses_the_configured_db_unless_the_name_carries_one() {
let s = ChSink::new("http://h:8123", "").unwrap();
assert_eq!(s.db, "default"); assert_eq!(s.target("events"), "`default`.`events`");
let s = ChSink::new("http://h:8123", "analytics").unwrap();
assert_eq!(s.target("events"), "`analytics`.`events`");
assert_eq!(s.target("other.events"), "`other`.`events`");
assert_eq!(quote_ident("a`b"), "`a``b`");
}
#[test]
fn url_forms_and_basic_auth() {
assert_eq!(ChSink::new("http://127.0.0.1:8123", "").unwrap().authority, "127.0.0.1:8123");
assert_eq!(ChSink::new("ch.lan", "").unwrap().authority, "ch.lan:8123");
assert_eq!(ChSink::new("", ""), None);
let s = ChSink::new("http://user:pw@ch.lan:8123", "").unwrap();
assert_eq!(s.authority, "ch.lan:8123");
assert_eq!(s.auth.as_deref(), Some("Basic dXNlcjpwdw=="));
}
#[test]
fn base64_matches_known_vectors() {
assert_eq!(b64(b"user:pw"), "dXNlcjpwdw==");
assert_eq!(b64(b""), "");
assert_eq!(b64(b"a"), "YQ==");
assert_eq!(b64(b"ab"), "YWI=");
assert_eq!(b64(b"abc"), "YWJj");
}
}