1use std::sync::Arc;
7
8mod cors;
9mod metrics;
10mod probes;
11mod startup;
12
13use axum::Router;
14use axum::body::to_bytes;
15use axum::http::{StatusCode, header};
16use axum::middleware::Next;
17use axum::response::{IntoResponse, Response};
18use axum::routing::{get, post};
19use futu_auth::{KeyStore, RuntimeCounters};
20
21use futu_server::router::RequestRouter;
22
23use crate::adapter::RestState;
24use crate::auth::{AuthState, bearer_auth};
25use crate::routes::{admin, qot, sys, trd};
26use crate::ws::{self, WsBroadcaster};
27
28use metrics::metrics_handler;
29use probes::{health_handler, readyz_handler};
30pub use startup::{
31 start_with_auth, start_with_auth_and_admin, start_with_auth_full_admin,
32 start_with_auth_full_admin_until_shutdown,
33};
34
35#[derive(Default)]
40pub struct RestAdminHooks {
41 pub admin_status_provider: Option<crate::adapter::AdminStatusProvider>,
42 pub admin_shutdown_handler: Option<crate::adapter::AdminShutdownHandler>,
43 pub admin_reload_handler: Option<crate::adapter::AdminReloadHandler>,
44 pub push_health_snapshot_provider: Option<crate::adapter::PushHealthSnapshotProvider>,
45 pub card_num_resolver: Option<crate::adapter::CardNumResolver>,
46}
47
48pub(crate) const LEGACY_WS_WARN_MESSAGE: &str = "WS endpoint /ws also accepts unauthenticated connections in legacy mode — \
53 same posture as REST mutating-blocked: legacy clients may push to /ws without auth. \
54 Migrate to --rest-keys-file for production. v2 will default-reject.";
55
56#[cfg(test)]
62pub(crate) fn build_legacy_readonly_router(
63 router: Arc<RequestRouter>,
64 ws_broadcaster: Arc<WsBroadcaster>,
65) -> Router {
66 build_router_with_auth(
67 router,
68 ws_broadcaster,
69 Arc::new(KeyStore::empty()),
70 Arc::new(RuntimeCounters::new()),
71 )
72}
73
74pub fn build_router_with_auth(
81 router: Arc<RequestRouter>,
82 ws_broadcaster: Arc<WsBroadcaster>,
83 key_store: Arc<KeyStore>,
84 counters: Arc<RuntimeCounters>,
85) -> Router {
86 build_router_with_auth_and_admin(router, ws_broadcaster, key_store, counters, None)
87}
88
89pub fn build_router_with_auth_and_admin(
96 router: Arc<RequestRouter>,
97 ws_broadcaster: Arc<WsBroadcaster>,
98 key_store: Arc<KeyStore>,
99 counters: Arc<RuntimeCounters>,
100 admin_status_provider: Option<crate::adapter::AdminStatusProvider>,
101) -> Router {
102 build_router_with_auth_full_admin(
103 router,
104 ws_broadcaster,
105 key_store,
106 counters,
107 RestAdminHooks {
108 admin_status_provider,
109 ..RestAdminHooks::default()
110 },
111 )
112}
113
114pub fn build_router_with_auth_full_admin(
119 router: Arc<RequestRouter>,
120 ws_broadcaster: Arc<WsBroadcaster>,
121 key_store: Arc<KeyStore>,
122 counters: Arc<RuntimeCounters>,
123 hooks: RestAdminHooks,
124) -> Router {
125 let RestAdminHooks {
126 admin_status_provider,
127 admin_shutdown_handler,
128 admin_reload_handler,
129 push_health_snapshot_provider,
130 card_num_resolver,
131 } = hooks;
132 let mut state = RestState::with_auth(
133 router,
134 ws_broadcaster,
135 Arc::clone(&key_store),
136 Arc::clone(&counters),
137 );
138 if let Some(p) = admin_status_provider {
139 state = state.with_admin_status_provider(p);
140 }
141 if let Some(h) = admin_shutdown_handler {
142 state = state.with_admin_shutdown_handler(h);
143 }
144 if let Some(h) = admin_reload_handler {
145 state = state.with_admin_reload_handler(h);
146 }
147 if let Some(p) = push_health_snapshot_provider {
148 state = state.with_push_health_snapshot_provider(p);
149 }
150 if let Some(r) = card_num_resolver {
151 state = state.with_card_num_resolver(r);
152 }
153 let auth_state = AuthState::new(Arc::clone(&key_store), Arc::clone(&counters));
154
155 let cors = cors::build_cors_layer(&key_store);
156
157 Router::new()
158 .route("/ws", get(ws::ws_handler))
160 .route("/api/global-state", get(sys::get_global_state))
162 .route("/api/user-info", get(sys::get_user_info))
163 .route("/api/quote-rights", get(sys::get_quote_rights))
164 .route("/api/quote-capability", get(sys::get_quote_capability))
165 .route(
166 "/api/delay-statistics",
167 get(sys::get_delay_statistics).post(sys::get_delay_statistics_post),
168 )
169 .route("/api/ping", get(sys::ping))
171 .route("/api/push-subscriber-info", get(sys::push_subscriber_info))
172 .route("/api/unsub-acc-push", post(sys::unsub_acc_push))
173 .route(
175 "/api/token-state",
176 get(sys::get_token_state).post(sys::get_token_state),
177 )
178 .route("/api/subscribe", post(qot::subscribe))
180 .route("/api/sub-info", get(qot::get_sub_info))
181 .route("/api/query-subscription", post(qot::query_subscription))
183 .route("/api/list-plates", post(qot::list_plates))
185 .route("/api/quote", post(qot::get_basic_qot))
186 .route("/api/kline", post(qot::get_kl))
187 .route("/api/orderbook", post(qot::get_order_book))
188 .route("/api/broker", post(qot::get_broker))
189 .route("/api/ticker", post(qot::get_ticker))
190 .route("/api/rt", post(qot::get_rt))
191 .route("/api/snapshot", post(qot::get_snapshot))
192 .route("/api/static-info", post(qot::get_static_info))
193 .route("/api/plate-set", post(qot::get_plate_set))
194 .route("/api/plate-security", post(qot::get_plate_security))
195 .route("/api/reference", post(qot::get_reference))
196 .route("/api/get-reference", post(qot::get_reference))
198 .route("/api/owner-plate", post(qot::get_owner_plate))
199 .route("/api/option-chain", post(qot::get_option_chain))
200 .route("/api/warrant", post(qot::get_warrant))
201 .route("/api/capital-flow", post(qot::get_capital_flow))
202 .route(
203 "/api/capital-distribution",
204 post(qot::get_capital_distribution),
205 )
206 .route("/api/company-profile", post(qot::get_company_profile))
207 .route("/api/company-executives", post(qot::get_company_executives))
208 .route(
209 "/api/company-executive-background",
210 post(qot::get_company_executive_background),
211 )
212 .route(
213 "/api/company-operational-efficiency",
214 post(qot::get_company_operational_efficiency),
215 )
216 .route(
217 "/api/financials-earnings-price-move",
218 post(qot::get_financials_earnings_price_move),
219 )
220 .route(
221 "/api/financials-earnings-price-history",
222 post(qot::get_financials_earnings_price_history),
223 )
224 .route(
225 "/api/financials-statements",
226 post(qot::get_financials_statements),
227 )
228 .route(
229 "/api/financials-revenue-breakdown",
230 post(qot::get_financials_revenue_breakdown),
231 )
232 .route(
233 "/api/research-analyst-consensus",
234 post(qot::get_research_analyst_consensus),
235 )
236 .route(
237 "/api/research-rating-summary",
238 post(qot::get_research_rating_summary),
239 )
240 .route(
241 "/api/research-morningstar-report",
242 post(qot::get_research_morningstar_report),
243 )
244 .route("/api/valuation-detail", post(qot::get_valuation_detail))
245 .route(
246 "/api/valuation-plate-stock-list",
247 post(qot::get_valuation_plate_stock_list),
248 )
249 .route(
250 "/api/corporate-actions-buybacks",
251 post(qot::get_corporate_actions_buybacks),
252 )
253 .route(
254 "/api/corporate-actions-dividends",
255 post(qot::get_corporate_actions_dividends),
256 )
257 .route(
258 "/api/corporate-actions-stock-splits",
259 post(qot::get_corporate_actions_stock_splits),
260 )
261 .route("/api/daily-short-volume", post(qot::get_daily_short_volume))
262 .route("/api/short-interest", post(qot::get_short_interest))
263 .route(
264 "/api/top-ten-buy-sell-brokers",
265 post(qot::get_top_ten_buy_sell_brokers),
266 )
267 .route(
268 "/api/shareholders-overview",
269 post(qot::get_shareholders_overview),
270 )
271 .route(
272 "/api/shareholders-holding-changes",
273 post(qot::get_shareholders_holding_changes),
274 )
275 .route(
276 "/api/shareholders-holder-detail",
277 post(qot::get_shareholders_holder_detail),
278 )
279 .route(
280 "/api/shareholders-institutional",
281 post(qot::get_shareholders_institutional),
282 )
283 .route(
284 "/api/insider-holder-list",
285 post(qot::get_insider_holder_list),
286 )
287 .route("/api/insider-trade-list", post(qot::get_insider_trade_list))
288 .route("/api/option-volatility", post(qot::get_option_volatility))
289 .route(
290 "/api/option-exercise-probability",
291 post(qot::get_option_exercise_probability),
292 )
293 .route("/api/option-quote", post(qot::get_option_quote))
294 .route("/api/option-strategy", post(qot::get_option_strategy))
295 .route(
296 "/api/option-strategy-analysis",
297 post(qot::get_option_strategy_analysis),
298 )
299 .route(
300 "/api/option-strategy-spread",
301 post(qot::get_option_strategy_spread),
302 )
303 .route("/api/stock-screen", post(qot::stock_screen))
304 .route("/api/option-screen", post(qot::option_screen))
305 .route("/api/warrant-screen", post(qot::warrant_screen))
306 .route("/api/technical-unusual", post(qot::get_technical_unusual))
307 .route("/api/financial-unusual", post(qot::get_financial_unusual))
308 .route("/api/derivative-unusual", post(qot::get_derivative_unusual))
309 .route("/api/financial-calendar", post(qot::get_financial_calendar))
310 .route(
311 "/api/financial-calendar-target",
312 post(qot::search_target_financial_calendar),
313 )
314 .route("/api/earnings-calendar", post(qot::get_earnings_calendar))
315 .route(
316 "/api/macro-indicator-list",
317 post(qot::get_macro_indicator_list),
318 )
319 .route(
320 "/api/macro-indicator-history",
321 post(qot::get_macro_indicator_history),
322 )
323 .route(
324 "/api/fed-watch-target-rate",
325 post(qot::get_fed_watch_target_rate),
326 )
327 .route("/api/fed-watch-dot-plot", post(qot::get_fed_watch_dot_plot))
328 .route("/api/earnings-beat-rank", post(qot::get_earnings_beat_rank))
329 .route("/api/dividend-rank", post(qot::get_dividend_rank))
330 .route("/api/dividend-calendar", post(qot::get_dividend_calendar))
331 .route("/api/economic-calendar", post(qot::get_economic_calendar))
332 .route("/api/us-pre-market-rank", post(qot::get_us_pre_market_rank))
333 .route(
334 "/api/us-after-hours-rank",
335 post(qot::get_us_after_hours_rank),
336 )
337 .route("/api/us-overnight-rank", post(qot::get_us_overnight_rank))
338 .route("/api/top-movers-rank", post(qot::get_top_movers_rank))
339 .route("/api/hot-list", post(qot::get_hot_list))
340 .route("/api/short-selling-rank", post(qot::get_short_selling_rank))
341 .route("/api/period-change-rank", post(qot::get_period_change_rank))
342 .route(
343 "/api/high-dividend-soe-rank",
344 post(qot::get_high_dividend_soe_rank),
345 )
346 .route("/api/institution-list", post(qot::get_institution_list))
347 .route(
348 "/api/institution-profile",
349 post(qot::get_institution_profile),
350 )
351 .route(
352 "/api/institution-distribution",
353 post(qot::get_institution_distribution),
354 )
355 .route(
356 "/api/institution-holding-change",
357 post(qot::get_institution_holding_change),
358 )
359 .route(
360 "/api/institution-holding-list",
361 post(qot::get_institution_holding_list),
362 )
363 .route("/api/ark-fund-holding", post(qot::get_ark_fund_holding))
364 .route("/api/ark-stock-dynamic", post(qot::get_ark_stock_dynamic))
365 .route(
366 "/api/ark-active-transaction",
367 post(qot::get_ark_active_transaction),
368 )
369 .route("/api/rating-change", post(qot::get_rating_change))
370 .route(
371 "/api/industrial-chain-list",
372 post(qot::get_industrial_chain_list),
373 )
374 .route(
375 "/api/industrial-chain-detail",
376 post(qot::get_industrial_chain_detail),
377 )
378 .route(
379 "/api/industrial-chain-by-plate",
380 post(qot::get_industrial_chain_by_plate),
381 )
382 .route(
383 "/api/industrial-plate-info",
384 post(qot::get_industrial_plate_info),
385 )
386 .route(
387 "/api/industrial-plate-stock",
388 post(qot::get_industrial_plate_stock),
389 )
390 .route("/api/heat-map-data", post(qot::get_heat_map_data))
391 .route(
392 "/api/rise-fall-distribution",
393 post(qot::get_rise_fall_distribution),
394 )
395 .route("/api/user-security", post(qot::get_user_security))
396 .route(
397 "/api/user-security-groups",
398 post(qot::get_user_security_group),
399 )
400 .route("/api/stock-filter", post(qot::stock_filter))
401 .route("/api/ipo-list", post(qot::get_ipo_list))
402 .route("/api/ipo-calendar", post(qot::get_ipo_calendar))
403 .route("/api/future-info", post(qot::get_future_info))
404 .route("/api/market-state", post(qot::get_market_state))
405 .route("/api/history-kline", post(qot::request_history_kl))
406 .route("/api/trading-days", post(qot::request_trading_days))
408 .route("/api/rehab", post(qot::request_rehab))
409 .route("/api/suspend", post(qot::get_suspend))
410 .route("/api/history-kl-quota", post(qot::request_history_kl_quota))
412 .route("/api/used-quota", post(qot::get_used_quota))
413 .route("/api/holding-change", post(qot::get_holding_change))
414 .route("/api/modify-user-security", post(qot::modify_user_security))
415 .route("/api/code-change", post(qot::get_code_change))
416 .route("/api/set-price-reminder", post(qot::set_price_reminder))
417 .route("/api/price-reminder", post(qot::get_price_reminder))
418 .route(
419 "/api/option-expiration-date",
420 post(qot::get_option_expiration_date),
421 )
422 .route("/api/unsubscribe", post(qot::unsubscribe))
423 .route(
425 "/api/risk-free-rate",
426 get(qot::get_risk_free_rate).post(qot::get_risk_free_rate),
427 )
428 .route(
430 "/api/spread-table",
431 get(qot::get_spread_table).post(qot::get_spread_table),
432 )
433 .route("/api/ticker-statistic", post(qot::get_ticker_statistic))
435 .route(
437 "/api/ticker-statistic-detail",
438 post(qot::get_ticker_statistic_detail),
439 )
440 .route("/api/flow-summary", post(trd::get_flow_summary))
441 .route("/api/acc-cash-flow", post(trd::get_flow_summary))
445 .route("/api/cash-log", post(trd::get_cash_log))
450 .route("/api/cash-detail", post(trd::get_cash_detail))
451 .route("/api/biz-group", post(trd::get_biz_group))
452 .route("/api/margin-info", post(trd::get_margin_info))
458 .route("/api/account-flag", post(trd::get_account_flag))
462 .route("/api/bond-total-asset", post(trd::get_bond_total_asset))
467 .route("/api/bond-single-asset", post(trd::get_bond_single_asset))
468 .route("/api/bond-position-list", post(trd::get_bond_position_list))
469 .route("/api/bond-answer-state", post(trd::get_bond_answer_state))
470 .route(
471 "/api/bond-trade-reminder",
472 post(trd::get_bond_trade_reminder),
473 )
474 .route("/api/accounts", get(trd::get_acc_list))
476 .route("/api/list-accounts", get(trd::get_acc_list))
478 .route("/api/unlock-trade", post(trd::unlock_trade))
479 .route("/api/sub-acc-push", post(trd::sub_acc_push))
480 .route("/api/funds", post(trd::get_funds))
481 .route("/api/positions", post(trd::get_positions))
482 .route("/api/orders", post(trd::get_orders))
483 .route("/api/order", post(trd::place_order))
484 .route("/api/combo-order", post(trd::place_combo_order))
485 .route("/api/modify-order", post(trd::modify_order))
486 .route("/api/cancel-order", post(trd::cancel_order))
487 .route("/api/cancel-all-order", post(trd::cancel_all_order))
489 .route("/api/order-fills", post(trd::get_order_fills))
490 .route("/api/max-trd-qtys", post(trd::get_max_trd_qtys))
491 .route("/api/combo-max-trd-qtys", post(trd::get_combo_max_trd_qtys))
492 .route("/api/reconfirm-order", post(trd::reconfirm_order))
494 .route("/api/history-orders", post(trd::get_history_orders))
495 .route(
496 "/api/history-order-fills",
497 post(trd::get_history_order_fills),
498 )
499 .route("/api/margin-ratio", post(trd::get_margin_ratio))
500 .route("/api/order-fee", post(trd::get_order_fee))
501 .route("/api/admin/status", get(admin::admin_status))
520 .route("/api/admin/shutdown", post(admin::admin_shutdown))
521 .route("/api/admin/reload", post(admin::admin_reload))
522 .layer(axum::middleware::from_fn(
528 crate::strict_fields::strict_field_validation_middleware,
529 ))
530 .layer(axum::middleware::from_fn_with_state(
531 auth_state,
532 bearer_auth,
533 ))
534 .layer(axum::middleware::from_fn(rest_error_envelope_middleware))
535 .route("/metrics", get(metrics_handler))
543 .route("/health", get(health_handler))
544 .route("/readyz", get(readyz_handler))
545 .fallback(unknown_route_fallback)
553 .layer(cors)
554 .with_state(state)
555}
556
557async fn unknown_route_fallback(req: axum::extract::Request) -> impl IntoResponse {
560 let path = req.uri().path().to_string();
561 let method = req.method().to_string();
562 (
563 axum::http::StatusCode::NOT_FOUND,
564 [(axum::http::header::CONTENT_TYPE, "application/json")],
565 axum::Json(serde_json::json!({
566 "error": format!("unknown route {method} {path:?}"),
567 "hint": "see categories below or full reference at https://www.futuapi.com/reference/rest-api/",
568 "categories": {
569 "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",
570 "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",
571 "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",
572 "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)",
573 "infra": "/health (liveness) /readyz (readiness) /metrics (Prometheus) /ws (WebSocket push)"
574 },
575 "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."
576 })),
577 )
578}
579
580async fn rest_error_envelope_middleware(req: axum::extract::Request, next: Next) -> Response {
586 let resp = next.run(req).await;
587 let status = resp.status();
588 if !matches!(
589 status,
590 StatusCode::BAD_REQUEST | StatusCode::UNSUPPORTED_MEDIA_TYPE
591 ) {
592 return resp;
593 }
594 let content_type = resp
595 .headers()
596 .get(header::CONTENT_TYPE)
597 .and_then(|v| v.to_str().ok())
598 .unwrap_or("");
599 if !content_type.starts_with("text/plain") {
600 return resp;
601 }
602
603 let bytes = match to_bytes(resp.into_body(), 64 * 1024).await {
604 Ok(bytes) => bytes,
605 Err(err) => {
606 let msg =
607 format!("REST request body parse error: failed to read rejection body: {err}");
608 return (
609 status,
610 axum::Json(serde_json::json!({
611 "ret_type": -1,
612 "ret_msg": msg,
613 "error": msg,
614 })),
615 )
616 .into_response();
617 }
618 };
619 let raw = String::from_utf8_lossy(&bytes);
620 let msg = format!("REST request body parse error: {}", raw.trim());
621 (
622 status,
623 axum::Json(serde_json::json!({
624 "ret_type": -1,
625 "ret_msg": msg,
626 "error": msg,
627 })),
628 )
629 .into_response()
630}
631
632#[cfg(test)]
633mod tests;