use std::{
collections::HashMap,
net::{Ipv4Addr, Ipv6Addr},
path::{Path, PathBuf},
time::{Duration, Instant},
};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use greptimedb_proto::v1::{self, auth_header::AuthScheme};
use crate::{
error::{Error, Result},
failover::FailoverConfig,
hints::{
DEFAULT_CATALOG, DEFAULT_SCHEMA, GREPTIME_AUTH_HEADER, GREPTIME_DB_HEADER,
GREPTIME_HINTS_HEADER, WriteHints,
},
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TransportScheme {
Grpc,
Grpcs,
Unix,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Endpoint {
scheme: TransportScheme,
host: String,
port: u16,
unix_path: Option<PathBuf>,
}
impl Endpoint {
pub fn parse(raw: &str) -> Result<Self> {
let invalid = || Error::InvalidConfig("invalid endpoint address".into());
if raw.is_empty() || raw.chars().any(|ch| ch.is_whitespace() || ch.is_control()) {
return Err(invalid());
}
let (scheme, address) = match raw.split_once("://") {
Some(("grpc" | "http", address)) => (TransportScheme::Grpc, address),
Some(("grpcs" | "https", address)) => (TransportScheme::Grpcs, address),
Some(("unix", address)) => (TransportScheme::Unix, address),
Some(_) => return Err(Error::InvalidConfig("unsupported endpoint scheme".into())),
None => (TransportScheme::Grpc, raw),
};
if scheme == TransportScheme::Unix {
if !address.starts_with('/') || address.contains(['?', '#']) || address == "/" {
return Err(invalid());
}
return Ok(Self {
scheme,
host: String::new(),
port: 4001,
unix_path: Some(PathBuf::from(address)),
});
}
if address.contains(['/', '?', '#', '@', '\\']) {
return Err(invalid());
}
let (host, port) = if let Some(rest) = address.strip_prefix('[') {
let (host, suffix) = rest.split_once(']').ok_or_else(invalid)?;
host.parse::<Ipv6Addr>().map_err(|_| invalid())?;
let port = if suffix.is_empty() {
4001
} else {
parse_port(suffix.strip_prefix(':').ok_or_else(invalid)?)?
};
(host, port)
} else {
let (host, port) = match address.split_once(':') {
Some((host, port)) => (host, parse_port(port)?),
None => (address, 4001),
};
validate_host(host)?;
(host, port)
};
Ok(Self {
scheme,
host: host.to_owned(),
port,
unix_path: None,
})
}
#[must_use]
pub const fn scheme(&self) -> TransportScheme {
self.scheme
}
#[must_use]
pub fn host(&self) -> &str {
&self.host
}
#[must_use]
pub const fn port(&self) -> u16 {
self.port
}
#[must_use]
pub fn socket_addr_str(&self) -> String {
if let Some(path) = &self.unix_path {
return path.to_string_lossy().into_owned();
}
if self.host.contains(':') {
format!("[{}]:{}", self.host, self.port)
} else {
format!("{}:{}", self.host, self.port)
}
}
#[must_use]
pub fn unix_path(&self) -> Option<&Path> {
self.unix_path.as_deref()
}
#[must_use]
pub fn authority(&self) -> String {
if self.scheme == TransportScheme::Unix {
"localhost".into()
} else {
self.socket_addr_str()
}
}
}
fn parse_port(raw: &str) -> Result<u16> {
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(Error::InvalidConfig(
"endpoint port must be a decimal integer".into(),
));
}
match raw.parse::<u16>() {
Ok(port) if port != 0 => Ok(port),
_ => Err(Error::InvalidConfig(
"endpoint port must be between 1 and 65535".into(),
)),
}
}
fn validate_host(host: &str) -> Result<()> {
let invalid = || Error::InvalidConfig("invalid endpoint host".into());
if host.parse::<Ipv4Addr>().is_ok() {
return Ok(());
}
let dns = host.strip_suffix('.').unwrap_or(host);
if dns.is_empty() || dns.len() > 253 {
return Err(invalid());
}
for label in dns.split('.') {
if label.is_empty()
|| label.len() > 63
|| label.starts_with('-')
|| label.ends_with('-')
|| !label
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
{
return Err(invalid());
}
}
if dns.contains('.')
&& dns
.bytes()
.all(|byte| byte.is_ascii_digit() || byte == b'.')
{
return Err(invalid());
}
Ok(())
}
#[derive(Clone, PartialEq, Eq)]
pub enum Auth {
Basic {
username: String,
password: String,
},
Token(String),
}
impl std::fmt::Debug for Auth {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Basic { username, .. } => formatter
.debug_struct("Basic")
.field("username", username)
.field("password", &"<redacted>")
.finish(),
Self::Token(_) => formatter.debug_tuple("Token").field(&"<redacted>").finish(),
}
}
}
impl Auth {
#[must_use]
pub fn basic(user: impl Into<String>, pass: impl Into<String>) -> Self {
Self::Basic {
username: user.into(),
password: pass.into(),
}
}
#[must_use]
pub fn token(token: impl Into<String>) -> Self {
Self::Token(token.into())
}
#[must_use]
pub fn to_metadata_value(&self) -> String {
match self {
Self::Basic { username, password } => {
format!(
"Basic {}",
STANDARD.encode(format!("{username}:{password}"))
)
}
Self::Token(token) => format!("Bearer {token}"),
}
}
#[must_use]
pub fn to_proto_auth_header(&self) -> v1::AuthHeader {
let auth_scheme = match self {
Self::Basic { username, password } => AuthScheme::Basic(v1::Basic {
username: username.clone(),
password: password.clone(),
}),
Self::Token(token) => AuthScheme::Token(v1::Token {
token: token.clone(),
}),
};
v1::AuthHeader {
auth_scheme: Some(auth_scheme),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Compression {
#[default]
Identity,
Gzip,
Deflate,
Snappy,
Zstd,
Lz4,
}
impl Compression {
#[must_use]
pub const fn to_grpc(self) -> Option<grpc::CompressionAlgorithm> {
Some(match self {
Self::Identity => grpc::CompressionAlgorithm::Identity,
Self::Gzip => grpc::CompressionAlgorithm::Gzip,
Self::Deflate => grpc::CompressionAlgorithm::Deflate,
Self::Snappy => grpc::CompressionAlgorithm::Snappy,
Self::Zstd => grpc::CompressionAlgorithm::Zstd,
Self::Lz4 => grpc::CompressionAlgorithm::Lz4,
})
}
}
#[derive(Clone, Debug)]
pub struct ClientOptions {
pub endpoints: Vec<Endpoint>,
pub failover: Option<FailoverConfig>,
pub catalog: String,
pub schema: String,
pub auth: Option<Auth>,
pub timezone: Option<String>,
pub write_hints: WriteHints,
pub compression: Compression,
pub connect_timeout: Duration,
pub request_timeout: Duration,
pub tcp_nodelay: bool,
pub max_in_flight: usize,
#[cfg(feature = "tls")]
pub tls: Option<crate::tls::TlsOptions>,
}
impl ClientOptions {
#[must_use]
pub fn new(endpoints: Vec<Endpoint>) -> Self {
Self {
endpoints,
failover: None,
catalog: DEFAULT_CATALOG.into(),
schema: DEFAULT_SCHEMA.into(),
auth: None,
timezone: None,
write_hints: WriteHints::new(),
compression: Compression::Identity,
connect_timeout: Duration::from_secs(5),
request_timeout: Duration::from_secs(30),
tcp_nodelay: true,
max_in_flight: 8,
#[cfg(feature = "tls")]
tls: None,
}
}
#[must_use]
pub fn from_endpoint(endpoint: Endpoint) -> Self {
Self::new(vec![endpoint])
}
#[must_use]
pub fn with_failover(mut self, config: FailoverConfig) -> Self {
self.failover = Some(config);
self
}
#[must_use]
pub fn with_catalog(mut self, catalog: impl Into<String>) -> Self {
self.catalog = catalog.into();
self
}
#[must_use]
pub fn with_schema(mut self, schema: impl Into<String>) -> Self {
self.schema = schema.into();
self
}
#[must_use]
pub fn with_dbname(mut self, dbname: impl Into<String>) -> Self {
let dbname = dbname.into();
if let Some((catalog, schema)) = dbname.split_once('-') {
self.catalog = catalog.into();
self.schema = schema.into();
} else {
self.schema = dbname;
}
self
}
#[must_use]
pub fn dbname(&self) -> String {
if self.catalog.is_empty() {
self.schema.clone()
} else {
format!("{}-{}", self.catalog, self.schema)
}
}
#[must_use]
pub fn build_request_header(
&self,
tracing_context: Option<HashMap<String, String>>,
) -> v1::RequestHeader {
v1::RequestHeader {
catalog: self.catalog.clone(),
schema: self.schema.clone(),
authorization: self.auth.as_ref().map(Auth::to_proto_auth_header),
dbname: self.dbname(),
tracing_context: tracing_context.unwrap_or_default(),
timezone: self.timezone.clone().unwrap_or_default(),
}
}
pub fn validate(&self) -> Result<()> {
if let Some(failover) = &self.failover {
if failover.endpoints.len() != self.endpoints.len() {
return Err(Error::InvalidConfig(
"failover endpoint names must match the configured endpoints".into(),
));
}
let addresses = failover
.endpoints
.iter()
.filter(|name| name.contains(':'))
.map(|name| Endpoint::parse(name))
.collect::<Result<Vec<_>>>();
if let Ok(addresses) = addresses
&& addresses.len() == failover.endpoints.len()
&& addresses != self.endpoints
{
return Err(Error::InvalidConfig(
"failover endpoint addresses must match the configured endpoints in order"
.into(),
));
}
}
if let Some(Auth::Basic { username, password }) = &self.auth {
if username.contains(':') {
return Err(Error::InvalidConfig(
"Basic authentication usernames must not contain a colon".into(),
));
}
if username
.bytes()
.chain(password.bytes())
.any(|byte| byte.is_ascii_control())
{
return Err(Error::InvalidConfig(
"Basic authentication credentials must not contain ASCII control characters"
.into(),
));
}
}
if self.endpoints.is_empty() || self.schema.is_empty() || self.max_in_flight == 0 {
return Err(Error::InvalidConfig(
"endpoints, schema, and in-flight capacity must be nonempty".into(),
));
}
for timeout in [self.connect_timeout, self.request_timeout] {
if timeout.is_zero() || Instant::now().checked_add(timeout).is_none() {
return Err(Error::InvalidConfig(
"timeouts must be nonzero and fit a monotonic deadline".into(),
));
}
}
grpc::core::format_grpc_timeout(self.request_timeout).map_err(|_| {
Error::InvalidConfig("request timeout exceeds the gRPC wire limit".into())
})?;
Ok(())
}
pub fn try_build_call_options(
&self,
extra_hints: Option<&WriteHints>,
) -> Result<grpc::CallOptions> {
self.validate()?;
let mut metadata = grpc::Metadata::new();
let mut append = |key: &str, value: &str| {
metadata.append_ascii(key, value).map_err(|_| {
Error::InvalidConfig(format!("invalid ASCII metadata value for {key}"))
})
};
append(GREPTIME_DB_HEADER, &self.dbname())?;
if let Some(auth) = &self.auth {
append(GREPTIME_AUTH_HEADER, &auth.to_metadata_value())?;
}
let hints = match extra_hints {
Some(extra) => self.write_hints.merged_with(extra),
None => self.write_hints.clone(),
};
if let Some(encoded) = hints.encode_header_value() {
append(GREPTIME_HINTS_HEADER, &encoded)?;
}
Ok(grpc::CallOptions {
metadata,
timeout: Some(self.request_timeout),
compression: self.compression.to_grpc(),
})
}
#[must_use]
pub fn build_call_options(&self, extra_hints: Option<&WriteHints>) -> grpc::CallOptions {
match self.try_build_call_options(extra_hints) {
Ok(options) => options,
Err(error) => panic!("cannot build GreptimeDB call options: {error}"),
}
}
}