pcs-external 2.0.0

Ppoppo Chat System (PCS) External API client -- gRPC client for the External Developer Platform
Documentation
//! gRPC client for PCS External API.
//!
//! # Usage
//!
//! ```no_run
//! # async fn example() -> Result<(), pcs_external::Error> {
//! use pcs_external::external::{connect, auth_request};
//! use pcs_external::external::proto::{
//!     external_channel_service_client::ExternalChannelServiceClient,
//!     ExtCreateChannelReq, ExtChannelType, ExtStorageMode,
//! };
//!
//! let channel = connect("http://localhost:3203").await?;
//! let mut client = ExternalChannelServiceClient::new(channel);
//!
//! let req = auth_request("pk_live_abc123", ExtCreateChannelReq {
//!     name: "test.ctx".into(),
//!     r#type: ExtChannelType::Group.into(),
//!     storage_mode: ExtStorageMode::Buffered.into(),
//!     ..Default::default()
//! })?;
//! let _resp = client.create_channel(req).await;
//! # Ok(())
//! # }
//! ```

pub mod proto;

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 are prefixed (e.g., `/ext/chat.external.ExternalMessageService/Method`).
/// This enables GKE Ingress path-based routing where `/ext` maps to the External API backend.
///
/// When the URL has no path (e.g., `https://api.ppoppo.com`), requests pass through unchanged.
#[derive(Clone)]
pub struct ExternalChannel {
    inner: Channel,
    prefix: String,
}

/// Connect to the PCS External API with automatic TLS and path prefix support.
///
/// When the URL starts with `https://`, TLS is configured using webpki root certificates.
/// When the URL contains a path (e.g., `/ext`), it's extracted and prepended to all
/// gRPC method paths for compatibility with path-based reverse proxy routing.
///
/// Default timeouts: 10s connect, 30s request.
///
/// # Examples
///
/// ```no_run
/// # async fn example() -> Result<(), pcs_external::Error> {
/// // Direct connection (no path prefix)
/// let channel = pcs_external::connect("http://localhost:3203").await?;
///
/// // With path prefix for GKE Ingress routing
/// let channel = pcs_external::connect("https://api.ppoppo.com/ext").await?;
/// // gRPC paths become: /ext/chat.external.ExternalMessageService/Method
/// # Ok(())
/// # }
/// ```
pub async fn connect(api_url: &str) -> Result<ExternalChannel, Error> {
    // Parse URL to extract optional path prefix
    let uri: http::Uri = api_url
        .parse()
        .map_err(|e| Error::External(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()
    };

    // H5 — pre-validate the prefix at connect time so that `Service::call`
    // can never silently fall back to an unprefixed URI. We construct a
    // representative gRPC method path and confirm the prepended URI parses.
    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(),
            })?;
    }

    // Build base URL (scheme + authority) for tonic Endpoint, stripping the path
    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::External(format!("Missing authority in URL: {api_url}")))?;
        format!("{scheme}://{authority}")
    };

    let endpoint = tonic::transport::Endpoint::from_shared(base_url.clone())
        .map_err(|e| Error::External(format!("Invalid API URL '{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::External(format!("TLS configuration failed: {e}")))?
    } else {
        endpoint
    };

    let channel = endpoint
        .connect()
        .await
        .map_err(|e| Error::External(format!("Failed to connect to {base_url}: {e}")))?;

    Ok(ExternalChannel {
        inner: channel,
        prefix,
    })
}

/// Wrap a request body with Bearer API key authentication metadata.
///
/// All PCS External API calls require an API key in the `Authorization` header.
/// This helper creates a `tonic::Request<T>` with the key pre-attached.
///
/// # Errors
///
/// Returns [`Error::InvalidApiKey`] if `api_key` contains characters that are
/// not valid HTTP header values (CR, LF, NUL, non-visible ASCII). The previous
/// `parse().ok()` path silently sent the request without auth, surfacing as a
/// confusing `Unauthenticated` from PCS instead of a local config error.
///
/// # Example
///
/// ```no_run
/// # use pcs_external::external::auth_request;
/// # use pcs_external::external::proto::ExtGetOrCreateDmReq;
/// # fn try_main() -> Result<(), pcs_external::Error> {
/// let req = auth_request("pk_live_abc123", ExtGetOrCreateDmReq {
///     target_ppnum: "77712345678".into(),
/// })?;
/// # Ok(()) }
/// ```
pub fn auth_request<T>(api_key: &str, body: T) -> Result<tonic::Request<T>, Error> {
    let value = format!("Bearer {api_key}")
        .parse::<tonic::metadata::MetadataValue<_>>()
        .map_err(|_| Error::InvalidApiKey)?;
    let mut req = tonic::Request::new(body);
    req.metadata_mut().insert("authorization", value);
    Ok(req)
}

// Implement tower Service for ExternalChannel using the same request/response
// types as tonic::transport::Channel. In tonic 0.14, Channel uses tonic::body::Body.
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(). Reaching here means a request URI
            // arrived in a shape that breaks the prefixed form. Short-circuit
            // by sending a request that the underlying Channel will reject,
            // rather than silently routing to the wrong backend. Returning
            // a 400-shaped Response would require a Body construction; we
            // route through inner with the *unprefixed* URI overridden to
            // a known-bad path so the server returns 404 deterministically.
            // (Service::Error is tonic::transport::Error which we cannot
            // synthesize from outside the crate.)
            *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)
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;

    #[test]
    fn auth_request_accepts_normal_key() {
        let req = auth_request("pk_live_abc123", ()).unwrap();
        let auth = req.metadata().get("authorization").unwrap();
        assert_eq!(auth, "Bearer pk_live_abc123");
    }

    #[test]
    fn auth_request_rejects_newline_in_key() {
        // Header injection attempt — must NOT silently strip auth and send.
        let result = auth_request("pk_live_abc\r\nX-Injected: bad", ());
        assert!(matches!(result, Err(Error::InvalidApiKey)));
    }

    #[test]
    fn auth_request_rejects_nul_in_key() {
        let result = auth_request("pk_live_abc\0nul", ());
        assert!(matches!(result, Err(Error::InvalidApiKey)));
    }
}

/// Prepend a path prefix to the request URI.
///
/// Transforms `/chat.external.ExternalMessageService/Method`
/// into `/ext/chat.external.ExternalMessageService/Method`.
///
/// Returns `true` on success. Returns `false` only if the path-prefixed URI
/// fails to parse — `connect()` validates the prefix at construction so this
/// path is unreachable in normal flows. If it does happen (e.g., a request
/// arrives with an unusually malformed path), the caller short-circuits the
/// request with a `400` so it never silently routes to the wrong backend.
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
}