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, Debug)]
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/// ```
72#[deprecated(
73 since = "0.2.0",
74 note = "use `pcs_port::GrpcPcsAdapter::{connect, from_env, from_parts}`; \
75 this free function will be removed in v0.3.0"
76)]
77pub async fn connect(api_url: &str) -> Result<ExternalChannel, Error> {
78 // Parse URL to extract optional path prefix
79 let uri: http::Uri = api_url
80 .parse()
81 .map_err(|e| Error::External(format!("Invalid API URL '{api_url}': {e}")))?;
82
83 let raw_path = uri.path().trim_end_matches('/');
84 let prefix = if raw_path.is_empty() || raw_path == "/" {
85 String::new()
86 } else {
87 raw_path.to_string()
88 };
89
90 // H5 — pre-validate the prefix at connect time so that `Service::call`
91 // can never silently fall back to an unprefixed URI. We construct a
92 // representative gRPC method path and confirm the prepended URI parses.
93 if !prefix.is_empty() {
94 let probe = format!("{prefix}/chat.external.SmokeTest/Method");
95 probe
96 .parse::<http::uri::PathAndQuery>()
97 .map_err(|e| Error::InvalidPathPrefix {
98 prefix: prefix.clone(),
99 reason: e.to_string(),
100 })?;
101 }
102
103 // Build base URL (scheme + authority) for tonic Endpoint, stripping the path
104 let base_url = if prefix.is_empty() {
105 api_url.to_string()
106 } else {
107 let scheme = uri.scheme_str().unwrap_or("https");
108 let authority = uri
109 .authority()
110 .map(|a| a.as_str())
111 .ok_or_else(|| Error::External(format!("Missing authority in URL: {api_url}")))?;
112 format!("{scheme}://{authority}")
113 };
114
115 let endpoint = tonic::transport::Endpoint::from_shared(base_url.clone())
116 .map_err(|e| Error::External(format!("Invalid API URL '{base_url}': {e}")))?
117 .connect_timeout(Duration::from_secs(10))
118 .timeout(Duration::from_secs(30));
119
120 let endpoint = if api_url.starts_with("https://") {
121 endpoint
122 .tls_config(tonic::transport::ClientTlsConfig::new().with_enabled_roots())
123 .map_err(|e| Error::External(format!("TLS configuration failed: {e}")))?
124 } else {
125 endpoint
126 };
127
128 let channel = endpoint
129 .connect()
130 .await
131 .map_err(|e| Error::External(format!("Failed to connect to {base_url}: {e}")))?;
132
133 Ok(ExternalChannel {
134 inner: channel,
135 prefix,
136 })
137}
138
139/// Wrap a request body with Bearer API key authentication metadata.
140///
141/// All PCS External API calls require an API key in the `Authorization` header.
142/// This helper creates a `tonic::Request<T>` with the key pre-attached.
143///
144/// # Errors
145///
146/// Returns [`Error::InvalidApiKey`] if `api_key` contains characters that are
147/// not valid HTTP header values (CR, LF, NUL, non-visible ASCII). The previous
148/// `parse().ok()` path silently sent the request without auth, surfacing as a
149/// confusing `Unauthenticated` from PCS instead of a local config error.
150///
151/// # Example
152///
153/// ```no_run
154/// # use pcs_external::external::auth_request;
155/// # use pcs_external::external::proto::ExtGetOrCreateDmReq;
156/// # fn try_main() -> Result<(), pcs_external::Error> {
157/// let req = auth_request("pk_live_abc123", ExtGetOrCreateDmReq {
158/// target_ppnum: "77712345678".into(),
159/// })?;
160/// # Ok(()) }
161/// ```
162pub fn auth_request<T>(api_key: &str, body: T) -> Result<tonic::Request<T>, Error> {
163 let value = format!("Bearer {api_key}")
164 .parse::<tonic::metadata::MetadataValue<_>>()
165 .map_err(|_| Error::InvalidApiKey)?;
166 let mut req = tonic::Request::new(body);
167 req.metadata_mut().insert("authorization", value);
168 Ok(req)
169}
170
171// Implement tower Service for ExternalChannel using the same request/response
172// types as tonic::transport::Channel. In tonic 0.14, Channel uses tonic::body::Body.
173impl tower_service::Service<http::Request<tonic::body::Body>> for ExternalChannel {
174 type Response = http::Response<tonic::body::Body>;
175 type Error = tonic::transport::Error;
176 type Future =
177 Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
178
179 fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
180 tower_service::Service::poll_ready(&mut self.inner, cx)
181 }
182
183 fn call(&mut self, mut req: http::Request<tonic::body::Body>) -> Self::Future {
184 if !self.prefix.is_empty() && !prepend_path_prefix(&mut req, &self.prefix) {
185 // Pre-validated at connect(). Reaching here means a request URI
186 // arrived in a shape that breaks the prefixed form. Short-circuit
187 // by sending a request that the underlying Channel will reject,
188 // rather than silently routing to the wrong backend. Returning
189 // a 400-shaped Response would require a Body construction; we
190 // route through inner with the *unprefixed* URI overridden to
191 // a known-bad path so the server returns 404 deterministically.
192 // (Service::Error is tonic::transport::Error which we cannot
193 // synthesize from outside the crate.)
194 *req.uri_mut() = "/__pcs_external_invalid_path__".parse().unwrap_or_else(|_| {
195 req.uri().clone()
196 });
197 }
198 let fut = tower_service::Service::call(&mut self.inner, req);
199 Box::pin(fut)
200 }
201}
202
203/// Prepend a path prefix to the request URI.
204///
205/// Transforms `/chat.external.ExternalMessageService/Method`
206/// into `/ext/chat.external.ExternalMessageService/Method`.
207///
208/// Returns `true` on success. Returns `false` only if the path-prefixed URI
209/// fails to parse — `connect()` validates the prefix at construction so this
210/// path is unreachable in normal flows. If it does happen (e.g., a request
211/// arrives with an unusually malformed path), the caller short-circuits the
212/// request with a `400` so it never silently routes to the wrong backend.
213fn prepend_path_prefix(req: &mut http::Request<tonic::body::Body>, prefix: &str) -> bool {
214 let pq_str = req
215 .uri()
216 .path_and_query()
217 .map(|pq| pq.as_str())
218 .unwrap_or("/");
219 let new_path = format!("{prefix}{pq_str}");
220 let Ok(new_pq) = new_path.parse::<http::uri::PathAndQuery>() else {
221 return false;
222 };
223 let mut parts = req.uri().clone().into_parts();
224 parts.path_and_query = Some(new_pq);
225 let Ok(new_uri) = http::Uri::from_parts(parts) else {
226 return false;
227 };
228 *req.uri_mut() = new_uri;
229 true
230}
231
232#[cfg(test)]
233#[allow(clippy::unwrap_used)]
234mod tests {
235 use super::*;
236
237 #[test]
238 fn auth_request_accepts_normal_key() {
239 let req = auth_request("pk_live_abc123", ()).unwrap();
240 let auth = req.metadata().get("authorization").unwrap();
241 assert_eq!(auth, "Bearer pk_live_abc123");
242 }
243
244 #[test]
245 fn auth_request_rejects_newline_in_key() {
246 // Header injection attempt — must NOT silently strip auth and send.
247 let result = auth_request("pk_live_abc\r\nX-Injected: bad", ());
248 assert!(matches!(result, Err(Error::InvalidApiKey)));
249 }
250
251 #[test]
252 fn auth_request_rejects_nul_in_key() {
253 let result = auth_request("pk_live_abc\0nul", ());
254 assert!(matches!(result, Err(Error::InvalidApiKey)));
255 }
256}