use std::time::Duration;
use serde::{Deserialize, Serialize};
use tonic::{
Request, Status, async_trait,
metadata::{MetadataKey, MetadataValue},
service::{Interceptor, interceptor::InterceptedService},
transport::Channel,
};
use tracing::info;
use crate::{
error::{ChapatyResult, TransportError},
generated::chapaty::bq_exporter::v1::exporter_service_client::ExporterServiceClient,
};
pub type ChapatyClient = ExporterServiceClient<InterceptedService<Channel, ApiKeyInterceptor>>;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct EndpointUrl(pub String); impl_from_primitive!(EndpointUrl, String);
impl From<&str> for EndpointUrl {
fn from(value: &str) -> Self {
Self(value.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Credential(pub String);
impl_from_primitive!(Credential, String);
impl From<&str> for Credential {
fn from(value: &str) -> Self {
Self(value.to_string())
}
}
#[async_trait]
pub trait Connect {
async fn connect(&self) -> ChapatyResult<ChapatyClient>;
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostedApi;
#[async_trait]
impl Connect for HostedApi {
#[tracing::instrument(skip(self), err)]
async fn connect(&self) -> ChapatyResult<ChapatyClient> {
let endpoint = std::env::var("CHAPATY_BQEXPORTER_URL")
.unwrap_or_else(|_| "https://bqexporter.chapaty.com".to_string());
let metadata_key = std::env::var("CHAPATY_METADATA_KEY").ok();
let credential = std::env::var("CHAPATY_CREDENTIAL").ok().map(Credential);
create_default_client(endpoint, metadata_key, credential).await
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DefaultGrpcEndpoint {
pub endpoint: EndpointUrl,
pub credential: Option<Credential>,
pub metadata_key: Option<String>,
}
#[async_trait]
impl Connect for DefaultGrpcEndpoint {
#[tracing::instrument(skip(self), fields(endpoint = %self.endpoint.0), err)]
async fn connect(&self) -> ChapatyResult<ChapatyClient> {
create_default_client(
self.endpoint.0.clone(),
self.metadata_key.clone(),
self.credential.clone(),
)
.await
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)]
pub enum DataSource {
#[default]
Hosted,
SelfHosted(DefaultGrpcEndpoint),
}
#[async_trait]
impl Connect for DataSource {
async fn connect(&self) -> ChapatyResult<ChapatyClient> {
match self {
Self::Hosted => HostedApi.connect().await,
Self::SelfHosted(rpc) => rpc.connect().await,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SourceGroup<T, S: Connect = DataSource> {
pub source: S,
pub items: Vec<T>,
}
impl<T, S: Connect> SourceGroup<T, S> {
pub const fn new(source: S) -> Self {
Self {
source,
items: Vec::new(),
}
}
pub fn add(&mut self, item: T) {
self.items.push(item);
}
}
async fn create_default_client(
endpoint: String,
metadata_key: Option<String>,
credential: Option<Credential>,
) -> ChapatyResult<ChapatyClient> {
info!(%endpoint, has_api_key = credential.is_some(), "Establishing gRPC connection");
let channel = Channel::from_shared(endpoint.clone())
.map_err(|_| TransportError::Connection("Invalid URI".into()))?
.http2_keep_alive_interval(Duration::from_secs(30))
.keep_alive_timeout(Duration::from_secs(10))
.keep_alive_while_idle(true)
.timeout(Duration::from_mins(10))
.tcp_keepalive(Some(Duration::from_mins(1)))
.connect_timeout(Duration::from_secs(30))
.initial_connection_window_size(Some(1024 * 1024)) .initial_stream_window_size(Some(1024 * 1024)) .connect()
.await
.map_err(|e| TransportError::Connection(e.to_string()))?;
let interceptor = ApiKeyInterceptor::new(metadata_key, credential);
let client = ExporterServiceClient::with_interceptor(channel, interceptor);
info!(%endpoint, "gRPC connection established with long-running configuration");
Ok(client)
}
#[derive(Clone)]
pub struct ApiKeyInterceptor {
metadata_key: MetadataKey<tonic::metadata::Ascii>,
metadata_value: Option<MetadataValue<tonic::metadata::Ascii>>,
}
impl ApiKeyInterceptor {
#[must_use]
#[expect(
clippy::expect_used,
reason = "a non-ascii token API key is a configuration error. Failing fast here surfaces it immediately at setup"
)]
pub fn new(metadata_key: Option<String>, credential: Option<Credential>) -> Self {
let metadata_key = metadata_key
.and_then(|key| key.parse().ok())
.unwrap_or_else(|| MetadataKey::from_static("api-key"));
let metadata_value = credential.map(|value| {
value
.0
.parse()
.expect("API key contains invalid characters for metadata")
});
Self {
metadata_key,
metadata_value,
}
}
}
impl Interceptor for ApiKeyInterceptor {
fn call(&mut self, mut req: Request<()>) -> Result<Request<()>, Status> {
if let Some(key) = &self.metadata_value {
req.metadata_mut()
.insert(self.metadata_key.clone(), key.clone());
}
Ok(req)
}
}