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;