1use axum::{
2 extract::DefaultBodyLimit,
3 middleware as axum_middleware,
4 response::IntoResponse,
5 routing::{delete, get, post, put},
6 Router,
7};
8use hypercall_types::{
9 API_ROUTE_APPROVE_AGENT, API_ROUTE_BULK_ORDER, API_ROUTE_BULK_ORDER_CLOID,
10 API_ROUTE_MARGIN_MODE, API_ROUTE_NOTIFICATIONS_MARK_ALL_READ,
11 API_ROUTE_NOTIFICATIONS_MARK_READ, API_ROUTE_ORDER, API_ROUTE_ORDERBOOK, API_ROUTE_ORDERS,
12 API_ROUTE_ORDER_CLOID, API_ROUTE_PROFILE_IMAGE, API_ROUTE_PUSH_PREFERENCES,
13 API_ROUTE_PUSH_SUBSCRIBE, API_ROUTE_PUSH_UNSUBSCRIBE, API_ROUTE_REVOKE_AGENT,
14 API_ROUTE_RFQ_ACCEPT, API_ROUTE_RFQ_REQUEST, API_ROUTE_SETTLEMENT_PAYOUTS,
15 API_ROUTE_SETTLEMENT_PAYOUTS_SEEN, API_ROUTE_USERNAME,
16};
17#[cfg(feature = "api-reference")]
18use scalar_api_reference::scalar_html_default;
19use std::sync::Arc;
20use std::time::Duration;
21use tower_http::{
22 catch_panic::CatchPanicLayer,
23 cors::CorsLayer,
24 trace::{self, TraceLayer},
25};
26use tracing::Level;
27#[cfg(feature = "api-reference")]
28use utoipa_swagger_ui::SwaggerUi;
29
30use super::{directives, handlers, middleware, websocket};
31use crate::rfq::handler_state::RfqHandlerState;
32use crate::rfq::qp_ws_state::QpWsState;
33use crate::runtime_status::{ReadinessGate, StandbyReplayProgress};
34
35type PromoteSender = Arc<tokio::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>>;
36
37pub struct BuildAppParams {
38 pub app_state: handlers::AppState,
39 pub rate_limit_state: middleware::RateLimitState,
40 pub ws_state: websocket::WsState,
41 pub qp_ws_state: QpWsState,
42 pub rfq_handler_state: RfqHandlerState,
43 pub readiness: Arc<dyn ReadinessGate>,
44 pub standby_progress: Option<Arc<dyn StandbyReplayProgress>>,
45 pub standby_promote: Option<PromoteSender>,
46 pub admin_router: Router,
49 pub openapi_doc: utoipa::openapi::OpenApi,
54}
55
56#[cfg(feature = "otel-tracing")]
57fn with_trace_context_layer(app: Router) -> Router {
58 app.layer(axum_middleware::from_fn(
60 middleware::trace_context_middleware,
61 ))
62}
63
64#[cfg(not(feature = "otel-tracing"))]
65fn with_trace_context_layer(app: Router) -> Router {
66 app
67}
68
69async fn metrics_handler(
71 axum::extract::State(app_state): axum::extract::State<handlers::AppState>,
72) -> impl IntoResponse {
73 (
74 [(
75 axum::http::header::CONTENT_TYPE,
76 "text/plain; version=0.0.4; charset=utf-8",
77 )],
78 app_state.metrics_renderer.render_metrics(),
79 )
80}
81
82pub fn build_app(params: BuildAppParams) -> Router {
83 let BuildAppParams {
84 app_state,
85 rate_limit_state,
86 ws_state,
87 qp_ws_state,
88 rfq_handler_state,
89 readiness,
90 standby_progress,
91 standby_promote,
92 admin_router,
93 openapi_doc,
94 } = params;
95
96 let app = Router::new().merge(public_routes(&app_state, rate_limit_state.clone()));
97 #[cfg(feature = "rsm-state")]
98 let app = app.merge(rsm_rpc_routes(&app_state));
99 let app = app
100 .merge(write_routes(&app_state, rate_limit_state))
101 .merge(bulk_routes(&app_state))
102 .merge(directives_routes(&app_state))
103 .merge(market_support_routes(&app_state))
104 .merge(websocket_routes(ws_state, qp_ws_state))
105 .merge(rfq_routes(rfq_handler_state.clone()))
106 .merge(admin_router);
107
108 let app = with_api_reference_routes(app, openapi_doc);
109 with_global_layers(app, readiness, standby_progress, standby_promote)
110}
111
112fn public_routes(
113 app_state: &handlers::AppState,
114 rate_limit_state: middleware::RateLimitState,
115) -> Router {
116 let rate_limited_public_routes = Router::new()
119 .route("/portfolio", get(handlers::account::get_portfolio))
120 .route(
121 "/portfolio/greeks",
122 get(handlers::greeks::get_portfolio_greeks),
123 )
124 .route("/profile", get(handlers::competition::get_profile))
125 .route(
126 "/profile/trades",
127 get(handlers::competition::get_profile_trades),
128 )
129 .route(
130 "/profile/realized-pnl",
131 get(handlers::competition::get_realized_pnl),
132 )
133 .route(
134 "/competitions/leaderboard",
135 get(handlers::competition::get_competition_leaderboard),
136 )
137 .route("/risk/grid", get(handlers::account::get_risk_grid))
138 .route("/fills", get(handlers::account::get_fills))
139 .route(API_ROUTE_ORDERS, get(handlers::account::get_orders))
140 .route(
141 "/notifications",
142 get(handlers::notifications::list_notifications),
143 )
144 .route(
145 "/historical-pnl",
146 get(handlers::historical_pnl::get_historical_pnl),
147 )
148 .route("/mmp-config", get(handlers::admin::get_mmp_config))
149 .route("/user-tier", get(handlers::admin::get_user_tier))
150 .route(API_ROUTE_USERNAME, get(handlers::username::get_username))
152 .layer(axum_middleware::from_fn_with_state(
153 rate_limit_state,
154 middleware::api_rate_limit_middleware,
155 ))
156 .with_state(app_state.clone());
157
158 let public_routes = Router::new()
159 .route("/trades", get(handlers::account::get_trades))
160 .route("/markets", get(handlers::account::get_markets))
161 .route("/health", get(handlers::health::health))
162 .route("/version", get(handlers::health::version))
163 .route("/ready", get(handlers::health::ready))
164 .route("/exchange-info", get(handlers::health::exchange_info))
165 .route("/standby-ready", get(handlers::health::standby_ready))
166 .route("/metrics", get(metrics_handler))
167 .route("/instruments", get(handlers::market_data::get_instruments))
168 .route(
169 "/instrument-specs",
170 get(handlers::market_data::get_instrument_specs),
171 )
172 .route(
173 "/gas-provider/:chain_id",
174 get(handlers::gas_provider::get_gas_provider),
175 )
176 .route("/candles", get(handlers::candles::get_candles))
177 .route(
178 "/options-summary",
179 get(handlers::market_data::get_options_summary),
180 )
181 .route(
182 "/expiry-summary",
183 get(handlers::market_data::get_expiry_summary),
184 )
185 .route(
186 API_ROUTE_ORDERBOOK,
187 get(handlers::options_chain::get_orderbook),
188 )
189 .route(
190 "/historical-theos",
191 get(handlers::historical_theos::get_historical_theos),
192 )
193 .route(
194 "/historical-theos/batch",
195 get(handlers::historical_theos::get_historical_theos_batch),
196 )
197 .route(
198 "/competitions",
199 get(handlers::competition::list_competitions),
200 )
201 .route(
202 "/competitions/:id",
203 get(handlers::competition::get_competition),
204 )
205 .route(
207 API_ROUTE_APPROVE_AGENT,
208 post(handlers::agents::approve_agent),
209 )
210 .route(
211 API_ROUTE_REVOKE_AGENT,
212 delete(handlers::agents::revoke_agent),
213 )
214 .route(
215 "/authorized-agents",
216 get(handlers::agents::get_authorized_agents),
217 );
218
219 #[cfg(feature = "faucet")]
220 let public_routes = public_routes.route("/faucet", post(handlers::testnet::faucet));
221
222 #[cfg(feature = "test-endpoints")]
223 let public_routes = public_routes
224 .route("/test/spot-price", post(handlers::testnet::set_spot_price))
225 .route("/test/option-iv", post(handlers::testnet::set_option_iv))
226 .route(
227 "/test/balance-ledger",
228 post(handlers::testnet::get_balance_ledger),
229 )
230 .route(
231 "/test/option-deposit",
232 post(handlers::testnet::apply_option_deposit),
233 )
234 .route(
235 "/test/cancel-all-orders",
236 post(handlers::admin::cancel_all_orders),
237 )
238 .route(
239 "/test/expire-instrument",
240 post(handlers::testnet::expire_instrument),
241 );
242
243 public_routes
244 .merge(rate_limited_public_routes)
245 .with_state(app_state.clone())
246}
247
248#[cfg(feature = "rsm-state")]
249fn rsm_rpc_routes(app_state: &handlers::AppState) -> Router {
250 if std::env::var("RSM_RPC_ENABLED")
251 .map(|value| value == "true" || value == "1")
252 .unwrap_or(false)
253 {
254 tracing::info!("RSM block JSON-RPC enabled at /rsm/rpc");
255 Router::new()
256 .route("/rsm/rpc", post(handlers::rsm_rpc::rsm_rpc))
257 .with_state(app_state.clone())
258 } else {
259 tracing::info!("RSM block JSON-RPC disabled (set RSM_RPC_ENABLED=true to enable)");
260 Router::new().with_state(app_state.clone())
261 }
262}
263
264fn write_routes(
265 app_state: &handlers::AppState,
266 rate_limit_state: middleware::RateLimitState,
267) -> Router {
268 Router::new()
271 .route(API_ROUTE_ORDER, post(handlers::orders::place_order))
272 .route(
273 API_ROUTE_ORDER,
274 put(handlers::replace_orders::replace_order),
275 )
276 .route(API_ROUTE_ORDER, delete(handlers::orders::cancel_order))
277 .route(
278 API_ROUTE_ORDER_CLOID,
279 delete(handlers::orders::cancel_order_by_cloid),
280 )
281 .route(
282 API_ROUTE_SETTLEMENT_PAYOUTS_SEEN,
283 post(handlers::settlement::mark_settlement_payouts_seen),
284 )
285 .route("/withdraw/option", post(handlers::account::withdraw_option))
286 .route("/withdraw/usdc", post(handlers::account::withdraw_usdc))
287 .route(
288 API_ROUTE_PROFILE_IMAGE,
289 post(handlers::profile_image::set_profile_image)
290 .layer(DefaultBodyLimit::max(7_000_000)),
291 )
292 .route("/mmp-config", post(handlers::admin::set_mmp_config))
294 .route("/mmp-config", delete(handlers::admin::delete_mmp_config))
295 .route("/mmp-config/reset", post(handlers::admin::reset_mmp))
296 .route("/user-tier", post(handlers::admin::set_user_tier))
298 .route("/user-tier", delete(handlers::admin::delete_user_tier))
299 .route(API_ROUTE_USERNAME, post(handlers::username::set_username))
301 .route(
302 API_ROUTE_USERNAME,
303 delete(handlers::username::delete_username),
304 )
305 .route(
307 API_ROUTE_PUSH_SUBSCRIBE,
308 post(handlers::push::push_subscribe),
309 )
310 .route(
311 API_ROUTE_PUSH_UNSUBSCRIBE,
312 post(handlers::push::push_unsubscribe),
313 )
314 .route(
315 API_ROUTE_PUSH_PREFERENCES,
316 post(handlers::push::push_preferences),
317 )
318 .route(
321 API_ROUTE_NOTIFICATIONS_MARK_READ,
322 post(handlers::notifications::mark_read),
323 )
324 .route(
325 API_ROUTE_NOTIFICATIONS_MARK_ALL_READ,
326 post(handlers::notifications::mark_all_read),
327 )
328 .route(
330 API_ROUTE_MARGIN_MODE,
331 post(handlers::account::set_margin_mode),
332 )
333 .layer(axum_middleware::from_fn_with_state(
335 rate_limit_state,
336 middleware::write_route_rate_limit_middleware,
337 ))
338 .layer(axum_middleware::from_fn_with_state(
340 middleware::SignatureMiddlewareState {
341 agent_auth: app_state.agent_auth.clone(),
342 auth_failure_recorder: app_state.auth_failure_recorder.clone(),
343 signing_chain_id: app_state.runtime_config.signing_chain_id,
344 },
345 middleware::signature_and_agent_middleware,
346 ))
347 .with_state(app_state.clone())
348}
349
350fn bulk_routes(app_state: &handlers::AppState) -> Router {
351 Router::new()
353 .route(
354 API_ROUTE_BULK_ORDER,
355 post(handlers::bulk_orders::bulk_place_order),
356 )
357 .route(
358 API_ROUTE_BULK_ORDER,
359 put(handlers::replace_orders::bulk_replace_order),
360 )
361 .route(
362 API_ROUTE_BULK_ORDER,
363 delete(handlers::bulk_orders::bulk_cancel_order),
364 )
365 .route(
366 API_ROUTE_BULK_ORDER_CLOID,
367 delete(handlers::bulk_orders::bulk_cancel_order_by_cloid),
368 )
369 .with_state(app_state.clone())
370}
371
372fn directives_routes(app_state: &handlers::AppState) -> Router {
373 directives::router()
374 .route(
375 "/v1/directives/:directive_id",
376 get(handlers::directives::get_directive_status),
377 )
378 .route(
379 "/v1/withdrawals",
380 get(handlers::directives::get_withdrawal_history),
381 )
382 .with_state(app_state.clone())
383}
384
385fn market_support_routes(app_state: &handlers::AppState) -> Router {
386 let greeks_routes = Router::new()
388 .route("/greeks", get(handlers::greeks::get_greeks))
389 .with_state(app_state.clone());
390
391 let liquidation_routes = Router::new()
393 .route(
394 "/liquidation/status",
395 get(handlers::liquidation::get_liquidation_status),
396 )
397 .route(
398 "/liquidation/history",
399 get(handlers::liquidation::get_liquidation_history),
400 )
401 .route(
402 "/liquidation/auction/:auction_id",
403 get(handlers::liquidation::get_liquidation_auction),
404 )
405 .with_state(app_state.clone());
406
407 let settlement_routes = Router::new()
409 .route(
410 API_ROUTE_SETTLEMENT_PAYOUTS,
411 get(handlers::settlement::get_settlement_payouts),
412 )
413 .with_state(app_state.clone());
414
415 Router::new()
416 .merge(greeks_routes)
417 .merge(liquidation_routes)
418 .merge(settlement_routes)
419}
420
421fn websocket_routes(ws_state: websocket::WsState, qp_ws_state: QpWsState) -> Router {
422 let ws_routes = Router::new()
424 .route("/ws", get(websocket::websocket_handler))
425 .with_state(ws_state);
426
427 let qp_ws_routes = Router::new()
429 .route("/ws/quotes", get(websocket::rfq::qp_websocket_handler))
430 .with_state(qp_ws_state);
431
432 Router::new().merge(ws_routes).merge(qp_ws_routes)
433}
434
435fn rfq_routes(rfq_handler_state: RfqHandlerState) -> Router {
436 Router::new()
440 .route(API_ROUTE_RFQ_REQUEST, post(handlers::rfq::submit_rfq))
441 .route(API_ROUTE_RFQ_ACCEPT, post(handlers::rfq::accept_rfq_quote))
442 .route("/rfq/:rfq_id", get(handlers::rfq::get_rfq))
443 .route("/rfq/history", get(handlers::rfq::get_rfq_history))
444 .with_state(rfq_handler_state)
445}
446
447#[cfg(feature = "api-reference")]
448fn with_api_reference_routes(mut app: Router, openapi_doc: utoipa::openapi::OpenApi) -> Router {
449 let openapi_doc = crate::openapi::filter_hidden_tags(openapi_doc);
453
454 tracing::info!("Swagger UI enabled at /swagger-ui");
457 app = app.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", openapi_doc));
458
459 let docs_redirect =
461 || async { axum::response::Redirect::to("https://docs.hypercall.xyz/api-reference") };
462 app = app
463 .route("/", axum::routing::get(docs_redirect))
464 .route("/docs", axum::routing::get(docs_redirect));
465
466 tracing::info!("Scalar API Reference enabled at /scalar");
468 let scalar_config = serde_json::json!({
469 "url": "/api-docs/openapi.json",
470 "theme": "purple"
471 });
472 let scalar_html = scalar_html_default(&scalar_config);
473 app.route(
474 "/scalar",
475 axum::routing::get(move || {
476 let html = scalar_html.clone();
477 async move { axum::response::Html(html) }
478 }),
479 )
480}
481
482#[cfg(not(feature = "api-reference"))]
483fn with_api_reference_routes(app: Router, _openapi_doc: utoipa::openapi::OpenApi) -> Router {
484 app
485}
486
487fn with_global_layers(
488 app: Router,
489 readiness: Arc<dyn ReadinessGate>,
490 standby_progress: Option<Arc<dyn StandbyReplayProgress>>,
491 standby_promote: Option<PromoteSender>,
492) -> Router {
493 let app = app.layer(axum_middleware::from_fn_with_state(
494 middleware::ReadinessMiddlewareState::new(readiness, standby_progress, standby_promote),
495 middleware::readiness_middleware,
496 ));
497 let app = with_trace_context_layer(app);
498
499 app.layer(
500 TraceLayer::new_for_http()
501 .make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO))
502 .on_response(trace::DefaultOnResponse::new().level(Level::INFO))
503 .on_failure(trace::DefaultOnFailure::new().level(Level::ERROR)),
504 )
505 .layer(CatchPanicLayer::new())
506 .layer(CorsLayer::permissive().max_age(Duration::from_secs(86400)))
507}