Skip to main content

espresso_api/
lib.rs

1//! Espresso API server with both Axum (HTTP/JSON) and gRPC endpoints
2
3// Module declarations
4mod axum;
5pub mod error;
6mod render;
7pub mod v1;
8
9/// The v2 API contract, generated by `build.rs` from the proto files in `proto/`: message types,
10/// the tonic server traits, and the protoJSON `Serialize`/`Deserialize` impls that define the
11/// HTTP wire encoding. `render` builds these messages from consensus types.
12pub mod proto {
13    // Every pbjson `Deserialize` impl formats its field list as `{:?}` through a reference.
14    #![allow(clippy::useless_borrows_in_formatting)]
15    #![expect(
16        clippy::large_enum_variant,
17        reason = "prost lays every oneof arm out inline, so the ADVZ transaction proof dwarfs its \
18                  siblings, and boxing it (`Builder::boxed`) breaks pbjson-build's serde"
19    )]
20
21    include!(concat!(env!("OUT_DIR"), "/espresso.api.v2.rs"));
22    include!(concat!(env!("OUT_DIR"), "/espresso.api.v2.serde.rs"));
23}
24
25/// Axum REST handlers derived from the `google.api.http` annotations, transcoding
26/// HTTP/JSON onto the tonic service traits.
27pub mod rest {
28    // The generator emits `#[expect]` attributes that not every handler fulfills.
29    #![allow(unfulfilled_lint_expectations)]
30    #![expect(
31        clippy::too_many_arguments,
32        reason = "`all_rest_routes` takes one service per proto file, so its arity grows with the \
33                  API"
34    )]
35
36    include!(concat!(env!("OUT_DIR"), "/espresso.api.v2.rest.rs"));
37}
38
39/// The compiled proto descriptor set, for gRPC reflection.
40pub const FILE_DESCRIPTOR_SET: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/descriptor.bin"));
41
42use tower::Layer;
43
44// Re-exports
45pub use self::axum::{create_router_v1, routes};
46use self::proto::{
47    availability_service_server::{AvailabilityService, AvailabilityServiceServer},
48    catchup_service_server::{CatchupService, CatchupServiceServer},
49    config_service_server::{ConfigService, ConfigServiceServer},
50    database_service_server::{DatabaseService, DatabaseServiceServer},
51    explorer_service_server::{ExplorerService, ExplorerServiceServer},
52    light_client_service_server::{LightClientService, LightClientServiceServer},
53    merklized_state_service_server::{MerklizedStateService, MerklizedStateServiceServer},
54    node_service_server::{NodeService, NodeServiceServer},
55    reward_state_service_server::{RewardStateService, RewardStateServiceServer},
56    state_signature_service_server::{StateSignatureService, StateSignatureServiceServer},
57    status_service_server::{StatusService, StatusServiceServer},
58    submit_service_server::{SubmitService, SubmitServiceServer},
59    token_service_server::{TokenService, TokenServiceServer},
60};
61
62/// Build a full request URL from a server base URL and a path produced by one of the
63/// `routes::v1::*` builders.
64///
65/// Use this from test/CLI sites that have a `url::Url` pointing at the API server and want
66/// the absolute URL for a single request. Internally this is just `base.join(path)`; the
67/// helper exists so the (path-const, builder, joiner) trio reads as one chain.
68pub fn url(base: &::url::Url, path: impl AsRef<str>) -> ::url::Url {
69    base.join(path.as_ref())
70        .expect("path produced by routes::*::path_fn is always a valid relative URL")
71}
72
73/// Start Axum HTTP server with combined v1 and v2 APIs
74///
75/// This serves both APIs at /v1/* and /v2/* from a single state implementation.
76///
77/// `catchup`, like the query-service modules (`status`, `availability`, `node`, `token`,
78/// `block-state`, `fee-state`, `reward-state`, `database`) and `v2`, is always on: tide-disco's
79/// SQL mode registered it unconditionally. `light-client` is always on too, since peers fetch
80/// missing data from it. `submit`, `config`, and `explorer` follow `Options`, matching
81/// `Options::init_with_query_module_sql`, and v2's gated services follow the same flag as their
82/// v1 module.
83pub async fn serve_axum<S>(
84    port: u16,
85    state: S,
86    modules: OptionalModules,
87    max_connections: Option<usize>,
88) -> anyhow::Result<()>
89where
90    S: v1::RewardApi
91        + v1::AvailabilityApi
92        + v1::HotShotAvailabilityApi
93        + v1::BlockStateApi
94        + v1::FeeStateApi
95        + v1::StatusApi
96        + v1::ConfigApi
97        + v1::NodeApi
98        + v1::CatchupApi
99        + v1::SubmitApi
100        + v1::StateSignatureApi
101        + v1::LightClientApi
102        + v1::ExplorerApi
103        + v1::TokenApi
104        + v1::DatabaseApi
105        + StatusService
106        + TokenService
107        + NodeService
108        + ConfigService
109        + DatabaseService
110        + AvailabilityService
111        + MerklizedStateService
112        + RewardStateService
113        + StateSignatureService
114        + SubmitService
115        + ExplorerService
116        + CatchupService
117        + LightClientService
118        + Send
119        + Sync
120        + 'static,
121{
122    let listener = bind_api(port).await?;
123    let state = std::sync::Arc::new(state);
124    let mut router = axum::router_reward(state.clone())
125        .merge(axum::router_availability(state.clone()))
126        .merge(axum::router_block_state(state.clone()))
127        .merge(axum::router_fee_state(state.clone()))
128        .merge(axum::router_status(state.clone()))
129        .merge(axum::router_node(state.clone()))
130        .merge(axum::router_catchup(state.clone()))
131        .merge(axum::router_state_signature(state.clone()))
132        .merge(axum::router_token(state.clone()))
133        .merge(axum::router_database(state.clone()))
134        .merge(axum::router_light_client(state.clone()));
135    if modules.submit {
136        router = router.merge(axum::router_submit(state.clone()));
137    }
138    if modules.config {
139        router = router.merge(axum::router_config(state.clone()));
140    }
141    if modules.explorer {
142        router = router.merge(axum::router_explorer(state.clone()));
143    }
144
145    let router = axum::finish_v1_docs(router)
146        .merge(router_v2(state, modules))
147        .merge(axum::router_v2_docs());
148    serve_router(listener, "v1 and v2", router, max_connections).await
149}
150
151/// The v2 REST routes exactly as [`serve_axum`] mounts them. Extracted so the test asserting
152/// every documented route is mounted exercises the same construction; don't inline it back.
153pub(crate) fn router_v2<S>(state: std::sync::Arc<S>, modules: OptionalModules) -> ::axum::Router
154where
155    S: StatusService
156        + TokenService
157        + NodeService
158        + ConfigService
159        + DatabaseService
160        + AvailabilityService
161        + MerklizedStateService
162        + RewardStateService
163        + StateSignatureService
164        + SubmitService
165        + ExplorerService
166        + CatchupService
167        + LightClientService
168        + Send
169        + Sync
170        + 'static,
171{
172    let router = rest::status_service_rest_router(state.clone())
173        .merge(rest::token_service_rest_router(state.clone()))
174        .merge(rest::node_service_rest_router(state.clone()))
175        .merge(rest::database_service_rest_router(state.clone()))
176        .merge(rest::availability_service_rest_router(state.clone()))
177        .merge(rest::merklized_state_service_rest_router(state.clone()))
178        .merge(rest::reward_state_service_rest_router(state.clone()))
179        .merge(rest::state_signature_service_rest_router(state.clone()))
180        .merge(rest::catchup_service_rest_router(state.clone()))
181        .merge(rest::light_client_service_rest_router(state.clone()));
182    let router = if modules.submit {
183        router.merge(rest::submit_service_rest_router(state.clone()))
184    } else {
185        router.merge(axum::router_module_disabled(
186            "submit",
187            routes::v2::SUBMIT_ROUTES,
188        ))
189    };
190    let router = if modules.explorer {
191        router.merge(rest::explorer_service_rest_router(state.clone()))
192    } else {
193        router.merge(axum::router_module_disabled(
194            "explorer",
195            routes::v2::EXPLORER_ROUTES,
196        ))
197    };
198    let router = if modules.config {
199        router.merge(rest::config_service_rest_router(state))
200    } else {
201        router.merge(axum::router_module_disabled(
202            "config",
203            routes::v2::CONFIG_ROUTES,
204        ))
205    };
206    router
207        .layer(::axum::middleware::from_fn(axum::v2_refuse_post_query))
208        .layer(::axum::middleware::from_fn(axum::v2_error_envelope))
209}
210
211/// Which of the optional API modules to serve, for modes that make them conditional
212/// (mirroring `Options::submit`/`Options::config`/`Options::explorer`).
213#[derive(Default, Clone, Copy, Debug)]
214pub struct OptionalModules {
215    pub submit: bool,
216    pub catchup: bool,
217    pub config: bool,
218    pub explorer: bool,
219}
220
221/// Serve the query API used by the filesystem-backed storage mode: status, availability, node,
222/// token, catchup, and state-signature are always on (tide registered them unconditionally);
223/// submit and config follow `Options`. Filesystem storage doesn't implement the
224/// reward/merklized-state/explorer/database traits, so those modules aren't served (a request to
225/// one of their routes 404s, matching tide).
226pub async fn serve_axum_fs<S>(
227    port: u16,
228    state: S,
229    modules: OptionalModules,
230    max_connections: Option<usize>,
231) -> anyhow::Result<()>
232where
233    S: v1::StatusApi
234        + v1::AvailabilityApi
235        + v1::HotShotAvailabilityApi
236        + v1::NodeApi
237        + v1::TokenApi
238        + v1::CatchupApi
239        + v1::SubmitApi
240        + v1::StateSignatureApi
241        + v1::ConfigApi
242        + Send
243        + Sync
244        + 'static,
245{
246    let listener = bind_api(port).await?;
247    let state = std::sync::Arc::new(state);
248    let mut router = axum::router_status(state.clone())
249        .merge(axum::router_availability(state.clone()))
250        .merge(axum::router_node(state.clone()))
251        .merge(axum::router_token(state.clone()))
252        .merge(axum::router_catchup(state.clone()))
253        .merge(axum::router_state_signature(state.clone()));
254    if modules.submit {
255        router = router.merge(axum::router_submit(state.clone()));
256    }
257    if modules.config {
258        router = router.merge(axum::router_config(state.clone()));
259    }
260
261    serve_router(
262        listener,
263        "fs",
264        axum::finish_v1_docs(router),
265        max_connections,
266    )
267    .await
268}
269
270/// Serve the status-only API: no availability/node/token data source is available, so only
271/// status and the HotShot modules (submit, catchup, state-signature, config) can
272/// be served. State-signature is always on; the rest follow `Options`.
273pub async fn serve_axum_status<S>(
274    port: u16,
275    state: S,
276    modules: OptionalModules,
277    max_connections: Option<usize>,
278) -> anyhow::Result<()>
279where
280    S: v1::StatusApi
281        + v1::SubmitApi
282        + v1::CatchupApi
283        + v1::StateSignatureApi
284        + v1::ConfigApi
285        + Send
286        + Sync
287        + 'static,
288{
289    let listener = bind_api(port).await?;
290    let state = std::sync::Arc::new(state);
291    let router =
292        axum::router_status(state.clone()).merge(axum::router_state_signature(state.clone()));
293    let router = merge_hotshot_modules(router, &state, modules);
294    serve_router(
295        listener,
296        "status",
297        axum::finish_v1_docs(router),
298        max_connections,
299    )
300    .await
301}
302
303/// Serve the bare API (no query or status module): only the HotShot modules are available,
304/// since the only app state is the HotShot handle. State-signature is always on; the rest follow
305/// `Options`, matching `Options::init_hotshot_modules`.
306pub async fn serve_axum_bare<S>(
307    port: u16,
308    state: S,
309    modules: OptionalModules,
310    max_connections: Option<usize>,
311) -> anyhow::Result<()>
312where
313    S: v1::SubmitApi
314        + v1::CatchupApi
315        + v1::StateSignatureApi
316        + v1::ConfigApi
317        + Send
318        + Sync
319        + 'static,
320{
321    let listener = bind_api(port).await?;
322    let state = std::sync::Arc::new(state);
323    let router = axum::router_state_signature(state.clone());
324    let router = merge_hotshot_modules(router, &state, modules);
325    serve_router(
326        listener,
327        "bare",
328        axum::finish_v1_docs(router),
329        max_connections,
330    )
331    .await
332}
333
334fn merge_hotshot_modules<S>(
335    mut router: aide::axum::ApiRouter,
336    state: &std::sync::Arc<S>,
337    modules: OptionalModules,
338) -> aide::axum::ApiRouter
339where
340    S: v1::SubmitApi + v1::CatchupApi + v1::ConfigApi + Send + Sync + 'static,
341{
342    if modules.submit {
343        router = router.merge(axum::router_submit(state.clone()));
344    }
345    if modules.catchup {
346        router = router.merge(axum::router_catchup(state.clone()));
347    }
348    if modules.config {
349        router = router.merge(axum::router_config(state.clone()));
350    }
351
352    router
353}
354
355/// Add the reserved top-level routes, apply the optional concurrency limit, rewrite legacy URIs,
356/// and bind/serve the router. Shared by all `serve_axum*` entry points.
357/// Bind before composing routers: OpenAPI generation takes ~0.5s in debug builds, and clients
358/// connecting during it should queue in the accept backlog rather than get refused.
359async fn bind_api(port: u16) -> anyhow::Result<tokio::net::TcpListener> {
360    let addr = format!("0.0.0.0:{}", port);
361    tracing::info!("Binding to {}", addr);
362    Ok(tokio::net::TcpListener::bind(&addr).await?)
363}
364
365async fn serve_router(
366    listener: tokio::net::TcpListener,
367    mode: &str,
368    router: ::axum::Router,
369    max_connections: Option<usize>,
370) -> anyhow::Result<()> {
371    let mut router = axum::with_top_level_routes(router);
372    if let Some(limit) = max_connections {
373        router = apply_connection_limit(router, limit);
374    }
375    let router = router.layer(http_wire::body_limit_layer());
376    // CORS goes on last so it wraps the connection limit, whose 429 would otherwise skip it.
377    let router = router.layer(http_wire::cors_layer());
378    // `Router::layer` middleware runs after routing, so it can't rewrite a URI to match a
379    // different route. Wrapping the whole router with `MapRequestLayer` instead runs the
380    // rewrite before routing, per the axum-documented pattern for this case.
381    let router = tower::util::MapRequestLayer::new(axum::rewrite_legacy_uri).layer(router);
382
383    tracing::info!(
384        "Axum API server listening on {:?} ({} mode)",
385        listener.local_addr()?,
386        mode
387    );
388    ::axum::serve(listener, ::axum::ServiceExt::into_make_service(router)).await?;
389
390    tracing::info!("Axum server stopped");
391    Ok(())
392}
393
394/// Shared budget: a request holds a slot until its handler returns, excess gets 429. A stream
395/// (websocket or SSE) releases its slot once the response starts, so open streams are not capped
396/// here, see [`axum::limit_requests`].
397fn apply_connection_limit(router: ::axum::Router, limit: usize) -> ::axum::Router {
398    let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(limit));
399    router
400        .layer(::axum::middleware::from_fn(axum::limit_requests))
401        .layer(::axum::Extension(axum::RequestLimit(semaphore)))
402}
403
404/// Start Tonic gRPC server. The gated services follow their module flag like the REST side; the
405/// reflection service still lists them, so a disabled deployment answers them with `Unimplemented`.
406pub async fn serve_tonic<S>(port: u16, state: S, modules: OptionalModules) -> anyhow::Result<()>
407where
408    S: StatusService
409        + TokenService
410        + NodeService
411        + ConfigService
412        + DatabaseService
413        + AvailabilityService
414        + MerklizedStateService
415        + RewardStateService
416        + StateSignatureService
417        + SubmitService
418        + ExplorerService
419        + CatchupService
420        + LightClientService
421        + Clone,
422{
423    use ::tonic::transport::Server;
424
425    let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
426
427    // Enable gRPC reflection for tools like grpcurl
428    let reflection_service = tonic_reflection::server::Builder::configure()
429        .register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET)
430        .build_v1()?;
431
432    let router = Server::builder()
433        .add_service(StatusServiceServer::new(state.clone()))
434        .add_service(TokenServiceServer::new(state.clone()))
435        .add_service(NodeServiceServer::new(state.clone()))
436        .add_service(DatabaseServiceServer::new(state.clone()))
437        .add_service(AvailabilityServiceServer::new(state.clone()))
438        .add_service(MerklizedStateServiceServer::new(state.clone()))
439        .add_service(RewardStateServiceServer::new(state.clone()))
440        .add_service(StateSignatureServiceServer::new(state.clone()))
441        .add_service(CatchupServiceServer::new(state.clone()))
442        .add_service(LightClientServiceServer::new(state.clone()))
443        .add_service(reflection_service)
444        .add_optional_service(modules.submit.then(|| {
445            // tonic's 4 MiB default would refuse a transaction the REST route and the block size
446            // limit accept.
447            SubmitServiceServer::new(state.clone())
448                .max_decoding_message_size(http_wire::MAX_REQUEST_BODY_BYTES)
449        }))
450        .add_optional_service(
451            modules
452                .explorer
453                .then(|| ExplorerServiceServer::new(state.clone())),
454        )
455        .add_optional_service(modules.config.then(|| ConfigServiceServer::new(state)));
456
457    tracing::info!("gRPC server listening on {}", addr);
458    router.serve(addr).await?;
459
460    Ok(())
461}