Skip to main content

futu_rest/
strict_fields.rs

1//! v1.4.93 P0-2 (BUG-002): REST unknown-field validation for strict POST routes.
2//!
3//! ## Problem
4//!
5//! REST endpoint typo fields (e.g. `xyzzy_bogus` / `begin_timme`) historically
6//! could be silently accepted by generated proto JSON structs.
7//!
8//! Root cause (CLAUDE.md pitfall #30): proto-build attached `#[serde(default)]`
9//! globally to all messages without `deny_unknown_fields`, so serde dropped
10//! unknown fields. Typos did not 400, daemon executed with default zero values,
11//! and could return ret_type=0 + empty data (silent-success anti-pattern,
12//! pitfall #45).
13//!
14//! ## Fix
15//!
16//! Axum middleware that intercepts request body for strict validator registry paths,
17//! deserializes to typed Request struct, re-serializes to canonical JSON, and
18//! recursively walks both Values to detect any keys in user input not in the
19//! re-serialized typed shape. Unknown -> 400 BAD_REQUEST with explanatory hint.
20//!
21//! The contract source is the strict validator registry below. Regression tests
22//! require all `EndpointSpec`-declared POST routes registered by REST server code
23//! to appear in this registry. Generated prost structs now also use
24//! `deny_unknown_fields`, but REST keeps this adapter-layer validator to produce
25//! stable user-facing 400 envelopes and to run after field alias normalization.
26//!
27//! ## Limitations
28//!
29//! - Vec/repeated fields cannot be schema-validated for inner keys when default
30//!   instantiated (default Vec is empty). Top-level + first-level nested object
31//!   typos (the BUG-002 case) ARE caught.
32//! - Validation runs AFTER `normalize_json_keys_snake_case` /
33//!   `apply_known_field_aliases` (replicated here to mimic adapter pre-processing)
34//!   so camelCase/aliased names don't false-trigger.
35
36use axum::Json;
37use axum::body::Body;
38use axum::extract::Request;
39use axum::http::StatusCode;
40use axum::middleware::Next;
41use axum::response::{IntoResponse, Response};
42use futu_core::proto_id;
43use prost::Message;
44use serde::de::DeserializeOwned;
45use serde_json::Value;
46
47use crate::adapter::{
48    apply_known_field_aliases_for_proto_id, expand_symbol_shorthand, maybe_expand_flat_trd_header,
49    maybe_wrap_flat_body_as_c2s, normalize_json_keys_snake_case,
50};
51
52type StrictValidator = fn(&Value) -> Result<(), Vec<String>>;
53
54// v1.4.104 codex round 2 F2 + P1-001 follow-up: list-style endpoints
55// (security_list 主字段) 让 adapter 的 single-symbol shorthand path 生成
56// c2s.security + c2s.owner orphan objects. 这些是 adapter 生成的副产品,
57// 不应当按用户 typo 拒绝。
58const LIST_STYLE_IGNORE: &[&str] = &["c2s.security", "c2s.owner"];
59const SYMBOL_SHORTHAND_OWNER_IGNORE: &[&str] = &["c2s.owner"];
60
61fn strict_unknown_fields_error_body(path: &str, unknown_paths: Vec<String>) -> Value {
62    let message = format!(
63        "REST {} rejects unknown field(s): {}. Check for typos. \
64         v1.4.93 BUG-002 strict validation.",
65        path,
66        unknown_paths.join(", ")
67    );
68    serde_json::json!({
69        "ret_type": -1,
70        "ret_msg": message,
71        "error": message,
72        "unknown_fields": unknown_paths,
73    })
74}
75
76fn validate_qot_sub_list_style(value: &Value) -> Result<(), Vec<String>> {
77    validate_for_path_with_ignore::<futu_proto::qot_sub::Request>(value, LIST_STYLE_IGNORE)
78}
79
80fn validate_basic_qot_list_style(value: &Value) -> Result<(), Vec<String>> {
81    validate_for_path_with_ignore::<futu_proto::qot_get_basic_qot::Request>(
82        value,
83        LIST_STYLE_IGNORE,
84    )
85}
86
87fn validate_snapshot_list_style(value: &Value) -> Result<(), Vec<String>> {
88    validate_for_path_with_ignore::<futu_proto::qot_get_security_snapshot::Request>(
89        value,
90        LIST_STYLE_IGNORE,
91    )
92}
93
94fn validate_static_info_list_style(value: &Value) -> Result<(), Vec<String>> {
95    validate_for_path_with_ignore::<futu_proto::qot_get_static_info::Request>(
96        value,
97        LIST_STYLE_IGNORE,
98    )
99}
100
101pub fn validate_flow_summary_strict(user_value: &Value) -> Result<(), Vec<String>> {
102    validate_for_path_with_ignore::<futu_proto::trd_flow_summary::Request>(
103        user_value,
104        &["c2s.begin_date", "c2s.end_date"],
105    )
106}
107
108fn validate_warrant_strict(value: &Value) -> Result<(), Vec<String>> {
109    validate_for_path_with_proto::<futu_proto::qot_get_warrant::Request>(
110        value,
111        proto_id::QOT_GET_WARRANT,
112    )
113}
114
115fn validate_stock_filter_strict(value: &Value) -> Result<(), Vec<String>> {
116    validate_for_path_with_proto::<futu_proto::qot_stock_filter::Request>(
117        value,
118        proto_id::QOT_STOCK_FILTER,
119    )
120}
121
122fn validate_future_info_strict(value: &Value) -> Result<(), Vec<String>> {
123    validate_for_path_with_proto::<futu_proto::qot_get_future_info::Request>(
124        value,
125        proto_id::QOT_GET_FUTURE_INFO,
126    )
127}
128
129fn validate_valuation_detail_strict(value: &Value) -> Result<(), Vec<String>> {
130    validate_for_path_with_proto::<futu_proto::qot_get_valuation_detail::Request>(
131        value,
132        proto_id::QOT_GET_VALUATION_DETAIL,
133    )
134}
135
136fn validate_delay_statistics_strict(value: &Value) -> Result<(), Vec<String>> {
137    validate_for_path_with_proto::<futu_proto::get_delay_statistics::Request>(
138        value,
139        proto_id::GET_DELAY_STATISTICS,
140    )
141}
142
143/// Return the strict unknown-field validator for a REST path.
144///
145/// v1.4.110 surface-spec runtime enforcement: this replaces the old parallel
146/// `STRICT_PATHS` list. `is_strict_path` and the middleware now share the same
147/// registry, while tests assert every `EndpointSpec` REST POST route is covered.
148fn strict_validator_for_path(path: &str) -> Option<StrictValidator> {
149    match path {
150        // qot endpoints
151        "/api/history-kline" => {
152            Some(validate_for_path::<futu_proto::qot_request_history_kl::Request>)
153        }
154        "/api/subscribe" | "/api/unsubscribe" => Some(validate_qot_sub_list_style),
155        "/api/query-subscription" => {
156            Some(validate_for_path::<futu_proto::qot_get_sub_info::Request>)
157        }
158        "/api/quote" => Some(validate_basic_qot_list_style),
159        "/api/snapshot" => Some(validate_snapshot_list_style),
160        "/api/kline" => Some(validate_for_path::<futu_proto::qot_get_kl::Request>),
161        "/api/orderbook" => Some(validate_for_path::<futu_proto::qot_get_order_book::Request>),
162        "/api/broker" => Some(validate_for_path::<futu_proto::qot_get_broker::Request>),
163        "/api/ticker" => Some(validate_for_path::<futu_proto::qot_get_ticker::Request>),
164        "/api/rt" => Some(validate_for_path::<futu_proto::qot_get_rt::Request>),
165        "/api/static-info" => Some(validate_static_info_list_style),
166        "/api/plate-set" | "/api/list-plates" => {
167            Some(validate_for_path::<futu_proto::qot_get_plate_set::Request>)
168        }
169        "/api/plate-security" => {
170            Some(validate_for_path::<futu_proto::qot_get_plate_security::Request>)
171        }
172        "/api/reference" | "/api/get-reference" => {
173            Some(validate_for_path::<futu_proto::qot_get_reference::Request>)
174        }
175        "/api/owner-plate" => Some(validate_for_path::<futu_proto::qot_get_owner_plate::Request>),
176        "/api/option-chain" => Some(validate_for_path::<futu_proto::qot_get_option_chain::Request>),
177        "/api/warrant" => Some(validate_warrant_strict),
178        "/api/capital-flow" => Some(validate_for_path::<futu_proto::qot_get_capital_flow::Request>),
179        "/api/capital-distribution" => {
180            Some(validate_for_path::<futu_proto::qot_get_capital_distribution::Request>)
181        }
182        "/api/company-profile" => {
183            Some(validate_for_path::<futu_proto::qot_get_company_profile::Request>)
184        }
185        "/api/company-executives" => {
186            Some(validate_for_path::<futu_proto::qot_get_company_executives::Request>)
187        }
188        "/api/company-executive-background" => {
189            Some(validate_for_path::<futu_proto::qot_get_company_executive_background::Request>)
190        }
191        "/api/company-operational-efficiency" => {
192            Some(validate_for_path::<futu_proto::qot_get_company_operational_efficiency::Request>)
193        }
194        "/api/financials-earnings-price-move" => {
195            Some(validate_for_path::<futu_proto::qot_get_financials_earnings_price_move::Request>)
196        }
197        "/api/financials-earnings-price-history" => Some(
198            validate_for_path::<futu_proto::qot_get_financials_earnings_price_history::Request>,
199        ),
200        "/api/financials-statements" => {
201            Some(validate_for_path::<futu_proto::qot_get_financials_statements::Request>)
202        }
203        "/api/financials-revenue-breakdown" => {
204            Some(validate_for_path::<futu_proto::qot_get_financials_revenue_breakdown::Request>)
205        }
206        "/api/research-analyst-consensus" => {
207            Some(validate_for_path::<futu_proto::qot_get_research_analyst_consensus::Request>)
208        }
209        "/api/research-rating-summary" => {
210            Some(validate_for_path::<futu_proto::qot_get_research_rating_summary::Request>)
211        }
212        "/api/research-morningstar-report" => {
213            Some(validate_for_path::<futu_proto::qot_get_research_morningstar_report::Request>)
214        }
215        "/api/valuation-detail" => Some(validate_valuation_detail_strict),
216        "/api/valuation-plate-stock-list" => {
217            Some(validate_for_path::<futu_proto::qot_get_valuation_plate_stock_list::Request>)
218        }
219        "/api/corporate-actions-buybacks" => {
220            Some(validate_for_path::<futu_proto::qot_get_corporate_actions_buybacks::Request>)
221        }
222        "/api/corporate-actions-dividends" => {
223            Some(validate_for_path::<futu_proto::qot_get_corporate_actions_dividends::Request>)
224        }
225        "/api/corporate-actions-stock-splits" => {
226            Some(validate_for_path::<futu_proto::qot_get_corporate_actions_stock_splits::Request>)
227        }
228        "/api/daily-short-volume" => {
229            Some(validate_for_path::<futu_proto::qot_get_daily_short_volume::Request>)
230        }
231        "/api/short-interest" => {
232            Some(validate_for_path::<futu_proto::qot_get_short_interest::Request>)
233        }
234        "/api/top-ten-buy-sell-brokers" => {
235            Some(validate_for_path::<futu_proto::qot_get_top_ten_buy_sell_brokers::Request>)
236        }
237        "/api/shareholders-overview" => {
238            Some(validate_for_path::<futu_proto::qot_get_shareholders_overview::Request>)
239        }
240        "/api/shareholders-holding-changes" => {
241            Some(validate_for_path::<futu_proto::qot_get_shareholders_holding_changes::Request>)
242        }
243        "/api/shareholders-holder-detail" => {
244            Some(validate_for_path::<futu_proto::qot_get_shareholders_holder_detail::Request>)
245        }
246        "/api/shareholders-institutional" => {
247            Some(validate_for_path::<futu_proto::qot_get_shareholders_institutional::Request>)
248        }
249        "/api/insider-holder-list" => {
250            Some(validate_for_path::<futu_proto::qot_get_insider_holder_list::Request>)
251        }
252        "/api/insider-trade-list" => {
253            Some(validate_for_path::<futu_proto::qot_get_insider_trade_list::Request>)
254        }
255        "/api/option-volatility" => {
256            Some(validate_for_path::<futu_proto::qot_get_option_volatility::Request>)
257        }
258        "/api/option-exercise-probability" => {
259            Some(validate_for_path::<futu_proto::qot_get_option_exercise_probability::Request>)
260        }
261        "/api/option-quote" => Some(validate_for_path::<futu_proto::qot_get_option_quote::Request>),
262        "/api/option-strategy" => {
263            Some(validate_for_path::<futu_proto::qot_get_option_strategy::Request>)
264        }
265        "/api/option-strategy-analysis" => {
266            Some(validate_for_path::<futu_proto::qot_get_option_strategy_analysis::Request>)
267        }
268        "/api/option-strategy-spread" => {
269            Some(validate_for_path::<futu_proto::qot_get_option_strategy_spread::Request>)
270        }
271        "/api/stock-screen" => Some(validate_for_path::<futu_proto::qot_stock_screen::Request>),
272        "/api/option-screen" => Some(validate_for_path::<futu_proto::qot_option_screen::Request>),
273        "/api/warrant-screen" => Some(validate_for_path::<futu_proto::qot_warrant_screen::Request>),
274        "/api/technical-unusual" => {
275            Some(validate_flat_for_path::<futu_proto::skill_wrap_api::TechnicalUnusualReq>)
276        }
277        "/api/financial-unusual" => {
278            Some(validate_flat_for_path::<futu_proto::skill_wrap_api::FinancialUnusualReq>)
279        }
280        "/api/derivative-unusual" => {
281            Some(validate_flat_for_path::<futu_proto::skill_wrap_api::DerivativeUnusualReq>)
282        }
283        "/api/user-security" => {
284            Some(validate_for_path::<futu_proto::qot_get_user_security::Request>)
285        }
286        "/api/stock-filter" => Some(validate_stock_filter_strict),
287        "/api/ipo-list" => Some(validate_for_path::<futu_proto::qot_get_ipo_list::Request>),
288        "/api/future-info" => Some(validate_future_info_strict),
289        "/api/market-state" => Some(validate_for_path::<futu_proto::qot_get_market_state::Request>),
290        "/api/trading-days" => {
291            Some(validate_for_path::<futu_proto::qot_request_trade_date::Request>)
292        }
293        "/api/rehab" => Some(validate_for_path::<futu_proto::qot_request_rehab::Request>),
294        "/api/suspend" => Some(validate_for_path::<futu_proto::qot_get_suspend::Request>),
295        "/api/history-kl-quota" => {
296            Some(validate_for_path::<futu_proto::qot_request_history_kl_quota::Request>)
297        }
298        "/api/used-quota" => Some(validate_for_path::<futu_proto::used_quota::Request>),
299        "/api/holding-change" => {
300            Some(validate_for_path::<futu_proto::qot_get_holding_change_list::Request>)
301        }
302        "/api/modify-user-security" => {
303            Some(validate_for_path::<futu_proto::qot_modify_user_security::Request>)
304        }
305        "/api/code-change" => Some(validate_for_path::<futu_proto::qot_get_code_change::Request>),
306        "/api/set-price-reminder" => {
307            Some(validate_for_path::<futu_proto::qot_set_price_reminder::Request>)
308        }
309        "/api/price-reminder" => {
310            Some(validate_for_path::<futu_proto::qot_get_price_reminder::Request>)
311        }
312        "/api/option-expiration-date" => {
313            Some(validate_for_path::<futu_proto::qot_get_option_expiration_date::Request>)
314        }
315        "/api/delay-statistics" => Some(validate_delay_statistics_strict),
316        "/api/token-state" => Some(
317            validate_for_path::<
318                futu_backend::proto_internal::futu_token_state::DaemonGetTokenStateReq,
319            >,
320        ),
321        "/api/risk-free-rate" => Some(
322            validate_for_path::<
323                futu_backend::proto_internal::risk_free_rate::DaemonGetRiskFreeRateReq,
324            >,
325        ),
326        "/api/spread-table" => Some(
327            validate_for_path::<
328                futu_backend::proto_internal::spread_table_6503::DaemonGetSpreadTableReq,
329            >,
330        ),
331        "/api/ticker-statistic" => Some(validate_ticker_statistic_strict),
332        "/api/ticker-statistic-detail" => Some(validate_ticker_statistic_detail_strict),
333
334        // trade endpoints
335        "/api/funds" => Some(validate_for_path::<futu_proto::trd_get_funds::Request>),
336        "/api/positions" => Some(validate_for_path::<futu_proto::trd_get_position_list::Request>),
337        "/api/orders" => Some(validate_for_path::<futu_proto::trd_get_order_list::Request>),
338        "/api/order-fills" => {
339            Some(validate_for_path::<futu_proto::trd_get_order_fill_list::Request>)
340        }
341        "/api/max-trd-qtys" => Some(validate_for_path::<futu_proto::trd_get_max_trd_qtys::Request>),
342        "/api/combo-max-trd-qtys" => {
343            Some(validate_for_path::<futu_proto::trd_get_combo_max_trd_qtys::Request>)
344        }
345        "/api/margin-ratio" => Some(validate_for_path::<futu_proto::trd_get_margin_ratio::Request>),
346        "/api/order" => {
347            Some(validate_for_path_with_symbol_owner_ignore::<futu_proto::trd_place_order::Request>)
348        }
349        "/api/combo-order" => Some(validate_for_path::<futu_proto::trd_place_combo_order::Request>),
350        "/api/order-fee" => Some(validate_for_path::<futu_proto::trd_get_order_fee::Request>),
351        "/api/history-orders" => {
352            Some(validate_for_path::<futu_proto::trd_get_history_order_list::Request>)
353        }
354        "/api/history-order-fills" => {
355            Some(validate_for_path::<futu_proto::trd_get_history_order_fill_list::Request>)
356        }
357        "/api/sub-acc-push" | "/api/unsub-acc-push" => {
358            Some(validate_for_path::<futu_proto::trd_sub_acc_push::Request>)
359        }
360        "/api/modify-order" | "/api/cancel-all-order" => {
361            Some(validate_for_path::<futu_proto::trd_modify_order::Request>)
362        }
363        "/api/unlock-trade" => Some(validate_for_path::<futu_proto::trd_unlock_trade::Request>),
364        "/api/reconfirm-order" => {
365            Some(validate_for_path::<futu_proto::trd_reconfirm_order::Request>)
366        }
367        "/api/flow-summary" | "/api/acc-cash-flow" => Some(validate_flow_summary_strict),
368        "/api/cash-log" => Some(
369            validate_for_path::<
370                futu_backend::proto_internal::realtime_asset_log::DaemonGetCashLogReq,
371            >,
372        ),
373        "/api/cash-detail" => Some(
374            validate_for_path::<
375                futu_backend::proto_internal::realtime_asset_log::DaemonGetCashDetailReq,
376            >,
377        ),
378        "/api/biz-group" => Some(
379            validate_for_path::<
380                futu_backend::proto_internal::realtime_asset_log::DaemonGetBizGroupReq,
381            >,
382        ),
383        "/api/margin-info" => Some(
384            validate_for_path::<
385                futu_backend::proto_internal::risk_user_account_info::DaemonGetMarginInfoReq,
386            >,
387        ),
388        "/api/account-flag" => Some(
389            validate_for_path::<futu_backend::proto_internal::account_flag::DaemonGetAccountFlagReq>,
390        ),
391        "/api/bond-total-asset" => Some(
392            validate_for_path::<
393                futu_backend::proto_internal::bond_client_view::DaemonGetBondTotalAssetReq,
394            >,
395        ),
396        "/api/bond-single-asset" => Some(
397            validate_for_path::<
398                futu_backend::proto_internal::bond_client_view::DaemonGetBondSingleAssetReq,
399            >,
400        ),
401        "/api/bond-position-list" => Some(
402            validate_for_path::<
403                futu_backend::proto_internal::bond_client_view::DaemonGetBondPositionListReq,
404            >,
405        ),
406        "/api/bond-answer-state" => Some(
407            validate_for_path::<
408                futu_backend::proto_internal::bond_client_view::DaemonGetBondAnswerStateReq,
409            >,
410        ),
411        "/api/bond-trade-reminder" => Some(
412            validate_for_path::<
413                futu_backend::proto_internal::bond_client_view::DaemonGetBondTradeReminderReq,
414            >,
415        ),
416
417        // daemon-local admin POST endpoints.
418        "/api/admin/shutdown" | "/api/admin/reload" => Some(validate_admin_empty_body),
419        _ => None,
420    }
421}
422
423/// Public test helper: returns true iff `path` is in the strict-validation list.
424pub fn is_strict_path(path: &str) -> bool {
425    strict_validator_for_path(path).is_some()
426}
427
428/// Body size cap for body-buffering middleware (10 MiB; same order as proto
429/// max-size guard elsewhere in adapter). Larger bodies bypass strict validation
430/// and fall through to handler — handler still applies its own size limits.
431const MAX_BODY_BYTES: usize = 10 * 1024 * 1024;
432
433/// Axum middleware: validate POST body against the typed Request schema for
434/// strict paths. Non-strict paths and non-POST methods pass through unmodified.
435pub async fn strict_field_validation_middleware(req: Request, next: Next) -> Response {
436    if req.method() != axum::http::Method::POST {
437        return next.run(req).await;
438    }
439    let path = req.uri().path().to_owned();
440    let Some(validator) = strict_validator_for_path(path.as_str()) else {
441        return next.run(req).await;
442    };
443
444    let (parts, body) = req.into_parts();
445    let bytes = match axum::body::to_bytes(body, MAX_BODY_BYTES).await {
446        Ok(b) => b,
447        Err(e) => {
448            return (
449                StatusCode::BAD_REQUEST,
450                Json(serde_json::json!({
451                    "error": format!("failed to read request body: {e}")
452                })),
453            )
454                .into_response();
455        }
456    };
457
458    // Empty body: handler will use Req::default(); no fields to validate.
459    if bytes.is_empty() {
460        let req = Request::from_parts(parts, Body::from(bytes));
461        return next.run(req).await;
462    }
463
464    // Parse user input as Value
465    let mut user_value: Value = match serde_json::from_slice(&bytes) {
466        Ok(v) => v,
467        Err(e) => {
468            return (
469                StatusCode::BAD_REQUEST,
470                Json(serde_json::json!({
471                    "error": format!("invalid JSON body: {e}")
472                })),
473            )
474                .into_response();
475        }
476    };
477
478    // v1.4.97 codex audit fix: rename OTP aliases BEFORE strict validation.
479    // Earlier v1.4.96 BUG #008 fix did the rename inside `unlock_trade`
480    // handler, but `/api/unlock-trade` is in the strict validator registry so this middleware
481    // runs first — it would see `otp` / `token` / `one_time_password` as
482    // unknown fields against `trd_unlock_trade::Request` schema and reject
483    // with 400 BEFORE the handler's rename logic can run. Apply rename
484    // pre-validation so user-friendly aliases pass strict validation.
485    // Handler still calls `apply_unlock_trade_otp_aliases` again (idempotent
486    // — sec_otp already present so alias strip is a no-op).
487    if path == "/api/unlock-trade" {
488        crate::routes::trd::apply_unlock_trade_otp_aliases(&mut user_value);
489    }
490
491    let validation_err = validator(&user_value);
492
493    if let Err(unknown_paths) = validation_err {
494        return (
495            StatusCode::BAD_REQUEST,
496            Json(strict_unknown_fields_error_body(&path, unknown_paths)),
497        )
498            .into_response();
499    }
500
501    // Restore body for downstream handler
502    let req = Request::from_parts(parts, Body::from(bytes));
503    next.run(req).await
504}
505
506/// Validate `user_value` against the typed `Req` schema. Returns `Err(unknown_paths)`
507/// if any user keys are not present after a deserialize -> reserialize roundtrip.
508///
509/// Mimics adapter pre-processing: `normalize_json_keys_snake_case` ->
510/// `apply_known_field_aliases_for_proto_id` -> `maybe_wrap_flat_body_as_c2s` ->
511/// `maybe_expand_flat_trd_header` -> `expand_symbol_shorthand` -> from_value -> to_value.
512fn validate_for_path<Req>(user_value: &Value) -> Result<(), Vec<String>>
513where
514    Req: Message + Default + DeserializeOwned + serde::Serialize,
515{
516    validate_for_path_with_ignore::<Req>(user_value, &[])
517}
518
519fn validate_for_path_with_proto<Req>(user_value: &Value, proto_id: u32) -> Result<(), Vec<String>>
520where
521    Req: Message + Default + DeserializeOwned + serde::Serialize,
522{
523    validate_for_path_with_options::<Req>(user_value, &[], true, Some(proto_id))
524}
525
526fn validate_flat_for_path<Req>(user_value: &Value) -> Result<(), Vec<String>>
527where
528    Req: Message + Default + DeserializeOwned + serde::Serialize,
529{
530    validate_for_path_with_options::<Req>(user_value, &[], false, None)
531}
532
533/// Same as `validate_for_path` but tolerates a list of dot-separated paths
534/// (e.g. `["c2s.owner"]`) — these will not be flagged as unknown even if they
535/// appear in `normalized` post-adapter-expansion but are absent from the typed
536/// `Req` shape.
537///
538/// **Use case** (codex F3 fix 2026-04-27): `/api/ticker-statistic` route uses
539/// codex 14th-round Finding 5 (P3, 2026-04-28 02:09): testable extracted
540/// branch for `/api/ticker-statistic` strict validation. Let unit test 调
541/// 真 branch 而不是手写 if/else (pitfall #54 schema-only fix).
542///
543/// 行为: 检查 user-supplied body 是否显式含 owner (4 path):
544/// (a) flat snake_case `{"owner": ...}`
545/// (b) flat PascalCase `{"Owner": ...}` (post-normalize 同 (a))
546/// (c) nested snake_case `{"c2s": {"owner": ...}}`
547/// (d) nested PascalCase `{"c2s": {"Owner": ...}}` (post-normalize 同 (c))
548///
549/// 任一命中 → loud reject `c2s.owner` / `owner`. 否 → 走 generic validator
550/// 容忍 adapter expand 后注入的 `c2s.owner`.
551pub fn validate_ticker_statistic_strict(user_value: &Value) -> Result<(), Vec<String>> {
552    let mut normalized_for_check = user_value.clone();
553    normalize_json_keys_snake_case(&mut normalized_for_check);
554    let nested_owner = normalized_for_check
555        .get("c2s")
556        .and_then(|c| c.get("owner"))
557        .is_some();
558    let flat_owner = normalized_for_check.get("owner").is_some();
559    if nested_owner || flat_owner {
560        let path = if nested_owner { "c2s.owner" } else { "owner" };
561        return Err(vec![path.to_string()]);
562    }
563    // adapter shorthand 注入的 owner 在 normalized 中存在 (post-
564    // expand_symbol_shorthand), 但 daemon proto 没 owner field.
565    // 用 ignore_paths 让 validator 不计 c2s.owner 为 unknown.
566    //
567    // v1.4.104 codex round 2 F2 + P1-001 follow-up: array shorthand path 也会
568    // 在用户传 `c2s.symbol` 时生成 `c2s.security_list` (1-element). ticker-
569    // statistic proto 没 security_list field, 同样需 ignore.
570    validate_for_path_with_ignore::<
571        futu_backend::proto_internal::ticker_statistic_daemon::DaemonGetTickerStatisticReq,
572    >(user_value, &["c2s.owner", "c2s.security_list"])
573}
574
575/// v1.4.106 codex 0500 ζ23-redo: 同 `validate_ticker_statistic_strict` —
576/// `/api/ticker-statistic-detail` 走 security shorthand 路径 (adapter
577/// `expand_symbol_shorthand` 在 validator 之前展开), 同样需:
578///   1. 显式 reject user-supplied `owner` (4 path: nested/flat × snake/Pascal)
579///   2. 容忍 adapter 注入的 `c2s.owner` / `c2s.security_list` (proto 没此字段)
580///   3. 调 generic validator 验证 strict 字段拼写 (typo guard)
581pub fn validate_ticker_statistic_detail_strict(user_value: &Value) -> Result<(), Vec<String>> {
582    let mut normalized_for_check = user_value.clone();
583    normalize_json_keys_snake_case(&mut normalized_for_check);
584    let nested_owner = normalized_for_check
585        .get("c2s")
586        .and_then(|c| c.get("owner"))
587        .is_some();
588    let flat_owner = normalized_for_check.get("owner").is_some();
589    if nested_owner || flat_owner {
590        let path = if nested_owner { "c2s.owner" } else { "owner" };
591        return Err(vec![path.to_string()]);
592    }
593    validate_for_path_with_ignore::<
594        futu_backend::proto_internal::ticker_statistic_daemon::DaemonGetTickerStatisticDetailReq,
595    >(user_value, &["c2s.owner", "c2s.security_list"])
596}
597
598/// v1.4.106 codex 0554 F2 [P2]: admin control-plane POST endpoints
599/// (`/api/admin/shutdown` + `/api/admin/reload`) 不带 proto request struct —
600/// handler 完全无视 body. 但 strict middleware 必须 reject 任何 user-supplied
601/// 字段, 避免 `{"force": true}` / `{"reason": "..."}` 之类 silent-accept (用户
602/// 以为生效, 实际 server 完全无视).
603///
604/// 行为: empty body / `{}` / `null` → OK; 任何 non-empty object / array /
605/// scalar → reject 列出 unknown 字段名.
606///
607/// 注意: middleware 顶层已对 empty bytes early-return; 本 fn 处理 `{}` 和
608/// `{"foo": 1}` 区分.
609pub fn validate_admin_empty_body(user_value: &Value) -> Result<(), Vec<String>> {
610    match user_value {
611        // `{}` / nullable → 通过
612        Value::Object(map) if map.is_empty() => Ok(()),
613        Value::Null => Ok(()),
614        // 非空 object → 列出所有 key
615        Value::Object(map) => Err(map.keys().cloned().collect()),
616        // 非 object (array / scalar) → 不允许 (admin endpoint 仅接受 `{}`/empty)
617        Value::Array(_) => Err(vec!["<root: array not allowed>".to_string()]),
618        Value::String(_) | Value::Number(_) | Value::Bool(_) => {
619            Err(vec!["<root: scalar not allowed>".to_string()])
620        }
621    }
622}
623
624/// adapter shorthand which injects `c2s.owner` (for endpoints like option-chain
625/// that need owner). ticker-statistic schema doesn't have owner, but injected
626/// owner shouldn't be flagged unknown. Caller path is responsible for rejecting
627/// **explicit user-supplied** owner *before* calling this fn.
628fn validate_for_path_with_ignore<Req>(
629    user_value: &Value,
630    ignore_paths: &[&str],
631) -> Result<(), Vec<String>>
632where
633    Req: Message + Default + DeserializeOwned + serde::Serialize,
634{
635    validate_for_path_with_options::<Req>(user_value, ignore_paths, true, None)
636}
637
638fn validate_for_path_with_symbol_owner_ignore<Req>(user_value: &Value) -> Result<(), Vec<String>>
639where
640    Req: Message + Default + DeserializeOwned + serde::Serialize,
641{
642    validate_for_path_with_ignore::<Req>(user_value, SYMBOL_SHORTHAND_OWNER_IGNORE)
643}
644
645fn validate_for_path_with_options<Req>(
646    user_value: &Value,
647    ignore_paths: &[&str],
648    wrap_flat_body_as_c2s: bool,
649    proto_id: Option<u32>,
650) -> Result<(), Vec<String>>
651where
652    Req: Message + Default + DeserializeOwned + serde::Serialize,
653{
654    let mut normalized = user_value.clone();
655    normalize_json_keys_snake_case(&mut normalized);
656    apply_known_field_aliases_for_proto_id(&mut normalized, proto_id);
657    if wrap_flat_body_as_c2s {
658        maybe_wrap_flat_body_as_c2s(&mut normalized);
659    }
660    maybe_expand_flat_trd_header(&mut normalized);
661    // expand_symbol_shorthand returns Result<(), String>; ignore here — if it
662    // fails, downstream handler will return its own 400. We're only checking
663    // unknown-field cases, not symbol shape.
664    if let Err(_symbol_error) = expand_symbol_shorthand(&mut normalized) {
665        // Keep strict-fields focused on unknown keys. Symbol shape errors are
666        // reported by the typed endpoint handlers with endpoint-specific 400s.
667    }
668
669    let mut schema_value = normalized.clone();
670    strip_ignored_paths(&mut schema_value, ignore_paths);
671
672    // Try to deserialize. If deserialize fails (e.g. type mismatch), let
673    // downstream handler return its own typed error — not our concern.
674    let req: Req = match serde_json::from_value(schema_value.clone()) {
675        Ok(r) => r,
676        Err(err) => {
677            if let Some(path) = unknown_field_from_serde_error(&err) {
678                return Err(vec![path]);
679            }
680            return Ok(());
681        }
682    };
683    let canonical = match serde_json::to_value(&req) {
684        Ok(v) => v,
685        Err(_) => return Ok(()),
686    };
687
688    let mut unknown = Vec::new();
689    collect_unknown_field_paths(&schema_value, &canonical, "", &mut unknown);
690    if unknown.is_empty() {
691        Ok(())
692    } else {
693        Err(unknown)
694    }
695}
696
697fn strip_ignored_paths(value: &mut Value, ignore_paths: &[&str]) {
698    for path in ignore_paths {
699        strip_path(value, path);
700    }
701}
702
703fn strip_path(value: &mut Value, path: &str) {
704    let mut parts = path.split('.').peekable();
705    let mut cursor = value;
706    while let Some(part) = parts.next() {
707        let Some(object) = cursor.as_object_mut() else {
708            return;
709        };
710        if parts.peek().is_none() {
711            object.remove(part);
712            return;
713        }
714        let Some(next) = object.get_mut(part) else {
715            return;
716        };
717        cursor = next;
718    }
719}
720
721fn unknown_field_from_serde_error(err: &serde_json::Error) -> Option<String> {
722    let message = err.to_string();
723    let rest = message.strip_prefix("unknown field `")?;
724    let (field, _) = rest.split_once('`')?;
725    if field.is_empty() {
726        None
727    } else {
728        Some(field.to_string())
729    }
730}
731
732/// Recursively walk `orig` (user input post-normalization) vs `accepted`
733/// (canonical re-serialized typed-struct value). Push any path in `orig`
734/// that's missing from `accepted` to `out`.
735fn collect_unknown_field_paths(
736    orig: &Value,
737    accepted: &Value,
738    prefix: &str,
739    out: &mut Vec<String>,
740) {
741    match (orig, accepted) {
742        (Value::Object(o), Value::Object(a)) => {
743            for (k, v) in o {
744                let path = if prefix.is_empty() {
745                    k.clone()
746                } else {
747                    format!("{prefix}.{k}")
748                };
749                match a.get(k) {
750                    Some(av) => collect_unknown_field_paths(v, av, &path, out),
751                    None => out.push(path),
752                }
753            }
754        }
755        (Value::Array(o_arr), Value::Array(a_arr)) => {
756            // For Vec<T> typed fields, default-instantiated proto Request has
757            // empty Vec -> a_arr is []. We can only validate inner keys when
758            // a_arr has at least one element (rare for Request shapes).
759            if let Some(a_first) = a_arr.first() {
760                for (i, o_item) in o_arr.iter().enumerate() {
761                    let path = format!("{prefix}[{i}]");
762                    collect_unknown_field_paths(o_item, a_first, &path, out);
763                }
764            }
765        }
766        // Primitive vs anything: not a key-set check; OK.
767        _ => {}
768    }
769}
770
771#[cfg(test)]
772mod tests;