1use std::sync::Arc;
7
8mod cors;
9mod metrics;
10mod probes;
11mod startup;
12
13mod generated_routes {
14 include!(concat!(env!("OUT_DIR"), "/generated_rest_routes.rs"));
15}
16
17use axum::Router;
18use axum::body::to_bytes;
19use axum::http::{StatusCode, header};
20use axum::middleware::Next;
21use axum::response::{IntoResponse, Response};
22use axum::routing::get;
23use futu_auth::{KeyStore, RuntimeCounters};
24
25use futu_server::router::RequestRouter;
26
27use crate::adapter::RestState;
28use crate::auth::{AuthState, bearer_auth};
29use crate::ws::{self, WsBroadcaster};
30
31use metrics::metrics_handler;
32use probes::{health_handler, livez_handler, readyz_handler};
33pub use startup::{
34 RestTlsConfig, RestTransport, start_with_auth, start_with_auth_and_admin,
35 start_with_auth_full_admin, start_with_auth_full_admin_until_shutdown,
36 start_with_auth_full_admin_until_shutdown_with_listener_events,
37 start_with_auth_full_admin_until_shutdown_with_transport_and_listener_events,
38};
39
40#[derive(Default)]
45pub struct RestAdminHooks {
46 pub admin_status_provider: Option<crate::adapter::AdminStatusProvider>,
47 pub admin_shutdown_handler: Option<crate::adapter::AdminShutdownHandler>,
48 pub admin_reload_handler: Option<crate::adapter::AdminReloadHandler>,
49 pub push_health_snapshot_provider: Option<crate::adapter::PushHealthSnapshotProvider>,
50 pub card_num_resolver: Option<crate::adapter::CardNumResolver>,
51}
52
53pub(crate) const LEGACY_WS_WARN_MESSAGE: &str = "WS endpoint /ws also accepts unauthenticated connections in legacy mode — \
58 same posture as REST mutating-blocked: legacy clients may push to /ws without auth. \
59 Migrate to --rest-keys-file for production. v2 will default-reject.";
60
61#[cfg(test)]
67pub(crate) fn build_legacy_readonly_router(
68 router: Arc<RequestRouter>,
69 ws_broadcaster: Arc<WsBroadcaster>,
70) -> Router {
71 build_router_with_auth(
72 router,
73 ws_broadcaster,
74 Arc::new(KeyStore::empty()),
75 Arc::new(RuntimeCounters::new()),
76 )
77}
78
79pub fn build_router_with_auth(
86 router: Arc<RequestRouter>,
87 ws_broadcaster: Arc<WsBroadcaster>,
88 key_store: Arc<KeyStore>,
89 counters: Arc<RuntimeCounters>,
90) -> Router {
91 build_router_with_auth_and_admin(router, ws_broadcaster, key_store, counters, None)
92}
93
94pub fn build_router_with_auth_and_admin(
101 router: Arc<RequestRouter>,
102 ws_broadcaster: Arc<WsBroadcaster>,
103 key_store: Arc<KeyStore>,
104 counters: Arc<RuntimeCounters>,
105 admin_status_provider: Option<crate::adapter::AdminStatusProvider>,
106) -> Router {
107 build_router_with_auth_full_admin(
108 router,
109 ws_broadcaster,
110 key_store,
111 counters,
112 RestAdminHooks {
113 admin_status_provider,
114 ..RestAdminHooks::default()
115 },
116 )
117}
118
119pub fn build_router_with_auth_full_admin(
124 router: Arc<RequestRouter>,
125 ws_broadcaster: Arc<WsBroadcaster>,
126 key_store: Arc<KeyStore>,
127 counters: Arc<RuntimeCounters>,
128 hooks: RestAdminHooks,
129) -> Router {
130 let RestAdminHooks {
131 admin_status_provider,
132 admin_shutdown_handler,
133 admin_reload_handler,
134 push_health_snapshot_provider,
135 card_num_resolver,
136 } = hooks;
137 let mut state = RestState::with_auth(
138 router,
139 ws_broadcaster,
140 Arc::clone(&key_store),
141 Arc::clone(&counters),
142 );
143 if let Some(p) = admin_status_provider {
144 state = state.with_admin_status_provider(p);
145 }
146 if let Some(h) = admin_shutdown_handler {
147 state = state.with_admin_shutdown_handler(h);
148 }
149 if let Some(h) = admin_reload_handler {
150 state = state.with_admin_reload_handler(h);
151 }
152 if let Some(p) = push_health_snapshot_provider {
153 state = state.with_push_health_snapshot_provider(p);
154 }
155 if let Some(r) = card_num_resolver {
156 state = state.with_card_num_resolver(r);
157 }
158 let startup_readiness = state.router.startup_readiness();
159 let auth_state = AuthState::new(Arc::clone(&key_store), Arc::clone(&counters));
160
161 let cors = cors::build_cors_layer(&key_store);
162
163 let router = Router::new()
164 .route("/ws", get(ws::ws_handler))
165 .route("/metrics", get(metrics_handler));
166 let router = generated_routes::register_generated_routes(router);
167
168 router
169 .layer(axum::middleware::from_fn_with_state(
174 startup_readiness,
175 startup_readiness_middleware,
176 ))
177 .layer(axum::middleware::from_fn(
183 crate::strict_fields::strict_field_validation_middleware,
184 ))
185 .layer(axum::middleware::from_fn_with_state(
186 auth_state,
187 bearer_auth,
188 ))
189 .layer(axum::middleware::from_fn(rest_error_envelope_middleware))
190 .route("/livez", get(livez_handler))
199 .route("/health", get(health_handler))
200 .route("/readyz", get(readyz_handler))
201 .fallback(unknown_route_fallback)
209 .layer(cors)
210 .with_state(state)
211}
212
213async fn startup_readiness_middleware(
214 axum::extract::State(readiness): axum::extract::State<futu_server::identity::StartupReadiness>,
215 req: axum::extract::Request,
216 next: Next,
217) -> Response {
218 let snapshot = readiness.snapshot();
219 let path = req.uri().path();
220 let allowed_prelogin = rest_path_allowed_prelogin(path);
221 if snapshot.state == futu_server::identity::StartupState::Ready || allowed_prelogin {
222 return next.run(req).await;
223 }
224
225 (
226 StatusCode::SERVICE_UNAVAILABLE,
227 axum::Json(serde_json::json!({
228 "ret_type": -1,
229 "ret_msg": "gateway authentication is not ready",
230 "error": "gateway_not_ready",
231 "startup_state": snapshot.state,
232 "generation": snapshot.generation,
233 })),
234 )
235 .into_response()
236}
237
238fn rest_path_allowed_prelogin(path: &str) -> bool {
239 matches!(path, "/api/global-state" | "/api/verification")
240}
241
242async fn unknown_route_fallback(req: axum::extract::Request) -> impl IntoResponse {
245 let path = req.uri().path().to_string();
246 let method = req.method().to_string();
247 (
248 axum::http::StatusCode::NOT_FOUND,
249 [(axum::http::header::CONTENT_TYPE, "application/json")],
250 axum::Json(serde_json::json!({
251 "error": format!("unknown route {method} {path:?}"),
252 "hint": "see categories below or full reference at https://www.futuapi.com/reference/rest-api/",
253 "categories": {
254 "qot (行情)": "/api/quote /api/snapshot /api/kline /api/orderbook /api/ticker /api/option-chain /api/option-quote /api/option-strategy /api/option-strategy-analysis /api/option-strategy-spread /api/history-kline /api/static-info /api/subscribe /api/sub-info /api/market-state /api/capital-flow /api/option-expiration-date /api/warrant /api/ipo-list /api/ipo-calendar /api/financial-calendar /api/financial-calendar-target",
255 "trd (交易, scope=acc:read or trade:*)": "/api/accounts /api/funds /api/positions /api/orders /api/order-fills /api/history-orders /api/history-order-fills /api/max-trd-qtys /api/combo-max-trd-qtys /api/margin-ratio /api/order-fee /api/sub-acc-push /api/flow-summary /api/order /api/combo-order /api/modify-order /api/cancel-order /api/cancel-all-order /api/unlock-trade /api/reconfirm-order",
256 "tier-m (mobile-driven, v1.4.94+)": "/api/cash-log /api/cash-detail /api/biz-group /api/margin-info /api/account-flag /api/bond-total-asset /api/bond-single-asset /api/bond-position-list /api/bond-answer-state /api/bond-trade-reminder",
257 "sys": "/api/global-state /api/user-info /api/quote-rights /api/quote-capability /api/delay-statistics /api/ping /api/push-subscriber-info /api/admin/status (admin scope)",
258 "infra": "/livez (liveness) /health (backend transport) /readyz (dispatch readiness) /metrics (Prometheus) /ws (WebSocket push)"
259 },
260 "method_hint": "most endpoints are POST with JSON body; /api/accounts /api/list-accounts /api/health /api/global-state are GET. Check the doc URL for exact verb."
261 })),
262 )
263}
264
265async fn rest_error_envelope_middleware(req: axum::extract::Request, next: Next) -> Response {
271 let resp = next.run(req).await;
272 let status = resp.status();
273 if !matches!(
274 status,
275 StatusCode::BAD_REQUEST | StatusCode::UNSUPPORTED_MEDIA_TYPE
276 ) {
277 return resp;
278 }
279 let content_type = resp
280 .headers()
281 .get(header::CONTENT_TYPE)
282 .and_then(|v| v.to_str().ok())
283 .unwrap_or("");
284 if !content_type.starts_with("text/plain") {
285 return resp;
286 }
287
288 let bytes = match to_bytes(resp.into_body(), 64 * 1024).await {
289 Ok(bytes) => bytes,
290 Err(err) => {
291 let msg =
292 format!("REST request body parse error: failed to read rejection body: {err}");
293 return (
294 status,
295 axum::Json(serde_json::json!({
296 "ret_type": -1,
297 "ret_msg": msg,
298 "error": msg,
299 })),
300 )
301 .into_response();
302 }
303 };
304 let raw = String::from_utf8_lossy(&bytes);
305 let msg = format!("REST request body parse error: {}", raw.trim());
306 (
307 status,
308 axum::Json(serde_json::json!({
309 "ret_type": -1,
310 "ret_msg": msg,
311 "error": msg,
312 })),
313 )
314 .into_response()
315}
316
317#[cfg(test)]
318mod tests;