Skip to main content

hypercall_api/
openapi.rs

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
17/// Security addon for authentication schemes
18pub 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            // EIP-712 Signature Authentication (documented as Bearer for visibility)
24            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            // Wallet Query Parameter (for read endpoints)
34            components.add_security_scheme(
35                "wallet_query",
36                SecurityScheme::ApiKey(ApiKey::Query(ApiKeyValue::new("wallet"))),
37            );
38
39            // Admin API Key (for monitoring endpoints)
40            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        // Health
153        crate::handlers::health::health,
154        crate::handlers::health::version,
155        crate::handlers::health::ready,
156        // Markets
157        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        // Portfolio
171        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        // Trading
184        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        // MMP
194        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        // Admin
199        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        // Username
204        crate::handlers::username::get_username,
205        crate::handlers::username::set_username,
206        crate::handlers::username::delete_username,
207        // Agents
208        crate::handlers::agents::approve_agent,
209        crate::handlers::agents::revoke_agent,
210        crate::handlers::agents::get_authorized_agents,
211        // Push Notifications
212        crate::handlers::push::push_subscribe,
213        crate::handlers::push::push_unsubscribe,
214        crate::handlers::push::push_preferences,
215        // Admin / monitoring paths live in the `hypercall-admin` crate and are
216        // merged in by the api-spec-exporter via `hypercall_admin::admin_openapi()`.
217    ),
218    components(schemas(
219        // Core types
220        hypercall_types::WalletAddress,
221        OpenApiApiResponse,
222        crate::models::InstrumentStatus,
223        crate::models::Pagination,
224        // Margin types (used in Portfolio)
225        crate::models::MarginSummary,
226        crate::models::SpanMarginSummary,
227        // Response types from models.rs
228        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        // Handler response types
285        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        // Request types from hypercall_types
309        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        // Auth types
325        hypercall_auth::SignatureRequest,
326        // MMP and User Tier request types from models
327        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        // Shared message types
336        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        // Orderbook types
345        hypercall_types::Side,
346        // Greeks types
347        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        // Admin / monitoring schemas live in the `hypercall-admin` crate and are
355        // merged in by the api-spec-exporter via `hypercall_admin::admin_openapi()`.
356        // Push notification types
357        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        // Health response types
364        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
385/// Tags considered internal-only (hidden from public API docs).
386const HIDDEN_TAGS: &[&str] = &["Monitoring", "Admin"];
387
388/// Returns the complete internal OpenAPI spec.
389///
390/// The testnet agent-authorization doc moved to `hypercall-admin`
391/// (`TestEndpointsApiDoc` there, merged by `admin_openapi()` when its
392/// `test-endpoints` feature is on).
393pub 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
407/// Returns the public OpenAPI spec with hidden tags filtered out.
408pub fn public_openapi() -> utoipa::openapi::OpenApi {
409    filter_hidden_tags(internal_openapi())
410}
411
412/// Normalizes schema refs that utoipa emits with Rust module prefixes.
413pub 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    // Boundary port traits moved into hypercall-runtime-api; their schemas are
425    // still registered under short names, so strip the new module-qualified path.
426    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    // 1. Remove hidden operations, then remove path items with no visible operations.
472    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    // 2. Remove hidden tag definitions
483    if let Some(tags) = spec.tags.as_mut() {
484        tags.retain(|t| !HIDDEN_TAGS.contains(&t.name.as_str()));
485    }
486
487    // 3. Remove the admin_key security scheme (only used by hidden endpoints)
488    if let Some(components) = spec.components.as_mut() {
489        components
490            .security_schemes
491            .retain(|name, _| name != "admin_key");
492    }
493
494    // 4. Remove schemas that were only reachable from hidden paths. utoipa emits all
495    // registered component schemas up front, so path filtering alone can leak
496    // internal-only response/request shapes into the public spec.
497    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        // The engine-state-digest path and its EngineStateDigest schema now live
740        // in the `hypercall-admin` crate; the api public spec must not contain
741        // them. (Hidden-tag filtering of the merged admin spec is exercised in
742        // the api-spec-exporter.)
743        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}