Skip to content
157 changes: 157 additions & 0 deletions rust/crates/geo/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,160 @@ 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",
}
}

/// Whether this session may reach an API limited to `required`.
///
/// `true` when the session's region matches the API's required region;
/// callers short-circuit with a unified error when it is `false`.
pub fn allows(self, required: DcRegion) -> bool {
self == required
}

/// Strip any leading `Bearer ` from a credential.
///
/// Region prefixes (`hk_m_`, `us_m_`, `ap_m_`, …) are routing metadata
/// consumed by [`from_credential`] to derive the [`DC_REGION_HEADER`].
/// The gateway accepts the full prefixed token and routes via the header,
/// so **no region prefix is stripped** — only `Bearer ` is removed.
pub fn strip_region_prefix(credential: &str) -> &str {
credential.strip_prefix("Bearer ").unwrap_or(credential)
}
}

impl std::fmt::Display for DcRegion {
/// Human-facing uppercase name (`AP`/`US`), for error messages and display.
/// The lowercase [`DC_REGION_HEADER`] value is [`as_str`](Self::as_str).
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
DcRegion::Us => "US",
DcRegion::Ap => "AP",
})
}
}

#[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 allows_matches_same_region() {
assert!(DcRegion::Ap.allows(DcRegion::Ap));
assert!(DcRegion::Us.allows(DcRegion::Us));
assert!(!DcRegion::Us.allows(DcRegion::Ap));
assert!(!DcRegion::Ap.allows(DcRegion::Us));
}

#[test]
fn display_is_uppercase() {
// Human-facing display is uppercase; the header value stays lowercase.
assert_eq!(DcRegion::Us.to_string(), "US");
assert_eq!(DcRegion::Ap.to_string(), "AP");
assert_eq!(DcRegion::Us.as_str(), "us");
assert_eq!(DcRegion::Ap.as_str(), "ap");
}

#[test]
fn strip_region_prefix_only_removes_bearer() {
// Region prefixes are kept as-is; only "Bearer " is stripped.
assert_eq!(DcRegion::strip_region_prefix("us_m_eyJabc"), "us_m_eyJabc");
assert_eq!(DcRegion::strip_region_prefix("hk_m_eyJabc"), "hk_m_eyJabc");
assert_eq!(
DcRegion::strip_region_prefix("Bearer us_m_eyJabc"),
"us_m_eyJabc"
);
assert_eq!(DcRegion::strip_region_prefix("Bearer eyJabc"), "eyJabc");
assert_eq!(DcRegion::strip_region_prefix("eyJabc"), "eyJabc");
}
}
23 changes: 22 additions & 1 deletion rust/crates/httpclient/src/client.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
use std::sync::Arc;

use longbridge_geo::DcRegion;
use reqwest::{
Client, Method,
header::{HeaderMap, HeaderName, HeaderValue},
};
use serde::Deserialize;

use crate::{HttpClientConfig, HttpClientError, HttpClientResult, Json, RequestBuilder};
use crate::{
AuthConfig, HttpClientConfig, HttpClientError, HttpClientResult, Json, RequestBuilder,
};

/// Longbridge HTTP client
#[derive(Clone)]
Expand Down Expand Up @@ -40,6 +43,24 @@ impl HttpClient {
self
}

/// The data-center region (`us`/`ap`) derived from this client's auth
/// credentials. Used by non-HTTP call sites (e.g. WebSocket quote commands)
/// to apply the same AP-only routing guard the HTTP path enforces.
pub async fn dc_region(&self) -> DcRegion {
match &self.config.auth {
AuthConfig::ApiKey {
app_key,
app_secret,
access_token,
} => DcRegion::from_credentials(&[app_key, access_token, app_secret]),
AuthConfig::OAuth(oauth) => oauth
.access_token()
.await
.map(|token| DcRegion::from_credential(&token))
.unwrap_or(DcRegion::Ap),
}
}

/// Create a new request builder
#[inline]
pub fn request(
Expand Down
15 changes: 15 additions & 0 deletions rust/crates/httpclient/src/error.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::error::Error;

use longbridge_geo::DcRegion;
use reqwest::StatusCode;

use crate::qs::QsError;
Expand Down Expand Up @@ -34,6 +35,20 @@ pub enum HttpClientError {
#[error("request timeout")]
RequestTimeout,

/// The requested API is restricted to one data center and cannot be reached
/// from a session in a different region.
#[error(
"this API ({path}) is only available in the {required} data center and is not supported for your {current}-region account"
)]
DcRegionRestricted {
/// The restricted API path (or WebSocket command) that was requested.
path: String,
/// The data center this API is limited to.
required: DcRegion,
/// The session's current data-center region.
current: DcRegion,
},

