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}