use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;
use tonic::transport::Channel;
use super::error::Error;
const PREFIX_PROBE_PATH: &str = "/ppoppo.sdk.v1.PrefixProbe/Method";
const INVALID_PATH_SENTINEL: &str = "/__ppoppo_sdk_invalid_path__";
#[derive(Clone, Debug)]
pub struct PathPrefixChannel {
inner: Channel,
prefix: String,
}
impl PathPrefixChannel {
pub async fn connect(api_url: &str) -> Result<Self, Error> {
let (base_url, prefix) = split_prefix(api_url)?;
let endpoint = tonic::transport::Endpoint::from_shared(base_url.clone())
.map_err(|e| Error::Transport(format!("invalid endpoint '{base_url}': {e}")))?
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(30));
let endpoint = if api_url.starts_with("https://") {
endpoint
.tls_config(tonic::transport::ClientTlsConfig::new().with_enabled_roots())
.map_err(|e| Error::Transport(format!("TLS configuration failed: {e}")))?
} else {
endpoint
};
let channel = endpoint
.connect()
.await
.map_err(|e| Error::Transport(format!("failed to connect to '{base_url}': {e}")))?;
Ok(Self { inner: channel, prefix })
}
}
impl tower_service::Service<http::Request<tonic::body::Body>> for PathPrefixChannel {
type Response = http::Response<tonic::body::Body>;
type Error = tonic::transport::Error;
type Future =
Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
tower_service::Service::poll_ready(&mut self.inner, cx)
}
fn call(&mut self, mut req: http::Request<tonic::body::Body>) -> Self::Future {
if !self.prefix.is_empty() && !prepend_path_prefix(&mut req, &self.prefix) {
*req.uri_mut() = INVALID_PATH_SENTINEL.parse().unwrap_or_else(|e| {
tracing::error!(
sentinel = INVALID_PATH_SENTINEL,
error = %e,
"INVALID_PATH_SENTINEL failed to parse; request keeps its \
unprefixed URI and may reach the wrong backend"
);
req.uri().clone()
});
}
let fut = tower_service::Service::call(&mut self.inner, req);
Box::pin(fut)
}
}
fn split_prefix(api_url: &str) -> Result<(String, String), Error> {
let uri: http::Uri = api_url
.parse()
.map_err(|e| Error::Transport(format!("invalid API URL '{api_url}': {e}")))?;
let raw_path = uri.path().trim_end_matches('/');
let prefix = if raw_path.is_empty() || raw_path == "/" {
String::new()
} else {
raw_path.to_string()
};
if !prefix.is_empty() {
let probe = format!("{prefix}{PREFIX_PROBE_PATH}");
probe
.parse::<http::uri::PathAndQuery>()
.map_err(|e| Error::InvalidPathPrefix {
prefix: prefix.clone(),
reason: e.to_string(),
})?;
}
let base_url = if prefix.is_empty() {
api_url.to_string()
} else {
let scheme = uri.scheme_str().unwrap_or("https");
let authority = uri
.authority()
.map(|a| a.as_str())
.ok_or_else(|| Error::Transport(format!("missing authority in URL: {api_url}")))?;
format!("{scheme}://{authority}")
};
Ok((base_url, prefix))
}
fn prepend_path_prefix<B>(req: &mut http::Request<B>, prefix: &str) -> bool {
let pq_str = req
.uri()
.path_and_query()
.map(|pq| pq.as_str())
.unwrap_or("/");
let new_path = format!("{prefix}{pq_str}");
let Ok(new_pq) = new_path.parse::<http::uri::PathAndQuery>() else {
return false;
};
let mut parts = req.uri().clone().into_parts();
parts.path_and_query = Some(new_pq);
let Ok(new_uri) = http::Uri::from_parts(parts) else {
return false;
};
*req.uri_mut() = new_uri;
true
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
fn uri_after_prefix(path: &str, prefix: &str) -> Option<String> {
let mut req = http::Request::builder().uri(path).body(()).ok()?;
prepend_path_prefix(&mut req, prefix).then(|| req.uri().to_string())
}
#[test]
fn prepends_prefix_to_grpc_method_path() {
assert_eq!(
uri_after_prefix("/pas.plims.v1.NumberService/Create", "/plims").as_deref(),
Some("/plims/pas.plims.v1.NumberService/Create"),
);
}
#[test]
fn preserves_query_string() {
assert_eq!(
uri_after_prefix("/chat.external.Svc/Method?page=2", "/ext").as_deref(),
Some("/ext/chat.external.Svc/Method?page=2"),
);
}
#[test]
fn probe_constant_forms_a_valid_path_and_query() {
let probe = format!("/plims{PREFIX_PROBE_PATH}");
assert!(probe.parse::<http::uri::PathAndQuery>().is_ok());
assert!(INVALID_PATH_SENTINEL.parse::<http::Uri>().is_ok());
}
#[test]
fn split_prefix_derives_base_url_and_prefix() {
let cases = [
("http://localhost:3103", "http://localhost:3103", ""),
("http://localhost:3103/", "http://localhost:3103/", ""),
("http://localhost:3103/plims", "http://localhost:3103", "/plims"),
("https://accounts.ppoppo.com/plims", "https://accounts.ppoppo.com", "/plims"),
("https://api.ppoppo.com/ext", "https://api.ppoppo.com", "/ext"),
];
for (url, base, prefix) in cases {
let (got_base, got_prefix) = split_prefix(url).expect("valid URL");
assert_eq!(got_base, base, "base_url for {url}");
assert_eq!(got_prefix, prefix, "prefix for {url}");
}
}
#[test]
fn split_prefix_rejects_path_prefix_without_authority() {
match split_prefix("/plims") {
Err(Error::Transport(msg)) => assert!(msg.contains("missing authority"), "{msg}"),
other => panic!("expected Transport(missing authority), got {other:?}"),
}
}
}