1use utoipa::{
2 openapi::security::{ApiKey, ApiKeyValue, Http, HttpAuthScheme, SecurityScheme},
3 Modify, OpenApi,
4};
5
6use std::collections::{BTreeSet, VecDeque};
7
8#[derive(utoipa::ToSchema)]
9#[schema(as = ApiResponse)]
10#[allow(dead_code)]
11struct OpenApiApiResponse {
12 success: bool,
13 data: Option<serde_json::Value>,
14 error: Option<String>,
15}
16
17pub struct SecurityAddon;
19
20impl Modify for SecurityAddon {
21 fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
22 if let Some(components) = openapi.components.as_mut() {
23 let mut http_auth = Http::new(HttpAuthScheme::Bearer);
25 http_auth.bearer_format = Some("EIP-712".to_string());
26 http_auth.description = Some(
27 "EIP-712 typed signature authentication. Include 'wallet', 'nonce', \
28 and 'signature' fields in the request body."
29 .to_string(),
30 );
31 components.add_security_scheme("eip712_signature", SecurityScheme::Http(http_auth));
32
33 components.add_security_scheme(
35 "wallet_query",
36 SecurityScheme::ApiKey(ApiKey::Query(ApiKeyValue::new("wallet"))),
37 );
38
39 components.add_security_scheme(
41 "admin_key",
42 SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("X-Admin-Key"))),
43 );
44 }
45 }
46}
47
48#[derive(OpenApi)]
49#[openapi(
50 info(
51 title = "Hypercall Trading API",
52 version = "0.0.1",
53 description = "Options and perpetuals trading API with EIP-712 signature authentication.\n\n\
54 ## Base URLs\n\n\
55 - **Testnet**: `https://testnet-api.hypercall.xyz`\n\
56 - **Production**: `https://api.hypercall.xyz`\n\n\
57 ## Content Types\n\n\
58 - **Requests**: `application/json` for write endpoints\n\
59 - **Responses**: `application/json`\n\n\
60 ## Authentication\n\n\
61 ### EIP-712 Signature (Write Operations)\n\n\
62 Write operations require an EIP-712 typed signature in the request body:\n\
63 - `wallet`: The wallet address performing the action\n\
64 - `nonce`: Unique nonce for replay protection\n\
65 - `signature`: EIP-712 signature from wallet owner or authorized agent\n\n\
66 **Bulk endpoints** verify signatures per-item in the handler (not via middleware).\n\n\
67 ### Wallet Query Parameter (Read Operations)\n\n\
68 Read endpoints are public and accept query parameters (including `wallet`) without proof of ownership. \
69 No ownership verification for read endpoints; treat as sensitive data.\n\n\
70 ### Agent Authorization\n\n\
71 A signer is authorized if:\n\
72 - `signer == wallet` (direct signing), OR\n\
73 - Signer has been approved via `POST /approve-agent` for the wallet (engine-owned state, read from lock-free snapshot)\n\n\
74 ## Price and Size Encoding\n\n\
75 **For signed endpoints**:\n\
76 - `price` and `size` **MUST be strings** in JSON\n\
77 - Must match exactly what was signed (string formatting matters)\n\
78 - Price must have ≤ 5 significant figures\n\n\
79 **Size conversion**: Human-readable size → contract units: `(size * 1_000_000) as u64` (truncates)\n\n\
80 **Orderbook size units**: `/orderbook`, `/expiry-summary`, and WebSocket `OrderbookUpdate` sizes are human-readable contracts.\n\n\
81 ## Symbol Format\n\n\
82 Options symbols: `UNDERLYING-YYYYMMDD-STRIKE-(C|P)`\n\n\
83 Also accepts Deribit-style expiry `DDMMMYY` (e.g., `BTC-30AUG25-95000-C`)\n\n\
84 ## Time in Force\n\n\
85 - `gtc`: Good Till Cancelled\n\
86 - `ioc`: Immediate or Cancel\n\
87 - `fok`: Fill or Kill\n\n\
88 ## Rate Limits\n\n\
89 Rate limiting is not enforced; self-throttle as needed.",
90 license(name = "Proprietary"),
91 contact(name = "Hypercall", url = "https://hypercall.xyz")
92 ),
93 servers(
94 (url = "https://testnet-api.hypercall.xyz", description = "Testnet"),
95 (url = "https://api.hypercall.xyz", description = "Production"),
96 ),
97 tags(
98 (name = "Health", description = "Health check and readiness endpoints for monitoring and load balancers."),
99 (name = "Markets", description = "Market data, instruments, orderbooks, and greeks. Public endpoints with no authentication required."),
100 (name = "Trading", description = "Order placement and cancellation. Requires EIP-712 signature authentication. Price and size must be strings for signature verification."),
101 (name = "Portfolio", description = "Account portfolio, open orders, and trade history. Public endpoints accepting wallet query parameter."),
102 (name = "MMP", description = "Market Maker Protection configuration. Allows setting limits on cumulative delta, vega, and quantity within a time window to automatically freeze trading."),
103 (name = "Admin", description = "User tier management (tier1/tier2) and margin mode selection (standard/portfolio)."),
104 (name = "Agents", description = "Agent authorization management. Allows delegating signing authority to agent wallets."),
105 (name = "Competition", description = "Competition lifecycle, leaderboard rankings, and profile competition stats."),
106 (name = "WebSocket", description = "Real-time data streaming via WebSocket.\n\n\
107 ## Connection\n\n\
108 Connect to `ws://HOST/ws` (or `wss://` for TLS). Query parameters:\n\
109 - `wallet` (optional): Wallet address for authenticated channels\n\n\
110 ## Message Format\n\n\
111 All messages are JSON with a `type` field.\n\n\
112 ### Client Messages\n\n\
113 **Subscribe**: `{\"type\": \"Subscribe\", \"channel\": \"orderbook\"}`\n\n\
114 **Subscribe (options chain with filters)**: `{\"type\": \"Subscribe\", \"channel\": \"options_chain\", \"symbols\": [\"BTC-20260331-100000-C\"], \"expiry\": \"2026-03-31\", \"option_type\": \"both\"}`\n\n\
115 **Unsubscribe**: `{\"type\": \"Unsubscribe\", \"channel\": \"orderbook\"}`\n\n\
116 **Unsubscribe (options chain with filters)**: `{\"type\": \"Unsubscribe\", \"channel\": \"options_chain\", \"symbols\": [\"BTC-20260331-100000-C\"], \"expiry\": \"2026-03-31\", \"option_type\": \"both\"}`\n\n\
117 For `options_chain`, all filter fields are optional. `symbols` supports multiple symbols, `expiry` uses `YYYY-MM-DD`, and `option_type` is `call|put|both`.\n\n\
118 ### Available Channels\n\n\
119 | Channel | Auth | Description |\n\
120 |---------|------|-------------|\n\
121 | `orderbook` | No | L2 orderbook updates (all symbols) |\n\
122 | `options_chain` | No | Incremental options chain updates |\n\
123 | `trades` | No | Public trade feed |\n\
124 | `market_updates` | No | Market listing changes |\n\
125 | `candles:<UNDERLYING>:<RESOLUTION>` | No | Realtime underlying candle updates |\n\
126 | `order_updates` | Yes | Your order status changes |\n\
127 | `fills` | Yes | Your trade fills |\n\
128 | `portfolio` | Yes | Your position/balance updates |\n\
129 | `liquidation` | Yes | Your liquidation state changes |\n\
130 | `competition` | Yes | Your competition rank/final stats |\n\
131 | `competition_engagement` | Yes | Your rank jumps, gap-to-next, and final standing |\n\n\
132 ### Server Messages\n\n\
133 **Subscribed**: `{\"type\": \"Subscribed\", \"channel\": \"orderbook\"}`\n\n\
134 **OrderbookUpdate**: `{\"type\": \"OrderbookUpdate\", \"symbol\": \"BTC-...\", \"option_token_address\": \"0x...\", \"bids\": [[price, size]], \"asks\": [[price, size]], \"timestamp\": 123}`\n\n\
135 **OptionsChainUpdate (Upsert)**: `{\"type\": \"OptionsChainUpdate\", \"action\": \"Upsert\", \"currency\": \"BTC\", \"expiry\": 1774944000, \"row\": {\"strike\": 100000.0, \"call\": {\"symbol\": \"BTC-20260331-100000-C\", \"bid_price_usd\": 100.0, \"ask_price_usd\": 101.0}, \"put\": null}, \"timestamp\": 123}`\n\n\
136 **OptionsChainUpdate (Remove)**: `{\"type\": \"OptionsChainUpdate\", \"action\": \"Remove\", \"currency\": \"BTC\", \"expiry\": 1774944000, \"strike\": 100000.0, \"option_type\": \"call\", \"symbol\": \"BTC-20260331-100000-C\", \"timestamp\": 124}`\n\n\
137 **Trade**: `{\"type\": \"Trade\", \"symbol\": \"BTC-...\", \"price\": \"100.5\", \"size\": \"1.0\", \"side\": \"buy\", \"timestamp\": 123}`\n\n\
138 **CandleUpdate**: `{\"type\": \"CandleUpdate\", \"underlying\": \"BTC\", \"resolution\": \"1m\", \"start_time_ms\": 123, \"end_time_ms\": 456, \"open\": 100.0, \"high\": 101.0, \"low\": 99.0, \"close\": 100.5, \"volume\": 12.3}`\n\n\
139 **OrderUpdate**: `{\"type\": \"OrderUpdate\", \"order_id\": 123, \"status\": \"filled\", ...}`\n\n\
140 **Fill**: `{\"type\": \"Fill\", \"order_id\": 123, \"fill_id\": 456, \"price\": \"100.5\", \"size\": \"1.0\", ...}`\n\n\
141 **PortfolioUpdate**: `{\"type\": \"PortfolioUpdate\", \"wallet\": \"0x...\", \"positions\": {...}, \"balance\": {...}}`\n\n\
142 **CompetitionRankChange**: `{\"type\": \"CompetitionRankChange\", \"wallet_address\": \"0x...\", \"competition_id\": 12, \"from_rank\": 20, \"to_rank\": 17, \"delta_places\": 3, \"pnl\": \"125.5\", \"timestamp\": 123}`\n\n\
143 **CompetitionGapUpdate**: `{\"type\": \"CompetitionGapUpdate\", \"wallet_address\": \"0x...\", \"competition_id\": 12, \"rank\": 17, \"next_rank\": 16, \"gap_metric_value\": \"200\", \"timestamp\": 123}` (gap measured in the competition's `primary_win_condition` metric)\n\n\
144 **CompetitionFinalStanding**: `{\"type\": \"CompetitionFinalStanding\", \"wallet_address\": \"0x...\", \"competition_id\": 12, \"rank\": 2, \"pnl\": \"320.1\", \"volume\": \"1500\", \"efficiency\": \"0.2134\", \"medal\": 2, \"timestamp\": 123}`\n\n\
145 **Error**: `{\"type\": \"Error\", \"message\": \"...\"}`"),
146 (name = "Username", description = "Wallet display-name management. Public lookup by wallet or username; authenticated set/delete via EIP-712 signature."),
147 (name = "Push Notifications", description = "Web Push subscription management. Register browser push subscriptions to receive fill and liquidation notifications even when the app is not open."),
148 (name = "Monitoring", description = "System integrity and debugging endpoints. Protected by X-Admin-Key header.")
149 ),
150 modifiers(&SecurityAddon),
151 paths(
152 crate::handlers::health::health,
154 crate::handlers::health::version,
155 crate::handlers::health::ready,
156 crate::handlers::account::get_trades,
158 crate::handlers::account::get_markets,
159 crate::handlers::market_data::get_instruments,
160 crate::handlers::market_data::get_instrument_specs,
161 crate::handlers::market_data::get_options_summary,
162 crate::handlers::options_chain::get_options_chain,
163 crate::handlers::market_data::get_expiry_summary,
164 crate::handlers::options_chain::get_orderbook,
165 crate::handlers::gas_provider::get_gas_provider,
166 crate::handlers::candles::get_candles,
167 crate::handlers::greeks::get_greeks,
168 crate::handlers::historical_theos::get_historical_theos,
169 crate::handlers::historical_theos::get_historical_theos_batch,
170 crate::handlers::account::get_portfolio,
172 crate::handlers::greeks::get_portfolio_greeks,
173 crate::handlers::account::get_fills,
174 crate::handlers::historical_pnl::get_historical_pnl,
175 crate::handlers::settlement::get_settlement_payouts,
176 crate::handlers::settlement::mark_settlement_payouts_seen,
177 crate::handlers::competition::list_competitions,
178 crate::handlers::competition::get_competition,
179 crate::handlers::competition::get_competition_leaderboard,
180 crate::handlers::competition::get_profile,
181 crate::handlers::competition::get_profile_trades,
182 crate::handlers::profile_image::set_profile_image,
183 crate::handlers::account::get_orders,
185 crate::handlers::orders::place_order,
186 crate::handlers::orders::cancel_order,
187 crate::handlers::orders::cancel_order_by_cloid,
188 crate::handlers::bulk_orders::bulk_place_order,
189 crate::handlers::replace_orders::replace_order,
190 crate::handlers::replace_orders::bulk_replace_order,
191 crate::handlers::bulk_orders::bulk_cancel_order,
192 crate::handlers::bulk_orders::bulk_cancel_order_by_cloid,
193 crate::handlers::admin::get_mmp_config,
195 crate::handlers::admin::set_mmp_config,
196 crate::handlers::admin::delete_mmp_config,
197 crate::handlers::admin::reset_mmp,
198 crate::handlers::admin::get_user_tier,
200 crate::handlers::admin::set_user_tier,
201 crate::handlers::admin::delete_user_tier,
202 crate::handlers::account::set_margin_mode,
203 crate::handlers::username::get_username,
205 crate::handlers::username::set_username,
206 crate::handlers::username::delete_username,
207 crate::handlers::agents::approve_agent,
209 crate::handlers::agents::revoke_agent,
210 crate::handlers::agents::get_authorized_agents,
211 crate::handlers::push::push_subscribe,
213 crate::handlers::push::push_unsubscribe,
214 crate::handlers::push::push_preferences,
215 ),
218 components(schemas(
219 hypercall_types::WalletAddress,
221 OpenApiApiResponse,
222 crate::models::InstrumentStatus,
223 crate::models::Pagination,
224 crate::models::MarginSummary,
226 crate::models::SpanMarginSummary,
227 crate::models::TradeApiResponse,
229 crate::models::Portfolio,
230 crate::models::PositionWithMetrics,
231 crate::models::Position,
232 crate::models::AccountBalance,
233 crate::models::TradesResponse,
234 crate::models::Pagination,
235 crate::models::MarketsResponse,
236 crate::models::MarketInfo,
237 crate::models::Instrument,
238 hypercall_types::InstrumentSpecResponse,
239 crate::models::FillApiResponse,
240 crate::models::FillsResponse,
241 crate::models::CompetitionData,
242 crate::models::CompetitionStateValue,
243 crate::models::CompetitionSortByValue,
244 crate::models::CompetitionSortOrderValue,
245 crate::models::CompetitionWinConditionValue,
246 crate::models::CompetitionsResponse,
247 crate::models::CompetitionResponse,
248 crate::models::LeaderboardRow,
249 crate::models::ConnectedUserRank,
250 crate::models::CompetitionLeaderboardResponse,
251 crate::models::ProfileMarginStats,
252 crate::models::ProfilePnlStats,
253 crate::models::ProfileMetricMedals,
254 crate::models::ProfileCompetitionRankSummary,
255 crate::models::ProfileData,
256 crate::models::ProfileResponse,
257 crate::models::ProfileTradesResponse,
258 crate::models::CompetitionUpsertRequest,
259 crate::models::CompetitionUpdateRequest,
260 crate::handlers::username::SetUsernameRequest,
261 crate::handlers::username::UsernameResponse,
262 crate::handlers::username::DeleteUsernameResponse,
263 crate::handlers::profile_image::SetProfileImageRequest,
264 crate::handlers::profile_image::SetProfileImageResponse,
265 crate::handlers::settlement::SettlementPayoutsResponse,
266 crate::handlers::settlement::SettlementPayoutEntry,
267 hypercall_types::HistoricalPnlInterval,
268 hypercall_types::HistoricalPnlPoint,
269 hypercall_types::HistoricalPnlResponse,
270 hypercall_types::HistoricalTheoInterval,
271 hypercall_types::HistoricalTheoPoint,
272 hypercall_types::HistoricalTheoResponse,
273 crate::handlers::historical_pnl::HistoricalPnlApiResponse,
274 crate::handlers::historical_theos::HistoricalTheoApiResponse,
275 crate::handlers::historical_theos::BatchHistoricalTheoApiResponse,
276 crate::handlers::settlement::SettlementPayoutSeenRequest,
277 crate::handlers::settlement::SettlementPayoutSeenMutationResponse,
278 crate::models::Order,
279 crate::models::OrdersResponse,
280 crate::models::MmpConfigData,
281 crate::models::MmpConfigResponse,
282 crate::models::UserTierData,
283 crate::models::UserTierResponse,
284 hypercall_types::InstrumentResponse,
286 hypercall_types::TickSizeStep,
287 crate::handlers::market_data::OptionSummary,
288 crate::handlers::market_data::InstrumentWithOrderbook,
289 crate::handlers::gas_provider::GasProviderErrorResponse,
290 crate::handlers::gas_provider::GasProviderFeeTierResponse,
291 crate::handlers::gas_provider::GasProviderResponse,
292 crate::models::OptionsChainSnapshotResponse,
293 crate::models::OptionsChainStrikeRow,
294 crate::models::OptionsChainLeg,
295 crate::models::OptionsChainGreeksAbs,
296 crate::models::OptionsChainGreeksCash,
297 crate::candles::CandleResolution,
298 crate::candles::UnderlyingCandle,
299 crate::candles::UnderlyingCandlesResponse,
300 crate::handlers::candles::CandlesApiResponse,
301 crate::handlers::candles::CandlesResponse,
302 hypercall_types::OrderBookResponse,
303 hypercall_types::OrderBookStats,
304 hypercall_types::OrderBookGreeks,
305 hypercall_types::ApproveAgentResponse,
306 hypercall_types::RevokeAgentResponse,
307 hypercall_types::AuthorizedAgentsResponse,
308 hypercall_types::PlaceOrderRequest,
310 hypercall_types::CancelOrderRequest,
311 hypercall_types::CancelOrderByCloidRequest,
312 crate::handlers::bulk_orders::BulkPlaceOrderRequest,
313 crate::handlers::bulk_orders::BulkPlaceOrderResponse,
314 crate::handlers::bulk_orders::BulkCancelOrderRequest,
315 crate::handlers::bulk_orders::BulkCancelOrderByCloidRequest,
316 crate::handlers::bulk_orders::BulkCancelOrderResponse,
317 crate::handlers::BulkOrderResult,
318 hypercall_types::ReplaceOrderRequest,
319 crate::handlers::replace_orders::BulkReplaceOrderRequest,
320 crate::handlers::replace_orders::BulkReplaceOrderResponse,
321 hypercall_types::ApproveAgentRequest,
322 hypercall_types::RevokeAgentRequest,
323 hypercall_types::MarketResponse,
324 hypercall_auth::SignatureRequest,
326 crate::models::SetMmpConfigRequest,
328 crate::models::DeleteMmpConfigRequest,
329 crate::models::ResetMmpRequest,
330 crate::models::SetUserTierRequest,
331 crate::models::DeleteUserTierRequest,
332 crate::models::SetMarginModeRequest,
333 crate::models::MarginModeResponse,
334 hypercall_types::api_models::MarginModeApiResponse,
335 hypercall_types::TimeInForce,
337 hypercall_types::OrderInfo,
338 hypercall_types::OrderUpdateMessage,
339 hypercall_types::OrderUpdateStatus,
340 hypercall_types::Market,
341 hypercall_types::MarketUpdateMessage,
342 hypercall_types::MarketUpdateStatus,
343 hypercall_types::OptionType,
344 hypercall_types::Side,
346 crate::handlers::greeks::GreeksResponse,
348 crate::handlers::greeks::GreeksApiResponse,
349 crate::handlers::greeks::PortfolioGreeksApiResponse,
350 crate::models::PortfolioGreeksResponse,
351 crate::models::PositionGreeksLeg,
352 crate::models::PortfolioGreeksAggregate,
353 crate::models::SimulatedGreeksOrder,
354 crate::handlers::push::PushSubscribeRequest,
358 crate::handlers::push::PushSubscribeResponse,
359 crate::handlers::push::PushUnsubscribeRequest,
360 crate::handlers::push::PushUnsubscribeResponse,
361 crate::handlers::push::PushPreferencesRequest,
362 crate::handlers::push::PushPreferencesResponse,
363 crate::models::HealthResponse,
365 crate::models::VersionResponse,
366 crate::models::ReadyResponse,
367 crate::models::ReadinessComponentReport,
368 ))
369)]
370pub struct ApiDoc;
371
372#[cfg(feature = "rsm-state")]
373#[derive(OpenApi)]
374#[openapi(
375 paths(crate::handlers::rsm_rpc::rsm_rpc),
376 components(schemas(
377 hypercall_rsm_rpc::RsmRpcError,
378 hypercall_rsm_rpc::RsmRpcRequest,
379 hypercall_rsm_rpc::RsmRpcResponse,
380 )),
381 tags((name = "RSM", description = "Validator RSM block inspection JSON-RPC endpoint. Feature-gated behind rsm-state."))
382)]
383struct RsmApiDoc;
384
385const HIDDEN_TAGS: &[&str] = &["Monitoring", "Admin"];
387
388pub fn internal_openapi() -> utoipa::openapi::OpenApi {
394 #[cfg(not(feature = "rsm-state"))]
395 {
396 ApiDoc::openapi()
397 }
398
399 #[cfg(feature = "rsm-state")]
400 {
401 let mut spec = ApiDoc::openapi();
402 spec.merge(RsmApiDoc::openapi());
403 spec
404 }
405}
406
407pub fn public_openapi() -> utoipa::openapi::OpenApi {
409 filter_hidden_tags(internal_openapi())
410}
411
412pub fn normalize_openapi_schema_refs(mut json: String) -> String {
414 json = json.replace(
415 "#/components/schemas/crate.api_server.models.",
416 "#/components/schemas/",
417 );
418 json = json.replace(
419 "#/components/schemas/crate.boundary.",
420 "#/components/schemas/",
421 );
422 json = json.replace("#/components/schemas/models.", "#/components/schemas/");
423 json = json.replace("#/components/schemas/boundary.", "#/components/schemas/");
424 json = json.replace(
427 "#/components/schemas/hypercall_runtime_api.boundary.engine.",
428 "#/components/schemas/",
429 );
430 json = json.replace(
431 "#/components/schemas/hypercall_runtime_api.boundary.read_models.",
432 "#/components/schemas/",
433 );
434 json = json.replace(
435 "#/components/schemas/hypercall_runtime_api.boundary.market_inputs.",
436 "#/components/schemas/",
437 );
438 json = json.replace(
439 "#/components/schemas/hypercall_types.api_models.",
440 "#/components/schemas/",
441 );
442 json = json.replace(
443 "#/components/schemas/super.models.",
444 "#/components/schemas/",
445 );
446 json = json.replace(
447 "#/components/schemas/crate.vol_oracle.",
448 "#/components/schemas/",
449 );
450 json = json.replace(
451 "#/components/schemas/hypercall_vol_oracle.",
452 "#/components/schemas/",
453 );
454 json = json.replace(
455 "#/components/schemas/crate.rsm.rpc.",
456 "#/components/schemas/",
457 );
458 json = json.replace(
459 "#/components/schemas/crate.rsm.engine_snapshot.",
460 "#/components/schemas/",
461 );
462 json = json.replace(
463 "#/components/schemas/hypercall_signer.",
464 "#/components/schemas/",
465 );
466 json = json.replace("#/components/schemas/crate.", "#/components/schemas/");
467 json
468}
469
470pub fn filter_hidden_tags(mut spec: utoipa::openapi::OpenApi) -> utoipa::openapi::OpenApi {
471 spec.paths.paths.retain(|_path, item| {
473 item.operations.retain(|_method, op| {
474 op.tags
475 .as_ref()
476 .is_none_or(|tags| tags.iter().any(|t| !HIDDEN_TAGS.contains(&t.as_str())))
477 });
478
479 !item.operations.is_empty()
480 });
481
482 if let Some(tags) = spec.tags.as_mut() {
484 tags.retain(|t| !HIDDEN_TAGS.contains(&t.name.as_str()));
485 }
486
487 if let Some(components) = spec.components.as_mut() {
489 components
490 .security_schemes
491 .retain(|name, _| name != "admin_key");
492 }
493
494 prune_unreachable_schemas(&mut spec);
498
499 spec
500}
501
502fn prune_unreachable_schemas(spec: &mut utoipa::openapi::OpenApi) {
503 let mut entrypoint =
504 serde_json::to_value(&*spec).expect("OpenAPI document should serialize for schema pruning");
505 if let Some(components) = entrypoint
506 .get_mut("components")
507 .and_then(serde_json::Value::as_object_mut)
508 {
509 components.insert(
510 "schemas".to_string(),
511 serde_json::Value::Object(Default::default()),
512 );
513 components.insert(
514 "responses".to_string(),
515 serde_json::Value::Object(Default::default()),
516 );
517 }
518
519 let Some(components) = spec.components.as_mut() else {
520 return;
521 };
522
523 if components.schemas.is_empty() {
524 return;
525 }
526
527 let responses = serde_json::to_value(&components.responses)
528 .expect("OpenAPI component responses should serialize");
529 let schemas = serde_json::to_value(&components.schemas)
530 .expect("OpenAPI component schemas should serialize");
531 let original_schemas = components.schemas.clone();
532
533 let mut reachable_schemas = BTreeSet::new();
534 let mut pending_schemas = VecDeque::new();
535 let mut reachable_responses = BTreeSet::new();
536 let mut pending_responses = VecDeque::new();
537
538 collect_component_refs(
539 &entrypoint,
540 &mut reachable_schemas,
541 &mut pending_schemas,
542 &mut reachable_responses,
543 &mut pending_responses,
544 );
545
546 while let Some(response_name) = pending_responses.pop_front() {
547 if let Some(response) = responses.get(&response_name) {
548 collect_component_refs(
549 response,
550 &mut reachable_schemas,
551 &mut pending_schemas,
552 &mut reachable_responses,
553 &mut pending_responses,
554 );
555 }
556 }
557
558 while let Some(schema_name) = pending_schemas.pop_front() {
559 if let Some(schema) = schemas.get(&schema_name) {
560 collect_component_refs(
561 schema,
562 &mut reachable_schemas,
563 &mut pending_schemas,
564 &mut reachable_responses,
565 &mut pending_responses,
566 );
567 }
568 }
569
570 components.schemas.retain(|name, _schema| {
571 reachable_schemas.contains(name)
572 || reachable_schemas.contains(canonical_schema_ref_name(name))
573 });
574
575 restore_serialized_schema_refs(spec, &original_schemas);
576}
577
578fn restore_serialized_schema_refs(
579 spec: &mut utoipa::openapi::OpenApi,
580 original_schemas: &std::collections::BTreeMap<
581 String,
582 utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
583 >,
584) {
585 loop {
586 let serialized = serde_json::to_value(&*spec)
587 .expect("OpenAPI document should serialize for schema ref reconciliation");
588 let mut referenced_schemas = BTreeSet::new();
589 let mut pending_schemas = VecDeque::new();
590 let mut referenced_responses = BTreeSet::new();
591 let mut pending_responses = VecDeque::new();
592
593 collect_component_refs(
594 &serialized,
595 &mut referenced_schemas,
596 &mut pending_schemas,
597 &mut referenced_responses,
598 &mut pending_responses,
599 );
600
601 let Some(components) = spec.components.as_mut() else {
602 return;
603 };
604
605 let mut restored = false;
606 for schema_name in referenced_schemas {
607 if components.schemas.contains_key(&schema_name) {
608 continue;
609 }
610
611 if let Some(schema) = original_schemas.get(&schema_name) {
612 components
613 .schemas
614 .insert(schema_name.to_string(), schema.clone());
615 restored = true;
616 } else if let Some((original_name, schema)) = original_schemas
617 .iter()
618 .find(|(name, _schema)| canonical_schema_ref_name(name) == schema_name)
619 {
620 components
621 .schemas
622 .insert(original_name.to_string(), schema.clone());
623 restored = true;
624 }
625 }
626
627 if !restored {
628 break;
629 }
630 }
631}
632
633fn collect_component_refs(
634 value: &serde_json::Value,
635 reachable_schemas: &mut BTreeSet<String>,
636 pending_schemas: &mut VecDeque<String>,
637 reachable_responses: &mut BTreeSet<String>,
638 pending_responses: &mut VecDeque<String>,
639) {
640 match value {
641 serde_json::Value::Object(map) => {
642 if let Some(ref_value) = map.get("$ref").and_then(serde_json::Value::as_str) {
643 if let Some(name) = ref_value.strip_prefix("#/components/schemas/") {
644 let name = canonical_schema_ref_name(name).to_string();
645 if reachable_schemas.insert(name.clone()) {
646 pending_schemas.push_back(name);
647 }
648 } else if let Some(name) = ref_value.strip_prefix("#/components/responses/") {
649 let name = name.to_string();
650 if reachable_responses.insert(name.clone()) {
651 pending_responses.push_back(name);
652 }
653 }
654 }
655
656 for nested in map.values() {
657 collect_component_refs(
658 nested,
659 reachable_schemas,
660 pending_schemas,
661 reachable_responses,
662 pending_responses,
663 );
664 }
665 }
666 serde_json::Value::Array(values) => {
667 for nested in values {
668 collect_component_refs(
669 nested,
670 reachable_schemas,
671 pending_schemas,
672 reachable_responses,
673 pending_responses,
674 );
675 }
676 }
677 _ => {}
678 }
679}
680
681fn canonical_schema_ref_name(name: &str) -> &str {
682 name.strip_prefix("crate.api_server.models.")
683 .or_else(|| name.strip_prefix("crate.boundary."))
684 .or_else(|| name.strip_prefix("models."))
685 .or_else(|| name.strip_prefix("boundary."))
686 .or_else(|| name.strip_prefix("hypercall_types.api_models."))
687 .or_else(|| name.strip_prefix("super.models."))
688 .or_else(|| name.strip_prefix("crate.vol_oracle."))
689 .or_else(|| name.strip_prefix("hypercall_vol_oracle."))
690 .or_else(|| name.strip_prefix("crate.rsm.rpc."))
691 .or_else(|| name.strip_prefix("crate.rsm.engine_snapshot."))
692 .or_else(|| name.strip_prefix("hypercall_signer."))
693 .or_else(|| name.strip_prefix("crate."))
694 .unwrap_or(name)
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700
701 #[test]
702 fn public_openapi_prunes_private_only_component_schemas() {
703 let mut spec = ApiDoc::openapi();
704 let mut private_path = spec
705 .paths
706 .paths
707 .get("/gas-provider/{chain_id}")
708 .expect("gas provider path should be present")
709 .clone();
710 for operation in private_path.operations.values_mut() {
711 operation.tags = Some(vec!["Monitoring".to_string()]);
712 }
713 spec.paths
714 .paths
715 .insert("/monitoring/private-only-test".to_string(), private_path);
716
717 let components = spec
718 .components
719 .as_mut()
720 .expect("components should be present");
721 let private_schema = components
722 .schemas
723 .get("GasProviderErrorResponse")
724 .expect("gas provider error schema should be present")
725 .clone();
726 components
727 .schemas
728 .insert("PrivateOnlyTestSchema".to_string(), private_schema);
729
730 let public =
731 serde_json::to_value(filter_hidden_tags(spec)).expect("OpenAPI should serialize");
732
733 assert!(public["paths"]["/monitoring/private-only-test"].is_null());
734 assert!(public["components"]["schemas"]["PrivateOnlyTestSchema"].is_null());
735 }
736
737 #[test]
738 fn public_openapi_excludes_engine_state_digest_when_route_is_hidden() {
739 let public = serde_json::to_value(public_openapi()).expect("OpenAPI should serialize");
744
745 assert!(public["paths"]["/monitoring/engine-state-digest"].is_null());
746 assert!(public["components"]["schemas"]["EngineStateDigest"].is_null());
747 }
748
749 #[test]
750 fn public_openapi_keeps_export_normalized_refs_reachable() {
751 let json = public_openapi()
752 .to_pretty_json()
753 .expect("OpenAPI should serialize");
754 let public: serde_json::Value = serde_json::from_str(&normalize_openapi_schema_refs(json))
755 .expect("normalized OpenAPI should parse as JSON");
756
757 assert!(public["paths"]["/margin-mode"]["post"].is_object());
758 assert!(public["components"]["schemas"]["MarginModeApiResponse"].is_object());
759 assert!(public["components"]["schemas"]["EngineStateDigest"].is_null());
760 }
761}