pcs-external 0.3.0

Ppoppo Chat System (PCS) External API client -- gRPC client for the External Developer Platform
Documentation
//! Path-prefix-aware tonic channel for GKE Ingress routing.
//!
//! `ExternalChannel` wraps `tonic::transport::Channel` and prepends a
//! path prefix (e.g. `/ext`) to every outgoing gRPC method path, enabling
//! path-based Ingress rules on GKE. When the API URL has no path component,
//! requests pass through unchanged.
//!
//! This module is private to `pcs-external`; consumers interact with the
//! SDK through [`crate::PcsExternalClient`], not this transport directly.

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;

use tonic::transport::Channel;

use crate::error::Error;

/// A gRPC channel that prepends an optional path prefix to all requests.
///
/// When the API URL includes a path (e.g. `https://api.ppoppo.com/ext`),
/// all gRPC method paths become `/ext/chat.external.Foo/Method`. When the
/// URL has no path (e.g. `https://api.ppoppo.com`), requests are unchanged.
#[derive(Clone, Debug)]
pub(crate) struct ExternalChannel {
    inner: Channel,
    prefix: String,
}

impl ExternalChannel {
    /// Connect to `api_url` and extract the optional path prefix.
    ///
    /// Default timeouts: 10 s connect, 30 s request.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Transport`] on DNS/TLS/connect failures.
    /// Returns [`Error::InvalidPathPrefix`] if the path component of the URL
    /// cannot be prepended to a gRPC method path.
    pub(crate) async fn connect(api_url: &str) -> Result<Self, 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()
        };

        // Pre-validate the prefix at connect time so `Service::call` never
        // silently falls back to an unprefixed URI.
        if !prefix.is_empty() {
            let probe = format!("{prefix}/chat.external.SmokeTest/Method");
            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}")
        };

        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 ExternalChannel {
    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) {
            // Pre-validated at connect(). If we somehow get here, send a
            // known-bad path so the server returns a deterministic 404
            // rather than silently routing to the wrong backend.
            *req.uri_mut() = "/__pcs_external_invalid_path__"
                .parse()
                .unwrap_or_else(|_| req.uri().clone());
        }
        let fut = tower_service::Service::call(&mut self.inner, req);
        Box::pin(fut)
    }
}

fn prepend_path_prefix(req: &mut http::Request<tonic::body::Body>, 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
}