Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions rust/crates/geo/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
2 changes: 1 addition & 1 deletion rust/crates/httpclient/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
26 changes: 22 additions & 4 deletions rust/crates/httpclient/src/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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,
Expand All @@ -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,
)
}
};
Expand All @@ -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
Expand Down
61 changes: 55 additions & 6 deletions rust/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<Request<()>> {
/// 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<Request<()>> {
let mut request = url.into_client_request()?;
request.headers_mut().append(
header::ACCEPT_LANGUAGE,
Expand All @@ -512,37 +536,51 @@ 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<Request<()>>) {
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))
}
}
}

pub(crate) async fn create_trade_ws_request(
&self,
) -> (&str, tokio_tungstenite::tungstenite::Result<Request<()>>) {
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))
}
}
}
Expand All @@ -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<String>) {
self.http_url = Some(url.into());
Expand Down
1 change: 1 addition & 0 deletions rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading