iota_rest_api/
lib.rs

1// Copyright (c) Mysten Labs, Inc.
2// Modifications Copyright (c) 2024 IOTA Stiftung
3// SPDX-License-Identifier: Apache-2.0
4
5use std::sync::Arc;
6
7use axum::{Router, response::Redirect, routing::get};
8use iota_network_stack::callback::CallbackLayer;
9use iota_types::{storage::RestStateReader, transaction_executor::TransactionExecutor};
10use openapi::ApiEndpoint;
11use reader::StateReader;
12use tap::Pipe;
13
14pub mod accept;
15mod accounts;
16mod checkpoints;
17pub mod client;
18mod coins;
19mod committee;
20pub mod content_type;
21mod epochs;
22mod error;
23mod health;
24mod info;
25mod metrics;
26mod objects;
27pub mod openapi;
28mod reader;
29mod response;
30mod system;
31pub mod transactions;
32pub mod types;
33
34pub use client::Client;
35pub use error::{RestError, Result};
36pub use iota_types::full_checkpoint_content::{CheckpointData, CheckpointTransaction};
37pub use metrics::RestMetrics;
38pub use transactions::ExecuteTransactionQueryParameters;
39
40pub const TEXT_PLAIN_UTF_8: &str = "text/plain; charset=utf-8";
41pub const APPLICATION_BCS: &str = "application/bcs";
42pub const APPLICATION_JSON: &str = "application/json";
43
44#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
45#[serde(rename_all = "lowercase")]
46pub enum Direction {
47    Ascending,
48    Descending,
49}
50
51#[derive(Debug)]
52pub struct Page<T, C> {
53    pub entries: response::ResponseContent<Vec<T>>,
54    pub cursor: Option<C>,
55}
56
57pub const DEFAULT_PAGE_SIZE: usize = 50;
58pub const MAX_PAGE_SIZE: usize = 100;
59
60impl<T: serde::Serialize, C: std::fmt::Display> axum::response::IntoResponse for Page<T, C> {
61    fn into_response(self) -> axum::response::Response {
62        let cursor = self
63            .cursor
64            .map(|cursor| [(crate::types::X_IOTA_CURSOR, cursor.to_string())]);
65
66        (cursor, self.entries).into_response()
67    }
68}
69
70const ENDPOINTS: &[&dyn ApiEndpoint<RestService>] = &[
71    &info::GetNodeInfo,
72    &health::HealthCheck,
73    &accounts::ListAccountObjects,
74    &objects::GetObject,
75    &objects::GetObjectWithVersion,
76    &objects::ListDynamicFields,
77    &checkpoints::ListCheckpoints,
78    &checkpoints::GetCheckpoint,
79    &checkpoints::GetCheckpointFull,
80    &transactions::GetTransaction,
81    &transactions::ListTransactions,
82    &committee::GetCommittee,
83    &committee::GetLatestCommittee,
84    &system::GetSystemStateSummary,
85    &system::GetCurrentProtocolConfig,
86    &system::GetProtocolConfig,
87    &system::GetGasInfo,
88    &transactions::ExecuteTransaction,
89    &coins::GetCoinInfo,
90    &epochs::GetEpochLastCheckpoint,
91];
92
93#[derive(Clone)]
94pub struct RestService {
95    reader: StateReader,
96    executor: Option<Arc<dyn TransactionExecutor>>,
97    chain_id: iota_types::digests::ChainIdentifier,
98    software_version: &'static str,
99    metrics: Option<Arc<RestMetrics>>,
100}
101
102impl axum::extract::FromRef<RestService> for StateReader {
103    fn from_ref(input: &RestService) -> Self {
104        input.reader.clone()
105    }
106}
107
108impl axum::extract::FromRef<RestService> for Option<Arc<dyn TransactionExecutor>> {
109    fn from_ref(input: &RestService) -> Self {
110        input.executor.clone()
111    }
112}
113
114impl RestService {
115    pub fn new(reader: Arc<dyn RestStateReader>, software_version: &'static str) -> Self {
116        let chain_id = reader.get_chain_identifier().unwrap();
117        Self {
118            reader: StateReader::new(reader),
119            executor: None,
120            chain_id,
121            software_version,
122            metrics: None,
123        }
124    }
125
126    pub fn new_without_version(reader: Arc<dyn RestStateReader>) -> Self {
127        Self::new(reader, "unknown")
128    }
129
130    pub fn with_executor(&mut self, executor: Arc<dyn TransactionExecutor + Send + Sync>) {
131        self.executor = Some(executor);
132    }
133
134    pub fn with_metrics(&mut self, metrics: RestMetrics) {
135        self.metrics = Some(Arc::new(metrics));
136    }
137
138    pub fn chain_id(&self) -> iota_types::digests::ChainIdentifier {
139        self.chain_id
140    }
141
142    pub fn software_version(&self) -> &'static str {
143        self.software_version
144    }
145
146    pub fn into_router(self) -> Router {
147        let metrics = self.metrics.clone();
148
149        let mut api = openapi::Api::new(info());
150
151        api.register_endpoints(ENDPOINTS.to_owned());
152
153        Router::new()
154            .nest("/api/v1/", api.to_router().with_state(self.clone()))
155            .route("/api/v1", get(|| async { Redirect::permanent("/api/v1/") }))
156            .layer(axum::middleware::map_response_with_state(
157                self,
158                response::append_info_headers,
159            ))
160            .pipe(|router| {
161                if let Some(metrics) = metrics {
162                    router.layer(CallbackLayer::new(
163                        metrics::RestMetricsMakeCallbackHandler::new(metrics),
164                    ))
165                } else {
166                    router
167                }
168            })
169    }
170
171    pub async fn start_service(self, socket_address: std::net::SocketAddr) {
172        let listener = tokio::net::TcpListener::bind(socket_address).await.unwrap();
173        axum::serve(listener, self.into_router()).await.unwrap();
174    }
175}
176
177fn info() -> openapiv3::v3_1::Info {
178    use openapiv3::v3_1::{Contact, License};
179
180    openapiv3::v3_1::Info {
181        title: "IOTA Node API".to_owned(),
182        description: Some("REST API for interacting with the IOTA Blockchain".to_owned()),
183        contact: Some(Contact {
184            name: Some("IOTA Foundation".to_owned()),
185            url: Some("https://github.com/iotaledger/iota".to_owned()),
186            ..Default::default()
187        }),
188        license: Some(License {
189            name: "Apache 2.0".to_owned(),
190            url: Some("https://www.apache.org/licenses/LICENSE-2.0.html".to_owned()),
191            ..Default::default()
192        }),
193        version: "0.0.0".to_owned(),
194        ..Default::default()
195    }
196}
197
198mod _schemars {
199    use schemars::{
200        JsonSchema,
201        schema::{InstanceType, Metadata, SchemaObject},
202    };
203
204    pub(crate) struct U64;
205
206    impl JsonSchema for U64 {
207        fn schema_name() -> String {
208            "u64".to_owned()
209        }
210
211        fn json_schema(_: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
212            SchemaObject {
213                metadata: Some(Box::new(Metadata {
214                    description: Some("Radix-10 encoded 64-bit unsigned integer".to_owned()),
215                    ..Default::default()
216                })),
217                instance_type: Some(InstanceType::String.into()),
218                format: Some("u64".to_owned()),
219                ..Default::default()
220            }
221            .into()
222        }
223
224        fn is_referenceable() -> bool {
225            false
226        }
227    }
228}
229
230#[cfg(test)]
231mod test {
232    use super::*;
233
234    #[test]
235    fn openapi_spec() {
236        const OPENAPI_SPEC_FILE: &str =
237            concat!(env!("CARGO_MANIFEST_DIR"), "/openapi/openapi.json");
238
239        let openapi = {
240            let mut api = openapi::Api::new(info());
241
242            api.register_endpoints(ENDPOINTS.to_owned());
243            api.openapi()
244        };
245
246        let mut actual = serde_json::to_string_pretty(&openapi).unwrap();
247        actual.push('\n');
248
249        // Update the expected format
250        if std::env::var_os("UPDATE").is_some() {
251            std::fs::write(OPENAPI_SPEC_FILE, &actual).unwrap();
252        }
253
254        let expected = std::fs::read_to_string(OPENAPI_SPEC_FILE).unwrap();
255
256        let diff = diffy::create_patch(&expected, &actual);
257
258        if !diff.hunks().is_empty() {
259            let formatter = if std::io::IsTerminal::is_terminal(&std::io::stderr()) {
260                diffy::PatchFormatter::new().with_color()
261            } else {
262                diffy::PatchFormatter::new()
263            };
264            let header = "Generated and checked-in openapi spec does not match. \
265                          Re-run with `UPDATE=1` to update expected format";
266            panic!("{header}\n\n{}", formatter.fmt_patch(&diff));
267        }
268    }
269
270    #[tokio::test]
271    async fn openapi_explorer() {
272        // Unless env var is set, just early return
273        if std::env::var_os("OPENAPI_EXPLORER").is_none() {
274            return;
275        }
276
277        let openapi = {
278            let mut api = openapi::Api::new(info());
279            api.register_endpoints(ENDPOINTS.to_owned());
280            api.openapi()
281        };
282
283        let router = openapi::OpenApiDocument::new(openapi).into_router();
284
285        let listener = tokio::net::TcpListener::bind("127.0.0.1:8000")
286            .await
287            .unwrap();
288        axum::serve(listener, router).await.unwrap();
289    }
290}