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 serde_json::Value;
43
44mod registry;
45mod unknown;
46mod validators;
47
48use registry::strict_validator_for_path;
49use unknown::strict_unknown_fields_error_body;
50
51#[cfg(test)]
52use validators::{
53    validate_admin_empty_body, validate_flow_summary_strict, validate_for_path,
54    validate_max_trd_qtys_strict, validate_ticker_statistic_detail_strict,
55    validate_ticker_statistic_strict,
56};
57
58/// Public test helper: returns true iff `path` is in the strict-validation list.
59pub fn is_strict_path(path: &str) -> bool {
60    strict_validator_for_path(path).is_some()
61}
62
63/// Body size cap for body-buffering middleware (10 MiB; same order as proto
64/// max-size guard elsewhere in adapter). Larger bodies bypass strict validation
65/// and fall through to handler — handler still applies its own size limits.
66const MAX_BODY_BYTES: usize = 10 * 1024 * 1024;
67
68/// Axum middleware: validate POST body against the typed Request schema for
69/// strict paths. Non-strict paths and non-POST methods pass through unmodified.
70pub async fn strict_field_validation_middleware(req: Request, next: Next) -> Response {
71    if req.method() != axum::http::Method::POST {
72        return next.run(req).await;
73    }
74    let path = req.uri().path().to_owned();
75    let Some(validator) = strict_validator_for_path(path.as_str()) else {
76        return next.run(req).await;
77    };
78
79    let (parts, body) = req.into_parts();
80    let bytes = match axum::body::to_bytes(body, MAX_BODY_BYTES).await {
81        Ok(b) => b,
82        Err(e) => {
83            return (
84                StatusCode::BAD_REQUEST,
85                Json(serde_json::json!({
86                    "error": format!("failed to read request body: {e}")
87                })),
88            )
89                .into_response();
90        }
91    };
92
93    // Empty body: handler will use Req::default(); no fields to validate.
94    if bytes.is_empty() {
95        let req = Request::from_parts(parts, Body::from(bytes));
96        return next.run(req).await;
97    }
98
99    // Parse user input as Value
100    let mut user_value: Value = match serde_json::from_slice(&bytes) {
101        Ok(v) => v,
102        Err(e) => {
103            return (
104                StatusCode::BAD_REQUEST,
105                Json(serde_json::json!({
106                    "error": format!("invalid JSON body: {e}")
107                })),
108            )
109                .into_response();
110        }
111    };
112
113    // v1.4.97 codex audit fix: rename OTP aliases BEFORE strict validation.
114    // Earlier v1.4.96 BUG #008 fix did the rename inside `unlock_trade`
115    // handler, but `/api/unlock-trade` is in the strict validator registry so this middleware
116    // runs first — it would see `otp` / `token` / `one_time_password` as
117    // unknown fields against `trd_unlock_trade::Request` schema and reject
118    // with 400 BEFORE the handler's rename logic can run. Apply rename
119    // pre-validation so user-friendly aliases pass strict validation.
120    // Handler still calls `apply_unlock_trade_otp_aliases` again (idempotent
121    // — sec_otp already present so alias strip is a no-op).
122    if path == "/api/unlock-trade" {
123        crate::routes::trd::apply_unlock_trade_otp_aliases(&mut user_value);
124    }
125
126    let validation_err = validator.validate(&user_value);
127
128    if let Err(unknown_paths) = validation_err {
129        return (
130            StatusCode::BAD_REQUEST,
131            Json(strict_unknown_fields_error_body(&path, unknown_paths)),
132        )
133            .into_response();
134    }
135
136    // Restore body for downstream handler
137    let req = Request::from_parts(parts, Body::from(bytes));
138    next.run(req).await
139}
140
141#[cfg(test)]
142mod tests;