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;
6pub mod v1;
7
8/// The v2 API contract, generated by `build.rs` from the proto files in `proto/`: message types,
9/// the tonic server traits, and the protoJSON `Serialize`/`Deserialize` impls that define the
10/// HTTP wire encoding.
11pub mod proto {
12    // Every pbjson `Deserialize` impl formats its field list as `{:?}` through a reference.
13    #![allow(clippy::useless_borrows_in_formatting)]
14
15    include!("generated/espresso.api.v2.rs");
16    include!("generated/espresso.api.v2.serde.rs");
17}
18
19/// Axum REST handlers derived from the `google.api.http` annotations, transcoding
20/// HTTP/JSON onto the tonic service traits.
21pub mod rest {
22    // The generator emits `#[expect]` attributes that not every handler fulfills.
23    #![allow(unfulfilled_lint_expectations)]
24
25    include!("generated/espresso.api.v2.rest.rs");
26}
27
28/// The compiled proto descriptor set, for gRPC reflection.
29pub const FILE_DESCRIPTOR_SET: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/descriptor.bin"));
30
31use tower::Layer;
32
33// Re-exports
34pub use self::axum::{create_router_v1, routes};
35use self::proto::{
36    status_service_server::{StatusService, StatusServiceServer},
37    token_service_server::{TokenService, TokenServiceServer},
38};
39
40/// Build a full request URL from a server base URL and a path produced by one of the
41/// `routes::v1::*` builders.
42///
43/// Use this from test/CLI sites that have a `url::Url` pointing at the API server and want
44/// the absolute URL for a single request. Internally this is just `base.join(path)`; the
45/// helper exists so the (path-const, builder, joiner) trio reads as one chain.
46pub fn url(base: &::url::Url, path: impl AsRef<str>) -> ::url::Url {
47    base.join(path.as_ref())
48        .expect("path produced by routes::*::path_fn is always a valid relative URL")
49}
50
51/// Start Axum HTTP server with combined v1 and v2 APIs
52///
53/// This serves both APIs at /v1/* and /v2/* from a single state implementation.
54///
55/// `catchup`, like the query-service modules (`status`, `availability`, `node`, `token`,
56/// `block-state`, `fee-state`, `reward-state`, `database`) and `v2`, is always on: tide-disco's
57/// SQL mode registered it unconditionally. `submit`, `config`, `explorer`, `light-client`, and
58/// `hotshot-events` follow `Options`, matching `Options::init_with_query_module_sql`.
59pub async fn serve_axum<S>(
60    port: u16,
61    state: S,
62    modules: OptionalModules,
63    max_connections: Option<usize>,
64) -> anyhow::Result<()>
65where
66    S: v1::RewardApi
67        + v1::AvailabilityApi
68        + v1::HotShotAvailabilityApi
69        + v1::BlockStateApi
70        + v1::FeeStateApi
71        + v1::StatusApi
72        + v1::ConfigApi
73        + v1::NodeApi
74        + v1::CatchupApi
75        + v1::SubmitApi
76        + v1::StateSignatureApi
77        + v1::HotShotEventsApi
78        + v1::LightClientApi
79        + v1::ExplorerApi
80        + v1::TokenApi
81        + v1::DatabaseApi
82        + StatusService
83        + TokenService
84        + Clone
85        + Send
86        + Sync
87        + 'static,
88{
89    let listener = bind_api(port).await?;
90    let mut router = axum::router_reward(state.clone())
91        .merge(axum::router_availability(state.clone()))
92        .merge(axum::router_block_state(state.clone()))
93        .merge(axum::router_fee_state(state.clone()))
94        .merge(axum::router_status(state.clone()))
95        .merge(axum::router_node(state.clone()))
96        .merge(axum::router_catchup(state.clone()))
97        .merge(axum::router_state_signature(state.clone()))
98        .merge(axum::router_token(state.clone()))
99        .merge(axum::router_database(state.clone()));
100    if modules.submit {
101        router = router.merge(axum::router_submit(state.clone()));
102    }
103    if modules.config {
104        router = router.merge(axum::router_config(state.clone()));
105    }
106    if modules.explorer {
107        router = router.merge(axum::router_explorer(state.clone()));
108    }
109    if modules.light_client {
110        router = router.merge(axum::router_light_client(state.clone()));
111    }
112    if modules.hotshot_events {
113        router = router.merge(axum::router_hotshot_events(state.clone()));
114    }
115    let router = axum::finish_v1_docs(router)
116        .merge(router_v2(std::sync::Arc::new(state)))
117        .merge(axum::router_v2_docs());
118    serve_router(listener, "v1 and v2", router, max_connections).await
119}
120
121/// The v2 REST routes exactly as [`serve_axum`] mounts them. Extracted so the test asserting
122/// every documented route is mounted exercises the same construction; don't inline it back.
123pub(crate) fn router_v2<S>(state: std::sync::Arc<S>) -> ::axum::Router
124where
125    S: StatusService + TokenService + Send + Sync + 'static,
126{
127    rest::status_service_rest_router(state.clone())
128        .merge(rest::token_service_rest_router(state))
129        .layer(::axum::middleware::from_fn(axum::v2_error_envelope))
130}
131
132/// Which of the optional API modules to serve, for modes that make them conditional
133/// (mirroring `Options::submit`/`Options::config`/`Options::explorer`/`Options::light_client`/
134/// `Options::hotshot_events`).
135#[derive(Default, Clone, Copy, Debug)]
136pub struct OptionalModules {
137    pub submit: bool,
138    pub catchup: bool,
139    pub config: bool,
140    pub hotshot_events: bool,
141    pub explorer: bool,
142    pub light_client: bool,
143}
144
145/// Serve the query API used by the filesystem-backed storage mode: status, availability, node,
146/// token, catchup, and state-signature are always on (tide registered them unconditionally);
147/// submit, config, and hotshot-events follow `Options`. Filesystem storage doesn't implement the
148/// reward/merklized-state/explorer/database traits, so those modules aren't served (a request to
149/// one of their routes 404s, matching tide).
150pub async fn serve_axum_fs<S>(
151    port: u16,
152    state: S,
153    modules: OptionalModules,
154    max_connections: Option<usize>,
155) -> anyhow::Result<()>
156where
157    S: v1::StatusApi
158        + v1::AvailabilityApi
159        + v1::HotShotAvailabilityApi
160        + v1::NodeApi
161        + v1::TokenApi
162        + v1::CatchupApi
163        + v1::SubmitApi
164        + v1::StateSignatureApi
165        + v1::ConfigApi
166        + v1::HotShotEventsApi
167        + Clone
168        + Send
169        + Sync
170        + 'static,
171{
172    let listener = bind_api(port).await?;
173    let mut router = axum::router_status(state.clone())
174        .merge(axum::router_availability(state.clone()))
175        .merge(axum::router_node(state.clone()))
176        .merge(axum::router_token(state.clone()))
177        .merge(axum::router_catchup(state.clone()))
178        .merge(axum::router_state_signature(state.clone()));
179    if modules.submit {
180        router = router.merge(axum::router_submit(state.clone()));
181    }
182    if modules.config {
183        router = router.merge(axum::router_config(state.clone()));
184    }
185    if modules.hotshot_events {
186        router = router.merge(axum::router_hotshot_events(state));
187    }
188    serve_router(
189        listener,
190        "fs",
191        axum::finish_v1_docs(router),
192        max_connections,
193    )
194    .await
195}
196
197/// Serve the status-only API: no availability/node/token data source is available, so only
198/// status and the HotShot modules (submit, catchup, state-signature, config, hotshot-events) can
199/// be served. State-signature is always on; the rest follow `Options`.
200pub async fn serve_axum_status<S>(
201    port: u16,
202    state: S,
203    modules: OptionalModules,
204    max_connections: Option<usize>,
205) -> anyhow::Result<()>
206where
207    S: v1::StatusApi
208        + v1::SubmitApi
209        + v1::CatchupApi
210        + v1::StateSignatureApi
211        + v1::ConfigApi
212        + v1::HotShotEventsApi
213        + Clone
214        + Send
215        + Sync
216        + 'static,
217{
218    let listener = bind_api(port).await?;
219    let router =
220        axum::router_status(state.clone()).merge(axum::router_state_signature(state.clone()));
221    let router = merge_hotshot_modules(router, &state, modules);
222    serve_router(
223        listener,
224        "status",
225        axum::finish_v1_docs(router),
226        max_connections,
227    )
228    .await
229}
230
231/// Serve the bare API (no query or status module): only the HotShot modules are available,
232/// since the only app state is the HotShot handle. State-signature is always on; the rest follow
233/// `Options`, matching `Options::init_hotshot_modules`.
234pub async fn serve_axum_bare<S>(
235    port: u16,
236    state: S,
237    modules: OptionalModules,
238    max_connections: Option<usize>,
239) -> anyhow::Result<()>
240where
241    S: v1::SubmitApi
242        + v1::CatchupApi
243        + v1::StateSignatureApi
244        + v1::ConfigApi
245        + v1::HotShotEventsApi
246        + Clone
247        + Send
248        + Sync
249        + 'static,
250{
251    let listener = bind_api(port).await?;
252    let router = axum::router_state_signature(state.clone());
253    let router = merge_hotshot_modules(router, &state, modules);
254    serve_router(
255        listener,
256        "bare",
257        axum::finish_v1_docs(router),
258        max_connections,
259    )
260    .await
261}
262
263fn merge_hotshot_modules<S>(
264    mut router: aide::axum::ApiRouter,
265    state: &S,
266    modules: OptionalModules,
267) -> aide::axum::ApiRouter
268where
269    S: v1::SubmitApi
270        + v1::CatchupApi
271        + v1::ConfigApi
272        + v1::HotShotEventsApi
273        + Clone
274        + Send
275        + Sync
276        + 'static,
277{
278    if modules.submit {
279        router = router.merge(axum::router_submit(state.clone()));
280    }
281    if modules.catchup {
282        router = router.merge(axum::router_catchup(state.clone()));
283    }
284    if modules.config {
285        router = router.merge(axum::router_config(state.clone()));
286    }
287    if modules.hotshot_events {
288        router = router.merge(axum::router_hotshot_events(state.clone()));
289    }
290    router
291}
292
293/// Add the reserved top-level routes, apply the optional concurrency limit, rewrite legacy URIs,
294/// and bind/serve the router. Shared by all `serve_axum*` entry points.
295/// Bind before composing routers: OpenAPI generation takes ~0.5s in debug builds, and clients
296/// connecting during it should queue in the accept backlog rather than get refused.
297async fn bind_api(port: u16) -> anyhow::Result<tokio::net::TcpListener> {
298    let addr = format!("0.0.0.0:{}", port);
299    tracing::info!("Binding to {}", addr);
300    Ok(tokio::net::TcpListener::bind(&addr).await?)
301}
302
303async fn serve_router(
304    listener: tokio::net::TcpListener,
305    mode: &str,
306    router: ::axum::Router,
307    max_connections: Option<usize>,
308) -> anyhow::Result<()> {
309    let mut router = axum::with_top_level_routes(router);
310    if let Some(limit) = max_connections {
311        router = apply_connection_limit(router, limit);
312    }
313    let router = router.layer(http_wire::body_limit_layer());
314    // CORS goes on last so it wraps the connection limit, whose 429 would otherwise skip it.
315    let router = router.layer(http_wire::cors_layer());
316    // `Router::layer` middleware runs after routing, so it can't rewrite a URI to match a
317    // different route. Wrapping the whole router with `MapRequestLayer` instead runs the
318    // rewrite before routing, per the axum-documented pattern for this case.
319    let router = tower::util::MapRequestLayer::new(axum::rewrite_legacy_uri).layer(router);
320
321    tracing::info!(
322        "Axum API server listening on {:?} ({} mode)",
323        listener.local_addr()?,
324        mode
325    );
326    ::axum::serve(listener, ::axum::ServiceExt::into_make_service(router)).await?;
327
328    tracing::info!("Axum server stopped");
329    Ok(())
330}
331
332/// Shared budget: plain requests hold a slot while in flight, streaming sockets for their
333/// lifetime; excess gets 429.
334fn apply_connection_limit(router: ::axum::Router, limit: usize) -> ::axum::Router {
335    let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(limit));
336    router
337        .layer(::axum::middleware::from_fn(axum::limit_requests))
338        .layer(::axum::Extension(axum::RequestLimit(semaphore)))
339}
340
341/// Start Tonic gRPC server
342pub async fn serve_tonic<S>(port: u16, state: S) -> anyhow::Result<()>
343where
344    S: StatusService + TokenService + Clone,
345{
346    use ::tonic::transport::Server;
347
348    let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
349
350    let status_service = StatusServiceServer::new(state.clone());
351    let token_service = TokenServiceServer::new(state);
352
353    // Enable gRPC reflection for tools like grpcurl
354    let reflection_service = tonic_reflection::server::Builder::configure()
355        .register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET)
356        .build_v1()?;
357
358    tracing::info!("gRPC server listening on {}", addr);
359    Server::builder()
360        .add_service(status_service)
361        .add_service(token_service)
362        .add_service(reflection_service)
363        .serve(addr)
364        .await?;
365
366    Ok(())
367}