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;