1use 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
54const 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
143fn strict_validator_for_path(path: &str) -> Option<StrictValidator> {
149 match path {
150 "/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 "/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 "/api/admin/shutdown" | "/api/admin/reload" => Some(validate_admin_empty_body),
419 _ => None,
420 }
421}
422
423pub fn is_strict_path(path: &str) -> bool {
425 strict_validator_for_path(path).is_some()
426}
427
428const MAX_BODY_BYTES: usize = 10 * 1024 * 1024;
432
433pub 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 if bytes.is_empty() {
460 let req = Request::from_parts(parts, Body::from(bytes));
461 return next.run(req).await;
462 }
463
464 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 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 let req = Request::from_parts(parts, Body::from(bytes));
503 next.run(req).await
504}
505
506fn 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
533pub 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 validate_for_path_with_ignore::<
571 futu_backend::proto_internal::ticker_statistic_daemon::DaemonGetTickerStatisticReq,
572 >(user_value, &["c2s.owner", "c2s.security_list"])
573}
574
575pub 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
598pub fn validate_admin_empty_body(user_value: &Value) -> Result<(), Vec<String>> {
610 match user_value {
611 Value::Object(map) if map.is_empty() => Ok(()),
613 Value::Null => Ok(()),
614 Value::Object(map) => Err(map.keys().cloned().collect()),
616 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
624fn 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 if let Err(_symbol_error) = expand_symbol_shorthand(&mut normalized) {
665 }
668
669 let mut schema_value = normalized.clone();
670 strip_ignored_paths(&mut schema_value, ignore_paths);
671
672 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
732fn 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 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 _ => {}
768 }
769}
770
771#[cfg(test)]
772mod tests;