/ ledger / query / src / query / rest.rs
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  }