use std::io::{self, Result};
use std::time::Duration;
use async_trait::async_trait;
use tonic::transport::{Certificate, ClientTlsConfig, Endpoint};
use tonic_health::pb::health_check_response::ServingStatus;
use tonic_health::pb::health_client::HealthClient;
use tonic_health::pb::HealthCheckRequest;
use crate::pinger::Pinger;
use crate::uri::get_uri;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
pub struct GrpcPinger {
pub endpoint: String,
pub service: String,
pub timeout: Duration,
ca_cert_pem: Option<Vec<u8>>,
}
impl GrpcPinger {
pub fn new(endpoint: impl Into<String>) -> Self {
Self {
endpoint: endpoint.into(),
service: String::new(),
timeout: DEFAULT_TIMEOUT,
ca_cert_pem: None,
}
}
pub fn with_service(mut self, service: impl Into<String>) -> Self {
self.service = service.into();
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn with_ca_cert(mut self, pem: impl Into<Vec<u8>>) -> Self {
self.ca_cert_pem = Some(pem.into());
self
}
}
#[async_trait]
impl Pinger for GrpcPinger {
async fn ping(&self) -> Result<()> {
let url = normalize_endpoint(&self.endpoint)?;
let uri = get_uri(&self.endpoint);
let domain = uri.domain.clone();
let mut endpoint = Endpoint::from_shared(url)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e.to_string()))?
.timeout(self.timeout)
.connect_timeout(self.timeout);
let scheme = uri.scheme.to_ascii_lowercase();
let is_tls = scheme == "grpcs" || scheme == "https";
if is_tls {
let mut tls = ClientTlsConfig::new().with_webpki_roots();
if let Some(pem) = &self.ca_cert_pem {
tls = ClientTlsConfig::new().ca_certificate(Certificate::from_pem(pem.clone()));
}
if !domain.is_empty() {
tls = tls.domain_name(domain);
}
endpoint = endpoint
.tls_config(tls)
.map_err(|e| io::Error::other(format!("tonic tls_config: {e}")))?;
}
let channel = endpoint
.connect()
.await
.map_err(|e| io::Error::other(format!("connect: {e}")))?;
let mut client = HealthClient::new(channel);
let req = HealthCheckRequest {
service: self.service.clone(),
};
let resp = client
.check(req)
.await
.map_err(|status| io::Error::other(format!("Health/Check: {status}")))?
.into_inner();
if resp.status != ServingStatus::Serving as i32 {
return Err(io::Error::other(format!(
"gRPC server returned status {} (expected SERVING=1)",
resp.status
)));
}
Ok(())
}
}
fn normalize_endpoint(input: &str) -> Result<String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"empty gRPC endpoint",
));
}
if let Some(rest) = trimmed.strip_prefix("grpcs://") {
Ok(format!("https://{rest}"))
} else if let Some(rest) = trimmed.strip_prefix("grpc://") {
Ok(format!("http://{rest}"))
} else if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
Ok(trimmed.to_string())
} else {
Ok(format!("http://{trimmed}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_endpoint_passes_http_through() {
assert_eq!(
normalize_endpoint("http://localhost:50051").unwrap(),
"http://localhost:50051"
);
}
#[test]
fn normalize_endpoint_passes_https_through() {
assert_eq!(
normalize_endpoint("https://broker:443").unwrap(),
"https://broker:443"
);
}
#[test]
fn normalize_endpoint_rewrites_grpc_to_http() {
assert_eq!(
normalize_endpoint("grpc://localhost:50051").unwrap(),
"http://localhost:50051"
);
}
#[test]
fn normalize_endpoint_rewrites_grpcs_to_https() {
assert_eq!(
normalize_endpoint("grpcs://broker:443").unwrap(),
"https://broker:443"
);
}
#[test]
fn normalize_endpoint_defaults_schemeless_to_http() {
assert_eq!(
normalize_endpoint("localhost:50051").unwrap(),
"http://localhost:50051"
);
}
#[test]
fn normalize_endpoint_rejects_empty() {
assert!(normalize_endpoint("").is_err());
assert!(normalize_endpoint(" ").is_err());
}
}