Skip to main content

pcs_external/external/
mod.rs

1//! gRPC client for PCS External API.
2//!
3//! # Usage
4//!
5//! ```no_run
6//! # async fn example() -> Result<(), pcs_external::Error> {
7//! use pcs_external::external::{connect, auth_request};
8//! use pcs_external::external::proto::{
9//!     external_channel_service_client::ExternalChannelServiceClient,
10//!     ExtCreateChannelReq, ExtChannelType, ExtStorageMode,
11//! };
12//!
13//! let channel = connect("http://localhost:3203").await?;
14//! let mut client = ExternalChannelServiceClient::new(channel);
15//!
16//! let req = auth_request("pk_live_abc123", ExtCreateChannelReq {
17//!     name: "test.ctx".into(),
18//!     r#type: ExtChannelType::Group.into(),
19//!     storage_mode: ExtStorageMode::Buffered.into(),
20//!     ..Default::default()
21//! });
22//! let _resp = client.create_channel(req).await;
23//! # Ok(())
24//! # }
25//! ```
26
27pub mod proto;
28
29use std::future::Future;
30use std::pin::Pin;
31use std::task::{Context, Poll};
32use std::time::Duration;
33
34use tonic::transport::Channel;
35
36use crate::error::Error;
37
38/// A gRPC channel that prepends an optional path prefix to all requests.
39///
40/// When the API URL includes a path (e.g., `https://api.ppoppo.com/ext`),
41/// all gRPC method paths are prefixed (e.g., `/ext/chat.external.ExternalMessageService/Method`).
42/// This enables GKE Ingress path-based routing where `/ext` maps to the External API backend.
43///
44/// When the URL has no path (e.g., `https://api.ppoppo.com`), requests pass through unchanged.
45#[derive(Clone)]
46pub struct ExternalChannel {
47    inner: Channel,
48    prefix: String,
49}
50
51/// Connect to the PCS External API with automatic TLS and path prefix support.
52///
53/// When the URL starts with `https://`, TLS is configured using webpki root certificates.
54/// When the URL contains a path (e.g., `/ext`), it's extracted and prepended to all
55/// gRPC method paths for compatibility with path-based reverse proxy routing.
56///
57/// Default timeouts: 10s connect, 30s request.
58///
59/// # Examples
60///
61/// ```no_run
62/// # async fn example() -> Result<(), pcs_external::Error> {
63/// // Direct connection (no path prefix)
64/// let channel = pcs_external::connect("http://localhost:3203").await?;
65///
66/// // With path prefix for GKE Ingress routing
67/// let channel = pcs_external::connect("https://api.ppoppo.com/ext").await?;
68/// // gRPC paths become: /ext/chat.external.ExternalMessageService/Method
69/// # Ok(())
70/// # }
71/// ```
72pub async fn connect(api_url: &str) -> Result<ExternalChannel, Error> {
73    // Parse URL to extract optional path prefix
74    let uri: http::Uri = api_url
75        .parse()
76        .map_err(|e| Error::External(format!("Invalid API URL '{api_url}': {e}")))?;
77
78    let raw_path = uri.path().trim_end_matches('/');
79    let prefix = if raw_path.is_empty() || raw_path == "/" {
80        String::new()
81    } else {
82        raw_path.to_string()
83    };
84
85    // Build base URL (scheme + authority) for tonic Endpoint, stripping the path
86    let base_url = if prefix.is_empty() {
87        api_url.to_string()
88    } else {
89        let scheme = uri.scheme_str().unwrap_or("https");
90        let authority = uri
91            .authority()
92            .map(|a| a.as_str())
93            .ok_or_else(|| {
94                Error::External(format!("Missing authority in URL: {api_url}"))
95            })?;
96        format!("{scheme}://{authority}")
97    };
98
99    let endpoint = tonic::transport::Endpoint::from_shared(base_url.clone())
100        .map_err(|e| Error::External(format!("Invalid API URL '{base_url}': {e}")))?
101        .connect_timeout(Duration::from_secs(10))
102        .timeout(Duration::from_secs(30));
103
104    let endpoint = if api_url.starts_with("https://") {
105        endpoint
106            .tls_config(tonic::transport::ClientTlsConfig::new().with_enabled_roots())
107            .map_err(|e| Error::External(format!("TLS configuration failed: {e}")))?
108    } else {
109        endpoint
110    };
111
112    let channel = endpoint
113        .connect()
114        .await
115        .map_err(|e| Error::External(format!("Failed to connect to {base_url}: {e}")))?;
116
117    Ok(ExternalChannel {
118        inner: channel,
119        prefix,
120    })
121}
122
123/// Wrap a request body with Bearer API key authentication metadata.
124///
125/// All PCS External API calls require an API key in the `Authorization` header.
126/// This helper creates a `tonic::Request<T>` with the key pre-attached.
127///
128/// # Example
129///
130/// ```no_run
131/// # use pcs_external::external::{auth_request};
132/// # use pcs_external::external::proto::ExtGetOrCreateDmReq;
133/// let req = auth_request("pk_live_abc123", ExtGetOrCreateDmReq {
134///     target_ppnum: "77712345678".into(),
135/// });
136/// ```
137pub fn auth_request<T>(api_key: &str, body: T) -> tonic::Request<T> {
138    let mut req = tonic::Request::new(body);
139    if let Ok(value) = format!("Bearer {api_key}").parse() {
140        req.metadata_mut().insert("authorization", value);
141    }
142    req
143}
144
145// Implement tower Service for ExternalChannel using the same request/response
146// types as tonic::transport::Channel. In tonic 0.14, Channel uses tonic::body::Body.
147impl tower_service::Service<http::Request<tonic::body::Body>> for ExternalChannel {
148    type Response = http::Response<tonic::body::Body>;
149    type Error = tonic::transport::Error;
150    type Future =
151        Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
152
153    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
154        tower_service::Service::poll_ready(&mut self.inner, cx)
155    }
156
157    fn call(&mut self, mut req: http::Request<tonic::body::Body>) -> Self::Future {
158        if !self.prefix.is_empty() {
159            prepend_path_prefix(&mut req, &self.prefix);
160        }
161        let fut = tower_service::Service::call(&mut self.inner, req);
162        Box::pin(fut)
163    }
164}
165
166/// Prepend a path prefix to the request URI.
167///
168/// Transforms `/chat.external.ExternalMessageService/Method`
169/// into `/ext/chat.external.ExternalMessageService/Method`.
170fn prepend_path_prefix(req: &mut http::Request<tonic::body::Body>, prefix: &str) {
171    let pq_str = req
172        .uri()
173        .path_and_query()
174        .map(|pq| pq.as_str())
175        .unwrap_or("/");
176    let new_path = format!("{prefix}{pq_str}");
177    if let Ok(new_pq) = new_path.parse::<http::uri::PathAndQuery>() {
178        let mut parts = req.uri().clone().into_parts();
179        parts.path_and_query = Some(new_pq);
180        if let Ok(new_uri) = http::Uri::from_parts(parts) {
181            *req.uri_mut() = new_uri;
182        }
183    }
184}