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