diff --git a/rust/crates/geo/src/lib.rs b/rust/crates/geo/src/lib.rs index 930f45ec63..dfb3687143 100644 --- a/rust/crates/geo/src/lib.rs +++ b/rust/crates/geo/src/lib.rs @@ -62,3 +62,127 @@ pub async fn is_cn() -> bool { IS_CN_DONE.get().copied().unwrap_or(false) } } + +/// HTTP and WebSocket header that selects the data center serving a request. +/// +/// An absent header is treated as [`DcRegion::Ap`] by the API gateway. +pub const DC_REGION_HEADER: &str = "x-dc-region"; + +/// Data center region used for API gateway routing. +/// +/// Independent of [`is_cn`]: that picks the `*.longbridge.cn` vs +/// `*.longbridge.com` host (mainland acceleration), while this selects which +/// data center (`us`/`ap`) the gateway sources data from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DcRegion { + /// Asia-Pacific data center (`ap`). The gateway default. + Ap, + /// US data center (`us`). + Us, +} + +impl DcRegion { + /// Derive the region from a single credential's prefix. + /// + /// Longbridge credentials — the OAuth access token, and the legacy API-key + /// `app_key` / `app_secret` / `access_token` — are prefixed with their data + /// center: `us_…` for the US data center, `ap_…` for Asia-Pacific. A `us_` + /// prefix maps to [`DcRegion::Us`]; everything else — including + /// `ap_`-prefixed and unprefixed credentials — maps to + /// [`DcRegion::Ap`], matching the gateway default. A leading `Bearer ` + /// is tolerated so an `Authorization` value can be passed directly. + pub fn from_credential(credential: &str) -> Self { + let credential = credential.strip_prefix("Bearer ").unwrap_or(credential); + if credential.starts_with("us_") { + DcRegion::Us + } else { + DcRegion::Ap + } + } + + /// Derive the region from a set of credentials, returning [`DcRegion::Us`] + /// if any of them carries the `us_` prefix. + /// + /// Used for legacy API-key auth, where the `app_key`, `app_secret`, and + /// `access_token` all carry the region prefix. + pub fn from_credentials(credentials: &[&str]) -> Self { + if credentials + .iter() + .any(|c| DcRegion::from_credential(c) == DcRegion::Us) + { + DcRegion::Us + } else { + DcRegion::Ap + } + } + + /// The [`DC_REGION_HEADER`] value for this region (`"us"` or `"ap"`). + pub fn as_str(self) -> &'static str { + match self { + DcRegion::Us => "us", + DcRegion::Ap => "ap", + } + } + + /// Strip the region prefix (`us_` / `ap_`), and any leading `Bearer `, from + /// a credential, returning the bare token to transmit. + /// + /// The prefix is region metadata for [`from_credential`] / the + /// [`DC_REGION_HEADER`] — it is **not** part of the verifiable credential. + /// The gateway verifies the bare token and routes by the region header, so + /// the prefix must be removed before the token is sent (e.g. in an + /// `Authorization: Bearer …` header or a WebSocket auth handshake). + pub fn strip_region_prefix(credential: &str) -> &str { + let credential = credential.strip_prefix("Bearer ").unwrap_or(credential); + credential + .strip_prefix("us_") + .or_else(|| credential.strip_prefix("ap_")) + .unwrap_or(credential) + } +} + +#[cfg(test)] +mod dc_region_tests { + use super::*; + + #[test] + fn from_credential_detects_region() { + assert_eq!(DcRegion::from_credential("us_abc"), DcRegion::Us); + assert_eq!(DcRegion::from_credential("ap_abc"), DcRegion::Ap); + // Unprefixed and unknown prefixes fall back to the AP default. + assert_eq!(DcRegion::from_credential("abc"), DcRegion::Ap); + assert_eq!(DcRegion::from_credential(""), DcRegion::Ap); + // A `Bearer ` prefix is tolerated. + assert_eq!(DcRegion::from_credential("Bearer us_x"), DcRegion::Us); + assert_eq!(DcRegion::from_credential("Bearer ap_x"), DcRegion::Ap); + } + + #[test] + fn from_credentials_is_us_if_any_is_us() { + assert_eq!( + DcRegion::from_credentials(&["ap_key", "us_secret", "ap_token"]), + DcRegion::Us + ); + assert_eq!( + DcRegion::from_credentials(&["ap_key", "ap_secret", "ap_token"]), + DcRegion::Ap + ); + assert_eq!(DcRegion::from_credentials(&[]), DcRegion::Ap); + } + + #[test] + fn as_str_matches_header_value() { + assert_eq!(DcRegion::Us.as_str(), "us"); + assert_eq!(DcRegion::Ap.as_str(), "ap"); + } + + #[test] + fn strip_region_prefix_removes_region_and_bearer() { + assert_eq!(DcRegion::strip_region_prefix("us_eyJabc"), "eyJabc"); + assert_eq!(DcRegion::strip_region_prefix("ap_eyJabc"), "eyJabc"); + assert_eq!(DcRegion::strip_region_prefix("Bearer us_eyJabc"), "eyJabc"); + // Unprefixed tokens pass through unchanged. + assert_eq!(DcRegion::strip_region_prefix("eyJabc"), "eyJabc"); + assert_eq!(DcRegion::strip_region_prefix("Bearer eyJabc"), "eyJabc"); + } +} diff --git a/rust/crates/httpclient/src/lib.rs b/rust/crates/httpclient/src/lib.rs index 9a495ebe87..544c3050bf 100644 --- a/rust/crates/httpclient/src/lib.rs +++ b/rust/crates/httpclient/src/lib.rs @@ -16,7 +16,7 @@ mod timestamp; pub use client::HttpClient; pub use config::{AuthConfig, HttpClientConfig}; pub use error::{HttpClientError, HttpClientResult, HttpError}; -pub use longbridge_geo::is_cn; +pub use longbridge_geo::{DC_REGION_HEADER, DcRegion, is_cn}; pub use qs::QsError; pub use request::{FromPayload, Json, RequestBuilder, ToPayload}; pub use reqwest::Method; diff --git a/rust/crates/httpclient/src/request.rs b/rust/crates/httpclient/src/request.rs index aee7182578..862bb40521 100644 --- a/rust/crates/httpclient/src/request.rs +++ b/rust/crates/httpclient/src/request.rs @@ -6,7 +6,7 @@ use std::{ time::{Duration, Instant}, }; -use longbridge_geo::is_cn; +use longbridge_geo::{DC_REGION_HEADER, DcRegion, is_cn}; use reqwest::{ Method, StatusCode, header::{HeaderMap, HeaderName, HeaderValue}, @@ -237,8 +237,9 @@ where .and_then(|value| value.parse().ok()) .unwrap_or_else(Timestamp::now); - // Resolve app_key, access_token, and optional app_secret from auth config - let (app_key, access_token, app_secret) = match &config.auth { + // Resolve app_key, access_token, optional app_secret, and the data-center + // region from the auth config. + let (app_key, access_token, app_secret, dc_region) = match &config.auth { AuthConfig::ApiKey { app_key, app_secret, @@ -247,16 +248,24 @@ where app_key.clone(), access_token.clone(), Some(app_secret.clone()), + DcRegion::from_credentials(&[app_key, access_token, app_secret]), ), AuthConfig::OAuth(oauth) => { let token = oauth .access_token() .await .map_err(|e| HttpClientError::OAuth(e.to_string()))?; + // The `us_`/`ap_` prefix is region metadata, not part of the + // verifiable bearer credential: derive the region from it, then + // strip it so the gateway verifies the bare token and routes by + // the `x-dc-region` header. + let region = DcRegion::from_credential(&token); + let bare = DcRegion::strip_region_prefix(&token); ( oauth.client_id().to_string(), - format!("Bearer {token}"), + format!("Bearer {bare}"), None, + region, ) } }; @@ -277,6 +286,15 @@ where .header("X-Timestamp", timestamp.to_string()) .header("Content-Type", "application/json; charset=utf-8"); + // Route to the data center matching the credential's region (us/ap), + // unless the caller already set the header explicitly (e.g. via custom + // headers). + let region_already_set = default_headers.contains_key(DC_REGION_HEADER) + || self.headers.contains_key(DC_REGION_HEADER); + if !region_already_set { + request_builder = request_builder.header(DC_REGION_HEADER, dc_region.as_str()); + } + // set the request body if let Some(body) = &self.body { let body = body diff --git a/rust/src/config.rs b/rust/src/config.rs index c2f0c49c56..94c839a1eb 100644 --- a/rust/src/config.rs +++ b/rust/src/config.rs @@ -7,7 +7,9 @@ use std::{ }; pub(crate) use http::{HeaderName, HeaderValue, Request, header}; -use longbridge_httpcli::{HttpClient, HttpClientConfig, Json, Method, is_cn}; +use longbridge_httpcli::{ + DC_REGION_HEADER, DcRegion, HttpClient, HttpClientConfig, Json, Method, is_cn, +}; use longbridge_oauth::OAuth; use num_enum::IntoPrimitive; use serde::{Deserialize, Serialize}; @@ -498,7 +500,29 @@ impl Config { .block_on(self.refresh_access_token(expired_at)) } - fn create_ws_request(&self, url: &str) -> tokio_tungstenite::tungstenite::Result> { + /// Resolve the data-center region from the auth credentials. For OAuth the + /// access token is resolved (and refreshed if needed); for legacy API-key + /// mode the `app_key`/`app_secret`/`access_token` prefixes are inspected. + async fn auth_dc_region(&self) -> DcRegion { + match &self.auth { + AuthMode::ApiKey { + app_key, + app_secret, + access_token, + } => DcRegion::from_credentials(&[app_key, access_token, app_secret]), + AuthMode::OAuth(oauth) => oauth + .access_token() + .await + .map(|token| DcRegion::from_credential(&token)) + .unwrap_or(DcRegion::Ap), + } + } + + fn create_ws_request( + &self, + url: &str, + dc_region: DcRegion, + ) -> tokio_tungstenite::tungstenite::Result> { let mut request = url.into_client_request()?; request.headers_mut().append( header::ACCEPT_LANGUAGE, @@ -512,21 +536,34 @@ impl Config { request.headers_mut().append(name, val); } } + // Route the upgrade to the data center matching the credential's region, + // unless the caller already set the header via a custom header. + if !self + .custom_headers + .keys() + .any(|key| key.eq_ignore_ascii_case(DC_REGION_HEADER)) + { + request.headers_mut().append( + HeaderName::from_static(DC_REGION_HEADER), + HeaderValue::from_static(dc_region.as_str()), + ); + } Ok(request) } pub(crate) async fn create_quote_ws_request( &self, ) -> (&str, tokio_tungstenite::tungstenite::Result>) { + let dc_region = self.auth_dc_region().await; match self.quote_ws_url.as_deref() { - Some(url) => (url, self.create_ws_request(url)), + Some(url) => (url, self.create_ws_request(url, dc_region)), None => { let url = if is_cn().await { DEFAULT_QUOTE_WS_URL_CN } else { DEFAULT_QUOTE_WS_URL }; - (url, self.create_ws_request(url)) + (url, self.create_ws_request(url, dc_region)) } } } @@ -534,15 +571,16 @@ impl Config { pub(crate) async fn create_trade_ws_request( &self, ) -> (&str, tokio_tungstenite::tungstenite::Result>) { + let dc_region = self.auth_dc_region().await; match self.trade_ws_url.as_deref() { - Some(url) => (url, self.create_ws_request(url)), + Some(url) => (url, self.create_ws_request(url, dc_region)), None => { let url = if is_cn().await { DEFAULT_TRADE_WS_URL_CN } else { DEFAULT_TRADE_WS_URL }; - (url, self.create_ws_request(url)) + (url, self.create_ws_request(url, dc_region)) } } } @@ -562,6 +600,17 @@ impl Config { self } + /// Override the data-center region for every HTTP and WebSocket request by + /// setting the [`DC_REGION_HEADER`](crate::DC_REGION_HEADER) explicitly. + /// + /// This is rarely needed: the region is otherwise derived automatically + /// from the auth credentials' prefix (`us_`/`ap_`). Use this only to + /// force a specific data center regardless of the credential. + #[must_use] + pub fn dc_region(self, region: DcRegion) -> Self { + self.header(DC_REGION_HEADER, region.as_str()) + } + /// Set the HTTP endpoint URL in place. pub fn set_http_url(&mut self, url: impl Into) { self.http_url = Some(url.into()); diff --git a/rust/src/lib.rs b/rust/src/lib.rs index df996bbd33..e2fb096b47 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -45,6 +45,7 @@ pub use dca::DCAContext; pub use error::{Error, Result, SimpleError, SimpleErrorKind}; pub use fundamental::FundamentalContext; pub use longbridge_httpcli as httpclient; +pub use longbridge_httpcli::{DC_REGION_HEADER, DcRegion}; pub use longbridge_wscli as wsclient; pub use market::MarketContext; pub use portfolio::PortfolioContext;