Skip to main content

iota_rest_kv/
server.rs

1// Copyright (c) 2025 IOTA Stiftung
2// SPDX-License-Identifier: Apache-2.0
3
4//! This module includes helper wrappers for building and starting a REST API
5//! server.
6use std::{net::SocketAddr, sync::Arc, time::Duration};
7
8use anyhow::Result;
9use axum::{
10    Router,
11    extract::{MatchedPath, Request},
12    http::{StatusCode, header::HeaderName},
13    response::{IntoResponse, Response},
14    routing::{get, post},
15};
16use iota_storage::http_key_value_store::ItemType;
17use tokio_util::sync::CancellationToken;
18use tower::ServiceBuilder;
19use tower_http::{
20    classify::ServerErrorsFailureClass,
21    request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer},
22    trace::TraceLayer,
23};
24use tracing::{Level, Span, field};
25
26const REQUEST_ID_HEADER: HeaderName = HeaderName::from_static("x-request-id");
27
28use crate::{
29    RestApiConfig,
30    bigtable::KvStoreClient,
31    errors::ApiError,
32    routes::{health, kv_store},
33    types::{RestServerAppState, SharedRestServerAppState},
34};
35
36/// A wrapper which builds the components needed for the REST API server and
37/// provides a simple way to start it.
38pub struct Server {
39    router: Router,
40    server_address: SocketAddr,
41    token: CancellationToken,
42}
43
44impl Server {
45    /// Create a new Server instance.
46    ///
47    /// Based on the config, it instantiates the [`KvStoreClient`] and
48    /// constructs the [`Router`].
49    pub async fn new(config: RestApiConfig, token: CancellationToken) -> Result<Self> {
50        let kv_store_client = KvStoreClient::new(config.kv_store_config).await?;
51
52        let shared_state = Arc::new(RestServerAppState {
53            kv_store_client: Arc::new(kv_store_client),
54            multiget_max_items: config.multiget_max_items,
55        });
56
57        Ok(Self {
58            router: build_router(shared_state),
59            token,
60            server_address: config.server_address,
61        })
62    }
63
64    /// Start the server, this method is blocking.
65    pub async fn serve(self) -> Result<()> {
66        let listener = tokio::net::TcpListener::bind(self.server_address)
67            .await
68            .expect("failed to bind to socket");
69
70        tracing::info!("listening on: {}", self.server_address);
71
72        axum::serve(listener, self.router)
73            .with_graceful_shutdown(async move {
74                self.token.cancelled().await;
75                tracing::info!("shutdown signal received.");
76            })
77            .await
78            .inspect_err(|e| tracing::error!("server encountered an error: {e}"))
79            .map_err(Into::into)
80    }
81}
82
83/// Builds the [`Router`] with all routes exposed by the REST API server.
84fn build_router(state: SharedRestServerAppState) -> Router {
85    Router::new()
86        .route("/health", get(health::health))
87        .route("/{item_type}", post(kv_store::multi_get_data))
88        .route("/{item_type}/{key}", get(kv_store::data_as_bytes))
89        // static and dynamic route segments are allowed to overlap. If they do, static segments
90        // will be given higher priority.
91        .route(
92            &format!("/{}/{{address}}", ItemType::TransactionDigestsByAddress),
93            get(kv_store::transaction_digests_by_address),
94        )
95        // register the fallback before the layers so that requests to
96        // unmatched routes are traced as well
97        .fallback(fallback)
98        .layer(
99            ServiceBuilder::new()
100                .layer(SetRequestIdLayer::new(REQUEST_ID_HEADER, MakeRequestUuid))
101                .layer(
102                    TraceLayer::new_for_http()
103                        .make_span_with(make_request_span)
104                        .on_response(log_response)
105                        .on_failure(
106                            |class: ServerErrorsFailureClass, latency: Duration, _: &Span| {
107                                tracing::error!(
108                                    %class,
109                                    latency_ms = latency.as_millis() as u64,
110                                    "request failed"
111                                );
112                            },
113                        ),
114                )
115                .layer(PropagateRequestIdLayer::new(REQUEST_ID_HEADER)),
116        )
117        .with_state(state)
118}
119
120/// Handles requests to routes that are not defined in the API.
121///
122/// This fallback handler is called when the requested URL path does not match
123/// any of the defined routes. It returns a `404 Not Found` error, indicating
124/// that the requested resource could not be found. This can happen if the user
125/// enters an incorrect URL or if the requested resource (identified by a
126/// [`Key`](iota_storage::http_key_value_store::Key)) cannot be extracted from
127/// the request.
128async fn fallback() -> impl IntoResponse {
129    ApiError::NotFound
130}
131
132/// Creates a tracing span that wraps a single request.
133///
134/// - If `DEBUG` logging is enabled, a detailed span is created containing:
135///   - `request_id`: Extracted from the request header (or empty if missing).
136///   - `method`: The HTTP method of the request.
137///   - `route`: The matched route template (falling back to the raw URI path).
138///   - `uri`: The concrete request path data.
139///   - `error`: Starts empty and is recorded by [`ApiError::into_response`]
140///     when the request fails.
141/// - Otherwise, a lighter span is created containing only `uri` and `error`.
142fn make_request_span(request: &Request) -> Span {
143    if tracing::span_enabled!(Level::DEBUG) {
144        let request_id = request
145            .headers()
146            .get(REQUEST_ID_HEADER)
147            .and_then(|value| value.to_str().ok())
148            .unwrap_or_default();
149        let route = request
150            .extensions()
151            .get::<MatchedPath>()
152            .map_or_else(|| request.uri().path(), MatchedPath::as_str);
153
154        tracing::debug_span!(
155            "request",
156            request_id,
157            method = %request.method(),
158            route,
159            uri = %request.uri(),
160            error = field::Empty,
161        )
162    } else {
163        tracing::info_span!(
164            "request",
165            uri = %request.uri(),
166            error = field::Empty,
167        )
168    }
169}
170
171/// Logs the completion of a request, choosing the level by status code range.
172///
173/// # Note
174/// Server errors are skipped here, they are logged by
175/// [`TraceLayer::on_failure`] together with the failure class.
176fn log_response(response: &Response, latency: Duration, _: &Span) {
177    let status = response.status();
178    let latency_ms = latency.as_millis() as u64;
179
180    if status.is_server_error() {
181        return;
182    }
183
184    if status.is_client_error() && status != StatusCode::NOT_FOUND {
185        tracing::warn!(%status, latency_ms, "request failed with client error");
186    } else {
187        tracing::info!(%status, latency_ms, "request completed");
188    }
189}
190
191/// Tests for the request-validation logic of the REST API handlers.
192///
193/// These tests point the test client to an address without an active BigTable
194/// service. This is safe because the BigTable channel connects lazily: most
195/// requests in following tests cases are validated and rejected or answered
196/// before any database call is initiated.
197#[cfg(test)]
198mod tests {
199    use std::num::NonZeroUsize;
200
201    use axum::{
202        body::Body,
203        http::{Method, Request, StatusCode, header},
204    };
205    use http_body_util::BodyExt;
206    use iota_sdk_types::{Address, TransactionDigest};
207    use iota_storage::http_key_value_store::{
208        TaggedKey, encode_digest, encode_object_key, encoded_tagged_key,
209    };
210    use iota_types::storage::ObjectKey;
211    use tower::ServiceExt;
212
213    use super::*;
214    use crate::{errors::ErrorResponse, routes::health::HealthResponse};
215
216    const MULTIGET_MAX_ITEMS: usize = 5;
217
218    /// Builds the server router for testing.
219    fn test_router() -> Router {
220        build_router(Arc::new(RestServerAppState {
221            kv_store_client: Arc::new(KvStoreClient::new_for_tests()),
222            multiget_max_items: NonZeroUsize::new(MULTIGET_MAX_ITEMS).unwrap(),
223        }))
224    }
225
226    /// Sends a request to the router and returns the status code and body.
227    async fn send(router: Router, request: Request<Body>) -> (StatusCode, String) {
228        let response = router.oneshot(request).await.unwrap();
229        let status = response.status();
230        let body = response.into_body().collect().await.unwrap().to_bytes();
231        (status, String::from_utf8_lossy(&body).into_owned())
232    }
233
234    /// Sends a GET request to the router and returns the status code and body.
235    async fn get(uri: &str) -> (StatusCode, String) {
236        send(
237            test_router(),
238            Request::builder().uri(uri).body(Body::empty()).unwrap(),
239        )
240        .await
241    }
242
243    /// Sends a POST request with JSON body to the router and returns the status
244    /// code and body.
245    async fn post_json(uri: &str, body: serde_json::Value) -> (StatusCode, String) {
246        send(
247            test_router(),
248            Request::builder()
249                .method(Method::POST)
250                .uri(uri)
251                .header(header::CONTENT_TYPE, "application/json")
252                .body(Body::from(body.to_string()))
253                .unwrap(),
254        )
255        .await
256    }
257
258    // Returns test cases for each item type, including:
259    /// - The item type itself.
260    /// - A well-formed encoded key for its routes.
261    /// - The expected error message if the key's decoded bytes cannot be parsed
262    ///   as that item type's key.
263    fn item_type_cases() -> [(ItemType, String, &'static str); 7] {
264        let digest_key = encode_digest(&TransactionDigest::random());
265        let tagged_key = encoded_tagged_key(&TaggedKey::CheckpointSequenceNumber(1));
266        let object_key = encode_object_key(&ObjectKey::ZERO);
267
268        [
269            (
270                ItemType::Transaction,
271                digest_key.clone(),
272                "invalid digest byte length",
273            ),
274            (
275                ItemType::TransactionEffects,
276                digest_key.clone(),
277                "invalid digest byte length",
278            ),
279            (
280                ItemType::TransactionToCheckpoint,
281                digest_key.clone(),
282                "invalid digest byte length",
283            ),
284            (
285                ItemType::EventTransactionDigest,
286                digest_key.clone(),
287                "invalid digest byte length",
288            ),
289            (
290                ItemType::Object,
291                object_key,
292                "failed to deserialize object key",
293            ),
294            (
295                ItemType::CheckpointContents,
296                tagged_key,
297                "failed to deserialize checkpoint sequence number",
298            ),
299            (
300                ItemType::CheckpointSummary,
301                digest_key,
302                "failed to deserialize checkpoint sequence number",
303            ),
304        ]
305    }
306
307    /// Asserts the rejection cases shared by every `/{item_type}/{key}`
308    /// route.
309    ///
310    /// - Keys that are not valid base64url.
311    /// - Keys whose decoded bytes cannot be parsed as the key the item type
312    ///   expects.
313    /// - `before_version` misuse.
314    async fn assert_item_type_route_rejections(
315        item_type: ItemType,
316        valid_key: &str,
317        key_decode_error: &str,
318    ) {
319        // invalid base64url.
320        let (status, body) = get(&format!("/{item_type}/!!")).await;
321        assert_eq!(
322            status,
323            StatusCode::BAD_REQUEST,
324            "{item_type}: malformed base64 key"
325        );
326        assert!(
327            body.contains("invalid base64 url string"),
328            "{item_type}: unexpected body: {body}"
329        );
330
331        // valid base64url, but "AAAA" decodes to 3 bytes, which cannot be
332        // parsed as the key the item type expects (a 32-byte digest, a BCS
333        // `ObjectKey`, or a BCS `TaggedKey`).
334        let (status, body) = get(&format!("/{item_type}/AAAA")).await;
335        assert_eq!(
336            status,
337            StatusCode::BAD_REQUEST,
338            "{item_type}: key decode failure"
339        );
340        assert!(
341            body.contains(key_decode_error),
342            "{item_type}: unexpected body: {body}"
343        );
344
345        // `before_version` is only supported by the object item type.
346        if item_type != ItemType::Object {
347            let (status, body) =
348                get(&format!("/{item_type}/{valid_key}?before_version=true")).await;
349            assert_eq!(
350                status,
351                StatusCode::BAD_REQUEST,
352                "{item_type}: before_version"
353            );
354            assert!(
355                body.contains("`before_version` query parameter is only valid for `ob` item types"),
356                "{item_type}: unexpected body: {body}"
357            );
358        }
359
360        // non-boolean query parameter value.
361        let (status, _) = get(&format!("/{item_type}/{valid_key}?before_version=notabool")).await;
362        assert_eq!(
363            status,
364            StatusCode::BAD_REQUEST,
365            "{item_type}: invalid query parameter value"
366        );
367    }
368
369    /// Asserts the rejection cases shared by every `POST /{item_type}`
370    /// multiget route.
371    ///
372    /// - An empty key list.
373    /// - More keys than the configured maximum.
374    /// - Keys that are not valid base64url.
375    /// - Keys whose decoded bytes cannot be parsed as the key the item type
376    ///   expects.
377    /// - `before_version` misuse.
378    async fn assert_multiget_route_rejections(
379        item_type: ItemType,
380        valid_key: &str,
381        key_decode_error: &str,
382    ) {
383        // empty key list.
384        let (status, body) =
385            post_json(&format!("/{item_type}"), serde_json::json!({ "keys": [] })).await;
386        assert_eq!(status, StatusCode::BAD_REQUEST, "{item_type}: empty keys");
387        assert!(
388            body.contains("no keys provided"),
389            "{item_type}: unexpected body: {body}"
390        );
391
392        // more keys than the configured maximum.
393        let keys = vec![valid_key; MULTIGET_MAX_ITEMS + 1];
394        let (status, body) = post_json(
395            &format!("/{item_type}"),
396            serde_json::json!({ "keys": keys }),
397        )
398        .await;
399        assert_eq!(
400            status,
401            StatusCode::BAD_REQUEST,
402            "{item_type}: too many keys"
403        );
404        assert!(
405            body.contains("too many keys"),
406            "{item_type}: unexpected body: {body}"
407        );
408
409        // a key that is not valid base64url among well-formed ones.
410        let (status, body) = post_json(
411            &format!("/{item_type}"),
412            serde_json::json!({ "keys": [valid_key, "!!"] }),
413        )
414        .await;
415        assert_eq!(
416            status,
417            StatusCode::BAD_REQUEST,
418            "{item_type}: malformed base64 key"
419        );
420        assert!(
421            body.contains("invalid key '!!'"),
422            "{item_type}: unexpected body: {body}"
423        );
424
425        // valid base64url, but "AAAA" decodes to 3 bytes, which cannot be
426        // parsed as the key the item type expects.
427        let (status, body) = post_json(
428            &format!("/{item_type}"),
429            serde_json::json!({ "keys": ["AAAA"] }),
430        )
431        .await;
432        assert_eq!(
433            status,
434            StatusCode::BAD_REQUEST,
435            "{item_type}: key decode failure"
436        );
437        assert!(
438            body.contains("invalid key 'AAAA'") && body.contains(key_decode_error),
439            "{item_type}: unexpected body: {body}"
440        );
441
442        // `before_version` is only supported by the object item type.
443        if item_type != ItemType::Object {
444            let (status, body) = post_json(
445                &format!("/{item_type}?before_version=true"),
446                serde_json::json!({ "keys": [valid_key] }),
447            )
448            .await;
449            assert_eq!(
450                status,
451                StatusCode::BAD_REQUEST,
452                "{item_type}: before_version"
453            );
454            assert!(
455                body.contains("`before_version` query parameter is only valid for `ob` item types"),
456                "{item_type}: unexpected body: {body}"
457            );
458        }
459    }
460
461    #[tokio::test]
462    async fn health_endpoint_reports_ok() {
463        let (status, body) = get("/health").await;
464        assert_eq!(status, StatusCode::OK);
465        let health: HealthResponse = serde_json::from_str(&body).unwrap();
466        assert_eq!(health.status, "OK");
467    }
468
469    #[tokio::test]
470    async fn unknown_route_returns_not_found() {
471        let (status, _) = get("/unknown/route/segments").await;
472        assert_eq!(status, StatusCode::NOT_FOUND);
473    }
474
475    #[tokio::test]
476    async fn unknown_item_type_is_rejected() {
477        let (status, body) = get(&format!(
478            "/zz/{}",
479            encode_digest(&TransactionDigest::random())
480        ))
481        .await;
482        assert_eq!(status, StatusCode::BAD_REQUEST);
483        assert!(
484            body.contains("invalid path parameter"),
485            "unexpected body: {body}"
486        );
487    }
488
489    #[tokio::test]
490    async fn item_type_routes_reject_invalid_requests() {
491        for (item_type, valid_key, key_decode_error) in item_type_cases() {
492            assert_item_type_route_rejections(item_type, &valid_key, key_decode_error).await;
493        }
494    }
495
496    #[tokio::test]
497    async fn before_version_for_min_version_returns_not_found() {
498        // The scan range below the minimum version is empty, so the handler
499        // answers 404 without querying the store.
500        let uri = format!(
501            "/ob/{}?before_version=true",
502            encode_object_key(&ObjectKey::ZERO)
503        );
504        let (status, body) = get(&uri).await;
505
506        assert_eq!(status, StatusCode::NOT_FOUND);
507        assert!(body.is_empty(), "expected empty body, got: {body}");
508    }
509
510    #[tokio::test]
511    async fn multiget_routes_reject_invalid_requests() {
512        for (item_type, valid_key, key_decode_error) in item_type_cases() {
513            assert_multiget_route_rejections(item_type, &valid_key, key_decode_error).await;
514        }
515    }
516
517    #[tokio::test]
518    async fn transactions_by_address_rejects_invalid_requests() {
519        let address_key = encode_digest(&Address::random());
520
521        // address is not valid base64url.
522        let (status, body) = get("/txa/!!").await;
523        assert_eq!(status, StatusCode::BAD_REQUEST);
524        assert!(
525            body.contains("address is not valid base64-url"),
526            "unexpected body: {body}"
527        );
528
529        // "AAAA" decodes to 3 bytes, not a 32-byte address.
530        let (status, body) = get("/txa/AAAA").await;
531        assert_eq!(status, StatusCode::BAD_REQUEST);
532        assert!(body.contains("invalid address"), "unexpected body: {body}");
533
534        // limit above the configured maximum.
535        let uri = format!("/txa/{address_key}?limit={}", MULTIGET_MAX_ITEMS + 1);
536        let (status, body) = get(&uri).await;
537        assert_eq!(status, StatusCode::BAD_REQUEST);
538        assert!(body.contains("limit too large"), "unexpected body: {body}");
539
540        // query parameter values that fail to deserialize.
541        for query in ["limit=0", "cursor=notanumber", "oldest_first=notabool"] {
542            let uri = format!("/txa/{address_key}?{query}");
543            let (status, _) = get(&uri).await;
544            assert_eq!(status, StatusCode::BAD_REQUEST, "GET {uri}");
545        }
546    }
547
548    #[tokio::test]
549    async fn multiget_rejects_transaction_digests_by_address_item_type() {
550        let key = encode_digest(&Address::random());
551        let (status, body) = post_json("/txa", serde_json::json!({ "keys": [key] })).await;
552
553        assert_eq!(status, StatusCode::BAD_REQUEST);
554        let res = serde_json::from_str::<ErrorResponse>(&body).unwrap();
555        assert_eq!(res.error_code, "400");
556        assert!(
557            res.error_message.contains("unsupported key"),
558            "unexpected body: {body}"
559        );
560    }
561
562    #[tokio::test]
563    async fn store_error_maps_to_internal_server_error() {
564        // A well-formed request that reaches the store fails against the test
565        // client's unreachable BigTableDB endpoint.
566        let uri = format!("/tx/{}", encode_digest(&TransactionDigest::random()));
567        let (status, body) = get(&uri).await;
568
569        assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
570        let res = serde_json::from_str::<ErrorResponse>(&body).unwrap();
571        assert_eq!(res.error_code, "500");
572        assert_eq!(res.error_message, "internal server error");
573    }
574
575    #[tokio::test]
576    async fn wrong_method_returns_method_not_allowed() {
577        // `/{item_type}` only accepts POST.
578        let (status, _) = get("/tx").await;
579        assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED);
580    }
581}