Skip to main content

longbridge_geo/
lib.rs

1//! Geo-detection helper for Longbridge OpenAPI.
2//!
3//! Determines whether the current access point is in China Mainland so that
4//! callers can choose between `*.longbridge.cn` and `*.longbridge.com`
5//! endpoints.
6
7use std::{
8    sync::{
9        OnceLock,
10        atomic::{AtomicBool, Ordering},
11    },
12    time::Duration,
13};
14
15// Process-wide cache so the probe is done at most once regardless of which
16// tokio worker thread calls `is_cn()`.
17static IS_CN_DONE: OnceLock<bool> = OnceLock::new();
18
19// Used to prevent multiple concurrent probes racing at startup.
20static IS_CN_PROBING: AtomicBool = AtomicBool::new(false);
21
22/// Do the best to guess whether the access point is in China Mainland or not.
23///
24/// Detection priority:
25/// 1. `LONGBRIDGE_REGION` environment variable (takes precedence).
26/// 2. `LONGPORT_REGION` environment variable (fallback alias).
27/// 3. Process-wide cached result from a previous probe.
28/// 4. Live HTTP probe to `https://geotest.lbkrs.com` — HTTP 200 → CN, anything
29///    else (error or non-200) → not CN.
30pub async fn is_cn() -> bool {
31    // 1 & 2: explicit region override
32    let user_region = std::env::var("LONGBRIDGE_REGION")
33        .ok()
34        .or_else(|| std::env::var("LONGPORT_REGION").ok());
35    if let Some(region) = user_region {
36        return region.eq_ignore_ascii_case("CN");
37    }
38
39    // 3: already probed
40    if let Some(&cached) = IS_CN_DONE.get() {
41        return cached;
42    }
43
44    // 4: live probe — only one task does the actual probe; others fall back
45    //    to `false` (global endpoint) which is safe and avoids a pile-up.
46    if IS_CN_PROBING
47        .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
48        .is_ok()
49    {
50        let result = reqwest::Client::new()
51            .get("https://geotest.lbkrs.com")
52            .timeout(Duration::from_secs(5))
53            .send()
54            .await
55            .is_ok_and(|resp| resp.status().is_success());
56
57        let _ = IS_CN_DONE.set(result);
58        result
59    } else {
60        // Another task is probing; use the cached value if it finished in the
61        // meantime, otherwise default to global endpoint.
62        IS_CN_DONE.get().copied().unwrap_or(false)
63    }
64}
65
66/// HTTP and WebSocket header that selects the data center serving a request.
67///
68/// An absent header is treated as [`DcRegion::Ap`] by the API gateway.
69pub const DC_REGION_HEADER: &str = "x-dc-region";
70
71/// Data center region used for API gateway routing.
72///
73/// Independent of [`is_cn`]: that picks the `*.longbridge.cn` vs
74/// `*.longbridge.com` host (mainland acceleration), while this selects which
75/// data center (`us`/`ap`) the gateway sources data from.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum DcRegion {
78    /// Asia-Pacific data center (`ap`). The gateway default.
79    Ap,
80    /// US data center (`us`).
81    Us,
82}
83
84impl DcRegion {
85    /// Derive the region from a single credential's prefix.
86    ///
87    /// Longbridge credentials — the OAuth access token, and the legacy API-key
88    /// `app_key` / `app_secret` / `access_token` — are prefixed with their data
89    /// center: `us_…` for the US data center, `ap_…` for Asia-Pacific. A `us_`
90    /// prefix maps to [`DcRegion::Us`]; everything else — including
91    /// `ap_`-prefixed and unprefixed credentials — maps to
92    /// [`DcRegion::Ap`], matching the gateway default. A leading `Bearer `
93    /// is tolerated so an `Authorization` value can be passed directly.
94    pub fn from_credential(credential: &str) -> Self {
95        let credential = credential.strip_prefix("Bearer ").unwrap_or(credential);
96        if credential.starts_with("us_") {
97            DcRegion::Us
98        } else {
99            DcRegion::Ap
100        }
101    }
102
103    /// Derive the region from a set of credentials, returning [`DcRegion::Us`]
104    /// if any of them carries the `us_` prefix.
105    ///
106    /// Used for legacy API-key auth, where the `app_key`, `app_secret`, and
107    /// `access_token` all carry the region prefix.
108    pub fn from_credentials(credentials: &[&str]) -> Self {
109        if credentials
110            .iter()
111            .any(|c| DcRegion::from_credential(c) == DcRegion::Us)
112        {
113            DcRegion::Us
114        } else {
115            DcRegion::Ap
116        }
117    }
118
119    /// The [`DC_REGION_HEADER`] value for this region (`"us"` or `"ap"`).
120    pub fn as_str(self) -> &'static str {
121        match self {
122            DcRegion::Us => "us",
123            DcRegion::Ap => "ap",
124        }
125    }
126
127    /// Whether this session may reach an API limited to `required`.
128    ///
129    /// `true` when the session's region matches the API's required region;
130    /// callers short-circuit with a unified error when it is `false`.
131    pub fn allows(self, required: DcRegion) -> bool {
132        self == required
133    }
134
135    /// Strip any leading `Bearer ` from a credential.
136    ///
137    /// Region prefixes (`hk_m_`, `us_m_`, `ap_m_`, …) are routing metadata
138    /// consumed by [`from_credential`] to derive the [`DC_REGION_HEADER`].
139    /// The gateway accepts the full prefixed token and routes via the header,
140    /// so **no region prefix is stripped** — only `Bearer ` is removed.
141    pub fn strip_region_prefix(credential: &str) -> &str {
142        credential.strip_prefix("Bearer ").unwrap_or(credential)
143    }
144}
145
146impl std::fmt::Display for DcRegion {
147    /// Human-facing uppercase name (`AP`/`US`), for error messages and display.
148    /// The lowercase [`DC_REGION_HEADER`] value is [`as_str`](Self::as_str).
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        f.write_str(match self {
151            DcRegion::Us => "US",
152            DcRegion::Ap => "AP",
153        })
154    }
155}
156
157#[cfg(test)]
158mod dc_region_tests {
159    use super::*;
160
161    #[test]
162    fn from_credential_detects_region() {
163        assert_eq!(DcRegion::from_credential("us_abc"), DcRegion::Us);
164        assert_eq!(DcRegion::from_credential("ap_abc"), DcRegion::Ap);
165        // Unprefixed and unknown prefixes fall back to the AP default.
166        assert_eq!(DcRegion::from_credential("abc"), DcRegion::Ap);
167        assert_eq!(DcRegion::from_credential(""), DcRegion::Ap);
168        // A `Bearer ` prefix is tolerated.
169        assert_eq!(DcRegion::from_credential("Bearer us_x"), DcRegion::Us);
170        assert_eq!(DcRegion::from_credential("Bearer ap_x"), DcRegion::Ap);
171    }
172
173    #[test]
174    fn from_credentials_is_us_if_any_is_us() {
175        assert_eq!(
176            DcRegion::from_credentials(&["ap_key", "us_secret", "ap_token"]),
177            DcRegion::Us
178        );
179        assert_eq!(
180            DcRegion::from_credentials(&["ap_key", "ap_secret", "ap_token"]),
181            DcRegion::Ap
182        );
183        assert_eq!(DcRegion::from_credentials(&[]), DcRegion::Ap);
184    }
185
186    #[test]
187    fn as_str_matches_header_value() {
188        assert_eq!(DcRegion::Us.as_str(), "us");
189        assert_eq!(DcRegion::Ap.as_str(), "ap");
190    }
191
192    #[test]
193    fn allows_matches_same_region() {
194        assert!(DcRegion::Ap.allows(DcRegion::Ap));
195        assert!(DcRegion::Us.allows(DcRegion::Us));
196        assert!(!DcRegion::Us.allows(DcRegion::Ap));
197        assert!(!DcRegion::Ap.allows(DcRegion::Us));
198    }
199
200    #[test]
201    fn display_is_uppercase() {
202        // Human-facing display is uppercase; the header value stays lowercase.
203        assert_eq!(DcRegion::Us.to_string(), "US");
204        assert_eq!(DcRegion::Ap.to_string(), "AP");
205        assert_eq!(DcRegion::Us.as_str(), "us");
206        assert_eq!(DcRegion::Ap.as_str(), "ap");
207    }
208
209    #[test]
210    fn strip_region_prefix_only_removes_bearer() {
211        // Region prefixes are kept as-is; only "Bearer " is stripped.
212        assert_eq!(DcRegion::strip_region_prefix("us_m_eyJabc"), "us_m_eyJabc");
213        assert_eq!(DcRegion::strip_region_prefix("hk_m_eyJabc"), "hk_m_eyJabc");
214        assert_eq!(
215            DcRegion::strip_region_prefix("Bearer us_m_eyJabc"),
216            "us_m_eyJabc"
217        );
218        assert_eq!(DcRegion::strip_region_prefix("Bearer eyJabc"), "eyJabc");
219        assert_eq!(DcRegion::strip_region_prefix("eyJabc"), "eyJabc");
220    }
221}