/// OpenAPI error
#[error("openapi error: code={code}: {message}")]
OpenApi {
Expand Down
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;
52 changes: 49 additions & 3 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 @@ -125,6 +125,7 @@ pub struct RequestBuilder<'a, T, Q, R> {
headers: HeaderMap,
body: Option<T>,
query_params: Option<Q>,
dc_restrict: Option<DcRegion>,
mark_resp: PhantomData<R>,
}

Expand All @@ -137,6 +138,7 @@ impl<'a> RequestBuilder<'a, (), (), ()> {
headers: Default::default(),
body: None,
query_params: None,
dc_restrict: None,
mark_resp: PhantomData,
}
}
Expand All @@ -156,6 +158,7 @@ impl<'a, T, Q, R> RequestBuilder<'a, T, Q, R> {
headers: self.headers,
body: Some(body),
query_params: self.query_params,
dc_restrict: self.dc_restrict,
mark_resp: self.mark_resp,
}
}
Expand All @@ -175,6 +178,19 @@ impl<'a, T, Q, R> RequestBuilder<'a, T, Q, R> {
self
}

/// Restrict this request to a single data center.
///
/// When set, [`do_send`](Self::do_send) short-circuits with
/// [`HttpClientError::DcRegionRestricted`] if the session's region differs,
/// instead of forwarding a request the target data center cannot serve.
/// Call sites for region-limited endpoints declare their region here —
/// `Ap` for AP-only APIs, `Us` for US-only ones.
#[must_use]
pub fn dc_restrict(mut self, region: DcRegion) -> Self {
self.dc_restrict = Some(region);
self
}

/// Set the query string
#[must_use]
pub fn query_params<Q2>(self, params: Q2) -> RequestBuilder<'a, T, Q2, R>
Expand All @@ -188,6 +204,7 @@ impl<'a, T, Q, R> RequestBuilder<'a, T, Q, R> {
headers: self.headers,
body: self.body,
query_params: Some(params),
dc_restrict: self.dc_restrict,
mark_resp: self.mark_resp,
}
}
Expand All @@ -205,6 +222,7 @@ impl<'a, T, Q, R> RequestBuilder<'a, T, Q, R> {
headers: self.headers,
body: self.body,
query_params: self.query_params,
dc_restrict: self.dc_restrict,
mark_resp: PhantomData,
}
}
Expand Down Expand Up @@ -237,8 +255,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,20 +266,38 @@ 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()))?;
// Derive DC region from the token prefix (us_→US, others→AP).
// The token is sent as-is (including any prefix); the gateway
// accepts the full token and routes via the x-dc-region header.
let region = DcRegion::from_credential(&token);
(
oauth.client_id().to_string(),
format!("Bearer {token}"),
None,
region,
)
}
};

// Short-circuit region-limited endpoints with a single unified error,
// instead of forwarding a request the target data center cannot serve.
if let Some(required) = self.dc_restrict
&& !dc_region.allows(required)
{
return Err(HttpClientError::DcRegionRestricted {
path: self.path.clone(),
required,
current: dc_region,
});
}

let app_key_value =
HeaderValue::from_str(&app_key).map_err(|_| HttpClientError::InvalidApiKey)?;
let access_token_value = HeaderValue::from_str(&access_token)
Expand All @@ -277,6 +314,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
2 changes: 1 addition & 1 deletion rust/crates/oauth/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use crate::{

const OAUTH_BASE_URL: &str = "https://openapi.longbridge.com/oauth2";
const OAUTH_BASE_URL_CN: &str = "https://openapi.longbridge.cn/oauth2";
const OAUTH_BASE_URL_TEST: &str = "https://openapi.longbridge.xyz/oauth2";
const OAUTH_BASE_URL_TEST: &str = "https://openapi-global.longbridge.xyz/oauth2";

async fn oauth_base_url() -> &'static str {
if std::env::var("LONGBRIDGE_ENV")
Expand Down
Loading
Loading