1use axum::Json;
36use axum::body::Body;
37use axum::extract::Request;
38use axum::http::StatusCode;
39use axum::middleware::Next;
40use axum::response::{IntoResponse, Response};
41use prost::Message;
42use serde::de::DeserializeOwned;
43use serde_json::Value;
44
45use crate::adapter::{
46 apply_known_field_aliases, expand_symbol_shorthand, maybe_expand_flat_trd_header,
47 maybe_wrap_flat_body_as_c2s, normalize_json_keys_snake_case,
48};
49
50type StrictValidator = fn(&Value) -> Result<(), Vec<String>>;
51
52const LIST_STYLE_IGNORE: &[&str] = &["c2s.security", "c2s.owner"];
57
58fn strict_unknown_fields_error_body(path: &str, unknown_paths: Vec<String>) -> Value {
59 let message = format!(
60 "REST {} rejects unknown field(s): {}. Check for typos. \
61 v1.4.93 BUG-002 strict validation.",
62 path,
63 unknown_paths.join(", ")
64 );
65 serde_json::json!({
66 "ret_type": -1,
67 "ret_msg": message,
68 "error": message,
69 "unknown_fields": unknown_paths,
70 })
71}
72
73fn validate_qot_sub_list_style(value: &Value) -> Result<(), Vec<String>> {
74 validate_for_path_with_ignore::<futu_proto::qot_sub::Request>(value, LIST_STYLE_IGNORE)
75}
76
77fn validate_basic_qot_list_style(value: &Value) -> Result<(), Vec<String>> {
78 validate_for_path_with_ignore::<futu_proto::qot_get_basic_qot::Request>(
79 value,
80 LIST_STYLE_IGNORE,
81 )
82}
83
84fn validate_snapshot_list_style(value: &Value) -> Result<(), Vec<String>> {
85 validate_for_path_with_ignore::<futu_proto::qot_get_security_snapshot::Request>(
86 value,
87 LIST_STYLE_IGNORE,
88 )
89}
90
91fn validate_static_info_list_style(value: &Value) -> Result<(), Vec<String>> {
92 validate_for_path_with_ignore::<futu_proto::qot_get_static_info::Request>(
93 value,
94 LIST_STYLE_IGNORE,
95 )
96}
97
98pub fn validate_flow_summary_strict(user_value: &Value) -> Result<(), Vec<String>> {
99 validate_for_path_with_ignore::<futu_proto::trd_flow_summary::Request>(
100 user_value,
101 &["c2s.begin_date", "c2s.end_date"],
102 )
103}
104
105fn strict_validator_for_path(path: &str) -> Option<StrictValidator> {
111 match path {
112 "/api/history-kline" => {
114 Some(validate_for_path::<futu_proto::qot_request_history_kl::Request>)
115 }
116 "/api/subscribe" | "/api/unsubscribe" => Some(validate_qot_sub_list_style),
117 "/api/query-subscription" => {
118 Some(validate_for_path::<futu_proto::qot_get_sub_info::Request>)
119 }
120 "/api/quote" => Some(validate_basic_qot_list_style),
121 "/api/snapshot" => Some(validate_snapshot_list_style),
122 "/api/kline" => Some(validate_for_path::<futu_proto::qot_get_kl::Request>),
123 "/api/orderbook" => Some(validate_for_path::<futu_proto::qot_get_order_book::Request>),
124 "/api/broker" => Some(validate_for_path::<futu_proto::qot_get_broker::Request>),
125 "/api/ticker" => Some(validate_for_path::<futu_proto::qot_get_ticker::Request>),
126 "/api/rt" => Some(validate_for_path::<futu_proto::qot_get_rt::Request>),
127 "/api/static-info" => Some(validate_static_info_list_style),
128 "/api/plate-set" | "/api/list-plates" => {
129 Some(validate_for_path::<futu_proto::qot_get_plate_set::Request>)
130 }
131 "/api/plate-security" => {
132 Some(validate_for_path::<futu_proto::qot_get_plate_security::Request>)
133 }
134 "/api/reference" | "/api/get-reference" => {
135 Some(validate_for_path::<futu_proto::qot_get_reference::Request>)
136 }
137 "/api/owner-plate" => Some(validate_for_path::<futu_proto::qot_get_owner_plate::Request>),
138 "/api/option-chain" => Some(validate_for_path::<futu_proto::qot_get_option_chain::Request>),
139 "/api/warrant" => Some(validate_for_path::<futu_proto::qot_get_warrant::Request>),
140 "/api/capital-flow" => Some(validate_for_path::<futu_proto::qot_get_capital_flow::Request>),
141 "/api/capital-distribution" => {
142 Some(validate_for_path::<futu_proto::qot_get_capital_distribution::Request>)
143 }
144 "/api/user-security" => {
145 Some(validate_for_path::<futu_proto::qot_get_user_security::Request>)
146 }
147 "/api/stock-filter" => Some(validate_for_path::<futu_proto::qot_stock_filter::Request>),
148 "/api/ipo-list" => Some(validate_for_path::<futu_proto::qot_get_ipo_list::Request>),
149 "/api/future-info" => Some(validate_for_path::<futu_proto::qot_get_future_info::Request>),
150 "/api/market-state" => Some(validate_for_path::<futu_proto::qot_get_market_state::Request>),
151 "/api/trading-days" => {
152 Some(validate_for_path::<futu_proto::qot_request_trade_date::Request>)
153 }
154 "/api/rehab" => Some(validate_for_path::<futu_proto::qot_request_rehab::Request>),
155 "/api/suspend" => Some(validate_for_path::<futu_proto::qot_get_suspend::Request>),
156 "/api/history-kl-quota" => {
157 Some(validate_for_path::<futu_proto::qot_request_history_kl_quota::Request>)
158 }
159 "/api/used-quota" => Some(validate_for_path::<futu_proto::used_quota::Request>),
160 "/api/holding-change" => {
161 Some(validate_for_path::<futu_proto::qot_get_holding_change_list::Request>)
162 }
163 "/api/modify-user-security" => {
164 Some(validate_for_path::<futu_proto::qot_modify_user_security::Request>)
165 }
166 "/api/code-change" => Some(validate_for_path::<futu_proto::qot_get_code_change::Request>),
167 "/api/set-price-reminder" => {
168 Some(validate_for_path::<futu_proto::qot_set_price_reminder::Request>)
169 }
170 "/api/price-reminder" => {
171 Some(validate_for_path::<futu_proto::qot_get_price_reminder::Request>)
172 }
173 "/api/option-expiration-date" => {
174 Some(validate_for_path::<futu_proto::qot_get_option_expiration_date::Request>)
175 }
176 "/api/delay-statistics" => {
177 Some(validate_for_path::<futu_proto::get_delay_statistics::Request>)
178 }
179 "/api/token-state" => Some(
180 validate_for_path::<
181 futu_backend::proto_internal::futu_token_state::DaemonGetTokenStateReq,
182 >,
183 ),
184 "/api/risk-free-rate" => Some(
185 validate_for_path::<
186 futu_backend::proto_internal::risk_free_rate::DaemonGetRiskFreeRateReq,
187 >,
188 ),
189 "/api/spread-table" => Some(
190 validate_for_path::<
191 futu_backend::proto_internal::spread_table_6503::DaemonGetSpreadTableReq,
192 >,
193 ),
194 "/api/ticker-statistic" => Some(validate_ticker_statistic_strict),
195 "/api/ticker-statistic-detail" => Some(validate_ticker_statistic_detail_strict),
196
197 "/api/funds" => Some(validate_for_path::<futu_proto::trd_get_funds::Request>),
199 "/api/positions" => Some(validate_for_path::<futu_proto::trd_get_position_list::Request>),
200 "/api/orders" => Some(validate_for_path::<futu_proto::trd_get_order_list::Request>),
201 "/api/order-fills" => {
202 Some(validate_for_path::<futu_proto::trd_get_order_fill_list::Request>)
203 }
204 "/api/max-trd-qtys" => Some(validate_for_path::<futu_proto::trd_get_max_trd_qtys::Request>),
205 "/api/margin-ratio" => Some(validate_for_path::<futu_proto::trd_get_margin_ratio::Request>),
206 "/api/order" => Some(validate_for_path::<futu_proto::trd_place_order::Request>),
207 "/api/order-fee" => Some(validate_for_path::<futu_proto::trd_get_order_fee::Request>),
208 "/api/history-orders" => {
209 Some(validate_for_path::<futu_proto::trd_get_history_order_list::Request>)
210 }
211 "/api/history-order-fills" => {
212 Some(validate_for_path::<futu_proto::trd_get_history_order_fill_list::Request>)
213 }
214 "/api/sub-acc-push" | "/api/unsub-acc-push" => {
215 Some(validate_for_path::<futu_proto::trd_sub_acc_push::Request>)
216 }
217 "/api/modify-order" | "/api/cancel-all-order" => {
218 Some(validate_for_path::<futu_proto::trd_modify_order::Request>)
219 }
220 "/api/unlock-trade" => Some(validate_for_path::<futu_proto::trd_unlock_trade::Request>),
221 "/api/reconfirm-order" => {
222 Some(validate_for_path::<futu_proto::trd_reconfirm_order::Request>)
223 }
224 "/api/flow-summary" | "/api/acc-cash-flow" => Some(validate_flow_summary_strict),
225 "/api/cash-log" => Some(
226 validate_for_path::<
227 futu_backend::proto_internal::realtime_asset_log::DaemonGetCashLogReq,
228 >,
229 ),
230 "/api/cash-detail" => Some(
231 validate_for_path::<
232 futu_backend::proto_internal::realtime_asset_log::DaemonGetCashDetailReq,
233 >,
234 ),
235 "/api/biz-group" => Some(
236 validate_for_path::<
237 futu_backend::proto_internal::realtime_asset_log::DaemonGetBizGroupReq,
238 >,
239 ),
240 "/api/margin-info" => Some(
241 validate_for_path::<
242 futu_backend::proto_internal::risk_user_account_info::DaemonGetMarginInfoReq,
243 >,
244 ),
245 "/api/account-flag" => Some(
246 validate_for_path::<futu_backend::proto_internal::account_flag::DaemonGetAccountFlagReq>,
247 ),
248 "/api/bond-total-asset" => Some(
249 validate_for_path::<
250 futu_backend::proto_internal::bond_client_view::DaemonGetBondTotalAssetReq,
251 >,
252 ),
253 "/api/bond-single-asset" => Some(
254 validate_for_path::<
255 futu_backend::proto_internal::bond_client_view::DaemonGetBondSingleAssetReq,
256 >,
257 ),
258 "/api/bond-position-list" => Some(
259 validate_for_path::<
260 futu_backend::proto_internal::bond_client_view::DaemonGetBondPositionListReq,
261 >,
262 ),
263 "/api/bond-answer-state" => Some(
264 validate_for_path::<
265 futu_backend::proto_internal::bond_client_view::DaemonGetBondAnswerStateReq,
266 >,
267 ),
268 "/api/bond-trade-reminder" => Some(
269 validate_for_path::<
270 futu_backend::proto_internal::bond_client_view::DaemonGetBondTradeReminderReq,
271 >,
272 ),
273
274 "/api/admin/shutdown" | "/api/admin/reload" => Some(validate_admin_empty_body),
276 _ => None,
277 }
278}
279
280pub fn is_strict_path(path: &str) -> bool {
282 strict_validator_for_path(path).is_some()
283}
284
285const MAX_BODY_BYTES: usize = 10 * 1024 * 1024;
289
290pub async fn strict_field_validation_middleware(req: Request, next: Next) -> Response {
293 if req.method() != axum::http::Method::POST {
294 return next.run(req).await;
295 }
296 let path = req.uri().path().to_owned();
297 let Some(validator) = strict_validator_for_path(path.as_str()) else {
298 return next.run(req).await;
299 };
300
301 let (parts, body) = req.into_parts();
302 let bytes = match axum::body::to_bytes(body, MAX_BODY_BYTES).await {
303 Ok(b) => b,
304 Err(e) => {
305 return (
306 StatusCode::BAD_REQUEST,
307 Json(serde_json::json!({
308 "error": format!("failed to read request body: {e}")
309 })),
310 )
311 .into_response();
312 }
313 };
314
315 if bytes.is_empty() {
317 let req = Request::from_parts(parts, Body::from(bytes));
318 return next.run(req).await;
319 }
320
321 let mut user_value: Value = match serde_json::from_slice(&bytes) {
323 Ok(v) => v,
324 Err(e) => {
325 return (
326 StatusCode::BAD_REQUEST,
327 Json(serde_json::json!({
328 "error": format!("invalid JSON body: {e}")
329 })),
330 )
331 .into_response();
332 }
333 };
334
335 if path == "/api/unlock-trade" {
345 crate::routes::trd::apply_unlock_trade_otp_aliases(&mut user_value);
346 }
347
348 let validation_err = validator(&user_value);
349
350 if let Err(unknown_paths) = validation_err {
351 return (
352 StatusCode::BAD_REQUEST,
353 Json(strict_unknown_fields_error_body(&path, unknown_paths)),
354 )
355 .into_response();
356 }
357
358 let req = Request::from_parts(parts, Body::from(bytes));
360 next.run(req).await
361}
362
363fn validate_for_path<Req>(user_value: &Value) -> Result<(), Vec<String>>
370where
371 Req: Message + Default + DeserializeOwned + serde::Serialize,
372{
373 validate_for_path_with_ignore::<Req>(user_value, &[])
374}
375
376pub fn validate_ticker_statistic_strict(user_value: &Value) -> Result<(), Vec<String>> {
395 let mut normalized_for_check = user_value.clone();
396 normalize_json_keys_snake_case(&mut normalized_for_check);
397 let nested_owner = normalized_for_check
398 .get("c2s")
399 .and_then(|c| c.get("owner"))
400 .is_some();
401 let flat_owner = normalized_for_check.get("owner").is_some();
402 if nested_owner || flat_owner {
403 let path = if nested_owner { "c2s.owner" } else { "owner" };
404 return Err(vec![path.to_string()]);
405 }
406 validate_for_path_with_ignore::<
414 futu_backend::proto_internal::ticker_statistic_daemon::DaemonGetTickerStatisticReq,
415 >(user_value, &["c2s.owner", "c2s.security_list"])
416}
417
418pub fn validate_ticker_statistic_detail_strict(user_value: &Value) -> Result<(), Vec<String>> {
425 let mut normalized_for_check = user_value.clone();
426 normalize_json_keys_snake_case(&mut normalized_for_check);
427 let nested_owner = normalized_for_check
428 .get("c2s")
429 .and_then(|c| c.get("owner"))
430 .is_some();
431 let flat_owner = normalized_for_check.get("owner").is_some();
432 if nested_owner || flat_owner {
433 let path = if nested_owner { "c2s.owner" } else { "owner" };
434 return Err(vec![path.to_string()]);
435 }
436 validate_for_path_with_ignore::<
437 futu_backend::proto_internal::ticker_statistic_daemon::DaemonGetTickerStatisticDetailReq,
438 >(user_value, &["c2s.owner", "c2s.security_list"])
439}
440
441pub fn validate_admin_empty_body(user_value: &Value) -> Result<(), Vec<String>> {
453 match user_value {
454 Value::Object(map) if map.is_empty() => Ok(()),
456 Value::Null => Ok(()),
457 Value::Object(map) => Err(map.keys().cloned().collect()),
459 Value::Array(_) => Err(vec!["<root: array not allowed>".to_string()]),
461 Value::String(_) | Value::Number(_) | Value::Bool(_) => {
462 Err(vec!["<root: scalar not allowed>".to_string()])
463 }
464 }
465}
466
467fn validate_for_path_with_ignore<Req>(
472 user_value: &Value,
473 ignore_paths: &[&str],
474) -> Result<(), Vec<String>>
475where
476 Req: Message + Default + DeserializeOwned + serde::Serialize,
477{
478 let mut normalized = user_value.clone();
479 normalize_json_keys_snake_case(&mut normalized);
480 apply_known_field_aliases(&mut normalized);
481 maybe_wrap_flat_body_as_c2s(&mut normalized);
482 maybe_expand_flat_trd_header(&mut normalized);
483 let _ = expand_symbol_shorthand(&mut normalized);
487
488 let req: Req = match serde_json::from_value(normalized.clone()) {
491 Ok(r) => r,
492 Err(_) => return Ok(()),
493 };
494 let canonical = match serde_json::to_value(&req) {
495 Ok(v) => v,
496 Err(_) => return Ok(()),
497 };
498
499 let mut unknown = Vec::new();
500 collect_unknown_field_paths(&normalized, &canonical, "", &mut unknown);
501 if !ignore_paths.is_empty() {
503 unknown.retain(|p| !ignore_paths.contains(&p.as_str()));
504 }
505 if unknown.is_empty() {
506 Ok(())
507 } else {
508 Err(unknown)
509 }
510}
511
512fn collect_unknown_field_paths(
516 orig: &Value,
517 accepted: &Value,
518 prefix: &str,
519 out: &mut Vec<String>,
520) {
521 match (orig, accepted) {
522 (Value::Object(o), Value::Object(a)) => {
523 for (k, v) in o {
524 let path = if prefix.is_empty() {
525 k.clone()
526 } else {
527 format!("{prefix}.{k}")
528 };
529 match a.get(k) {
530 Some(av) => collect_unknown_field_paths(v, av, &path, out),
531 None => out.push(path),
532 }
533 }
534 }
535 (Value::Array(o_arr), Value::Array(a_arr)) => {
536 if let Some(a_first) = a_arr.first() {
540 for (i, o_item) in o_arr.iter().enumerate() {
541 let path = format!("{prefix}[{i}]");
542 collect_unknown_field_paths(o_item, a_first, &path, out);
543 }
544 }
545 }
546 _ => {}
548 }
549}
550
551#[cfg(test)]
552mod tests;