rest.rs
1 // Copyright (c) 2019-2025 Alpha-Delta Network Inc. 2 // This file is part of the deltavm library. 3 4 // Licensed under the Apache License, Version 2.0 (the "License"); 5 // you may not use this file except in compliance with the License. 6 // You may obtain a copy of the License at: 7 8 // http://www.apache.org/licenses/LICENSE-2.0 9 10 // Unless required by applicable law or agreed to in writing, software 11 // distributed under the License is distributed on an "AS IS" BASIS, 12 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 // See the License for the specific language governing permissions and 14 // limitations under the License. 15 16 use crate::QueryTrait; 17 18 use deltavm_console::{ 19 network::Network, 20 program::{ProgramID, StatePath}, 21 types::Field, 22 }; 23 use deltavm_ledger_block::Transaction; 24 use deltavm_synthesizer_program::Program; 25 26 use anyhow::{Context, Result, anyhow, bail, ensure}; 27 use serde::{Deserialize, de::DeserializeOwned}; 28 use ureq::http::{self, uri}; 29 30 use std::str::FromStr; 31 32 /// Queries that use a node's REST API as their source of information. 33 #[derive(Clone)] 34 pub struct RestQuery<N: Network> { 35 base_url: http::Uri, 36 _marker: std::marker::PhantomData<N>, 37 } 38 39 impl<N: Network> From<http::Uri> for RestQuery<N> { 40 fn from(base_url: http::Uri) -> Self { 41 Self { base_url, _marker: Default::default() } 42 } 43 } 44 45 /// The serialized REST error sent over the network. 46 #[derive(Debug, Deserialize)] 47 pub struct RestError { 48 /// The type of error (corresponding to the HTTP status code). 49 error_type: String, 50 /// The top-level error message. 51 message: String, 52 /// The chain of errors that led to the top-level error. 53 #[serde(skip_serializing_if = "Vec::is_empty")] 54 chain: Vec<String>, 55 } 56 57 impl RestError { 58 /// Converts a `RestError` into an `anyhow::Error`. 59 pub fn parse(self) -> anyhow::Error { 60 let mut error: Option<anyhow::Error> = None; 61 for next in self.chain.into_iter() { 62 if let Some(previous) = error { 63 error = Some(previous.context(next)); 64 } else { 65 error = Some(anyhow!(next)); 66 } 67 } 68 69 let toplevel = format!("{}: {}", self.error_type, self.message); 70 if let Some(error) = error { error.context(toplevel) } else { anyhow!(toplevel) } 71 } 72 } 73 74 /// Initialize the `Query` object from an endpoint URL (passed as a string). The URI should point to a snarkOS node's REST API. 75 impl<N: Network> FromStr for RestQuery<N> { 76 type Err = anyhow::Error; 77 78 fn from_str(str_representation: &str) -> Result<Self> { 79 let base_url = str_representation.parse::<http::Uri>().with_context(|| "Failed to parse URL")?; 80 81 // Perform checks. 82 if let Some(scheme) = base_url.scheme() 83 && *scheme != uri::Scheme::HTTP 84 && *scheme != uri::Scheme::HTTPS 85 { 86 bail!("Invalid scheme in URL: {scheme}"); 87 } 88 89 if let Some(s) = base_url.host() 90 && s.is_empty() 91 { 92 bail!("Invalid URL for REST endpoint. Empty hostname given."); 93 } else if base_url.host().is_none() { 94 bail!("Invalid URL for REST endpoint. No hostname given."); 95 } 96 97 if base_url.query().is_some() { 98 bail!("Base URL for REST endpoints cannot contain a query"); 99 } 100 101 Ok(Self::from(base_url)) 102 } 103 } 104 105 #[cfg_attr(feature = "async", async_trait::async_trait(?Send))] 106 impl<N: Network> QueryTrait<N> for RestQuery<N> { 107 /// Returns the current state root. 108 fn current_state_root(&self) -> Result<N::StateRoot> { 109 self.get_request("stateRoot/latest") 110 } 111 112 /// Returns the current state root. 113 #[cfg(feature = "async")] 114 async fn current_state_root_async(&self) -> Result<N::StateRoot> { 115 self.get_request_async("stateRoot/latest").await 116 } 117 118 /// Returns a state path for the given `commitment`. 119 fn get_state_path_for_commitment(&self, commitment: &Field<N>) -> Result<StatePath<N>> { 120 self.get_request(&format!("statePath/{commitment}")) 121 } 122 123 /// Returns a state path for the given `commitment`. 124 #[cfg(feature = "async")] 125 async fn get_state_path_for_commitment_async(&self, commitment: &Field<N>) -> Result<StatePath<N>> { 126 self.get_request_async(&format!("statePath/{commitment}")).await 127 } 128 129 /// Returns a list of state paths for the given list of `commitment`s. 130 fn get_state_paths_for_commitments(&self, commitments: &[Field<N>]) -> Result<Vec<StatePath<N>>> { 131 // Construct the comma separated string of commitments. 132 let commitments_string = commitments.iter().map(|cm| cm.to_string()).collect::<Vec<_>>().join(","); 133 self.get_request(&format!("statePaths?commitments={commitments_string}")) 134 } 135 136 /// Returns a list of state paths for the given list of `commitment`s. 137 #[cfg(feature = "async")] 138 async fn get_state_paths_for_commitments_async(&self, commitments: &[Field<N>]) -> Result<Vec<StatePath<N>>> { 139 // Construct the comma separated string of commitments. 140 let commitments_string = commitments.iter().map(|cm| cm.to_string()).collect::<Vec<_>>().join(","); 141 self.get_request_async(&format!("statePaths?commitments={commitments_string}")).await 142 } 143 144 /// Returns a state path for the given `commitment`. 145 fn current_block_height(&self) -> Result<u32> { 146 self.get_request("block/height/latest") 147 } 148 149 /// Returns a state path for the given `commitment`. 150 #[cfg(feature = "async")] 151 async fn current_block_height_async(&self) -> Result<u32> { 152 self.get_request_async("block/height/latest").await 153 } 154 } 155 156 impl<N: Network> RestQuery<N> { 157 /// Returns the transaction for the given transaction ID. 158 pub fn get_transaction(&self, transaction_id: &N::TransactionID) -> Result<Transaction<N>> { 159 self.get_request(&format!("transaction/{transaction_id}")) 160 } 161 162 /// Returns the transaction for the given transaction ID. 163 #[cfg(feature = "async")] 164 pub async fn get_transaction_async(&self, transaction_id: &N::TransactionID) -> Result<Transaction<N>> { 165 self.get_request_async(&format!("transaction/{transaction_id}")).await 166 } 167 168 /// Returns the program for the given program ID. 169 pub fn get_program(&self, program_id: &ProgramID<N>) -> Result<Program<N>> { 170 self.get_request(&format!("program/{program_id}")) 171 } 172 173 /// Returns the program for the given program ID. 174 #[cfg(feature = "async")] 175 pub async fn get_program_async(&self, program_id: &ProgramID<N>) -> Result<Program<N>> { 176 self.get_request_async(&format!("program/{program_id}")).await 177 } 178 179 /// Builds the full endpoint Uri from the base and path. Used internally 180 /// for all REST API calls. 181 /// 182 /// # Arguments 183 /// - `route`: the route to the endpoint (e.g., `stateRoot/latest`). This cannot start with a slash. 184 fn build_endpoint(&self, route: &str) -> Result<String> { 185 // This function is only called internally but check for additional sanity. 186 ensure!(!route.starts_with('/'), "path cannot start with a slash"); 187 188 // Work around a bug in the `http` crate where empty paths will be set to '/' but other paths are not appended with a slash. 189 // See [this issue](https://github.com/hyperium/http/issues/507). 190 let path = if self.base_url.path().ends_with('/') { 191 format!("{base_url}{network}/{route}", base_url = self.base_url, network = N::SHORT_NAME) 192 } else { 193 format!("{base_url}/{network}/{route}", base_url = self.base_url, network = N::SHORT_NAME) 194 }; 195 196 Ok(path) 197 } 198 199 /// Performs a GET request to the given URL and deserializes the returned JSON. 200 /// 201 /// # Arguments 202 /// - `route`: the specific API route to use, e.g., `stateRoot/latest` 203 fn get_request<T: DeserializeOwned>(&self, route: &str) -> Result<T> { 204 let endpoint = self.build_endpoint(route)?; 205 let mut response = ureq::get(&endpoint) 206 .config() 207 .http_status_as_error(false) 208 .build() 209 .call() 210 // This handles I/O errors. 211 .with_context(|| format!("Failed to fetch from {endpoint}"))?; 212 213 if response.status().is_success() { 214 response.body_mut().read_json().with_context(|| format!("Failed to parse JSON response from {endpoint}")) 215 } else { 216 // v2 will return the error in JSON format. 217 let is_json = response 218 .headers() 219 .get(http::header::CONTENT_TYPE) 220 .and_then(|ct| ct.to_str().ok()) 221 .map(|ct| ct.contains("json")) 222 .unwrap_or(false); 223 224 // Convert returned error into an `anyhow::Error`. 225 // Depending on the API version, the error is either encoded as a string or as a JSON. 226 if is_json { 227 let error: RestError = response 228 .body_mut() 229 .read_json() 230 .with_context(|| format!("Failed to parse JSON error response from {endpoint}"))?; 231 Err(error.parse().context(format!("Failed to fetch from {endpoint}"))) 232 } else { 233 let error = response 234 .body_mut() 235 .read_to_string() 236 .with_context(|| format!("Failed to read error message {endpoint}"))?; 237 Err(anyhow!(error).context(format!("Failed to fetch from {endpoint}"))) 238 } 239 } 240 } 241 242 /// Async version of [`Self::get_request`]. Performs a GET request to the given URL and deserializes the returned JSON. 243 /// 244 /// # Arguments 245 /// - `route`: the specific API route to use, e.g., `stateRoot/latest` 246 #[cfg(feature = "async")] 247 async fn get_request_async<T: DeserializeOwned>(&self, route: &str) -> Result<T> { 248 let endpoint = self.build_endpoint(route)?; 249 let response = reqwest::get(&endpoint).await.with_context(|| format!("Failed to fetch from {endpoint}"))?; 250 251 if response.status().is_success() { 252 response.json().await.with_context(|| format!("Failed to parse JSON response from {endpoint}")) 253 } else { 254 // v2 will return the error in JSON format. 255 let is_json = response 256 .headers() 257 .get(http::header::CONTENT_TYPE) 258 .and_then(|ct| ct.to_str().ok()) 259 .map(|ct| ct.contains("json")) 260 .unwrap_or(false); 261 262 if is_json { 263 // Convert returned error into an `anyhow::Error`. 264 let error: RestError = response 265 .json() 266 .await 267 .with_context(|| format!("Failed to parse JSON error response from {endpoint}"))?; 268 Err(error.parse().context(format!("Failed to fetch from {endpoint}"))) 269 } else { 270 let error = 271 response.text().await.with_context(|| format!("Failed to read error message {endpoint}"))?; 272 Err(anyhow!(error).context(format!("Failed to fetch from {endpoint}"))) 273 } 274 } 275 } 276 } 277 278 #[cfg(test)] 279 mod tests { 280 use crate::Query; 281 282 use deltavm_console::network::TestnetV0; 283 use deltavm_ledger_store::helpers::memory::BlockMemory; 284 285 use anyhow::Result; 286 287 type CurrentNetwork = TestnetV0; 288 type CurrentQuery = Query<CurrentNetwork, BlockMemory<CurrentNetwork>>; 289 290 /// Tests HTTP's behavior of printing an empty path `/` 291 /// 292 /// `generate_endpoint` can handle base_urls with and without a trailing slash. 293 /// However, this test is still useful to see if the behavior changes in the future and a second slash is not 294 /// appended to a URL with an existing trailing slash. 295 #[test] 296 fn test_rest_url_parse() -> Result<()> { 297 let noslash = "http://localhost:3030"; 298 let withslash = format!("{noslash}/"); 299 let route = "some/route"; 300 301 let query = noslash.parse::<CurrentQuery>().unwrap(); 302 let Query::REST(rest) = query else { panic!() }; 303 assert_eq!(rest.base_url.path_and_query().unwrap().to_string(), "/"); 304 assert_eq!(rest.base_url.to_string(), withslash); 305 assert_eq!(rest.build_endpoint(route)?, format!("{noslash}/testnet/{route}")); 306 307 let query = withslash.parse::<CurrentQuery>().unwrap(); 308 let Query::REST(rest) = query else { panic!() }; 309 assert_eq!(rest.base_url.path_and_query().unwrap().to_string(), "/"); 310 assert_eq!(rest.base_url.to_string(), withslash); 311 assert_eq!(rest.build_endpoint(route)?, format!("{noslash}/testnet/{route}")); 312 313 Ok(()) 314 } 315 316 #[test] 317 fn test_rest_url_with_colon_parse() { 318 let str = "http://myendpoint.addr/:var/foo/bar"; 319 let query = str.parse::<CurrentQuery>().unwrap(); 320 321 let Query::REST(rest) = query else { panic!() }; 322 assert_eq!(rest.base_url.to_string(), format!("{str}")); 323 assert_eq!(rest.base_url.path_and_query().unwrap().to_string(), "/:var/foo/bar"); 324 } 325 326 #[test] 327 fn test_rest_url_parse_with_suffix() -> Result<()> { 328 let base = "http://localhost:3030/a/prefix/v2"; 329 let route = "a/route"; 330 331 // Test without trailing slash. 332 let query = base.parse::<CurrentQuery>().unwrap(); 333 let Query::REST(rest) = query else { panic!() }; 334 assert_eq!(rest.build_endpoint(route)?, format!("{base}/testnet/{route}")); 335 336 // Set again with trailing slash. 337 let query = format!("{base}/").parse::<CurrentQuery>().unwrap(); 338 let Query::REST(rest) = query else { panic!() }; 339 assert_eq!(rest.build_endpoint(route)?, format!("{base}/testnet/{route}")); 340 341 Ok(()) 342 } 343 }