snarkvm_ledger_query/query/
rest.rs

1// Copyright (c) 2019-2025 Provable Inc.
2// This file is part of the snarkVM 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
16use crate::QueryTrait;
17
18use snarkvm_console::{
19    network::Network,
20    program::{ProgramID, StatePath},
21    types::Field,
22};
23use snarkvm_ledger_block::Transaction;
24use snarkvm_synthesizer_program::Program;
25
26use anyhow::{Context, Result, anyhow, bail, ensure};
27use serde::{Deserialize, de::DeserializeOwned};
28use ureq::http::{self, uri};
29
30use std::str::FromStr;
31
32/// Queries that use a node's REST API as their source of information.
33#[derive(Clone)]
34pub struct RestQuery<N: Network> {
35    base_url: http::Uri,
36    _marker: std::marker::PhantomData<N>,
37}
38
39impl<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)]
47pub 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
57impl 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.
75impl<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        if let Some(scheme) = base_url.scheme()
82            && *scheme != uri::Scheme::HTTP
83            && *scheme != uri::Scheme::HTTPS
84        {
85            bail!("Invalid scheme in URL: {scheme}");
86        }
87
88        if let Some(s) = base_url.host()
89            && s.is_empty()
90        {
91            bail!("Invalid URL for REST endpoint. Empty hostname given.");
92        } else if base_url.host().is_none() {
93            bail!("Invalid URL for REST endpoint. No hostname given.");
94        }
95
96        if base_url.query().is_some() {
97            bail!("Base URL for REST endpoints cannot contain a query");
98        }
99
100        Ok(Self { base_url, _marker: Default::default() })
101    }
102}
103
104#[cfg_attr(feature = "async", async_trait::async_trait(?Send))]
105impl<N: Network> QueryTrait<N> for RestQuery<N> {
106    /// Returns the current state root.
107    fn current_state_root(&self) -> Result<N::StateRoot> {
108        self.get_request("stateRoot/latest")
109    }
110
111    /// Returns the current state root.
112    #[cfg(feature = "async")]
113    async fn current_state_root_async(&self) -> Result<N::StateRoot> {
114        self.get_request_async("stateRoot/latest").await
115    }
116
117    /// Returns a state path for the given `commitment`.
118    fn get_state_path_for_commitment(&self, commitment: &Field<N>) -> Result<StatePath<N>> {
119        self.get_request(&format!("statePath/{commitment}"))
120    }
121
122    /// Returns a state path for the given `commitment`.
123    #[cfg(feature = "async")]
124    async fn get_state_path_for_commitment_async(&self, commitment: &Field<N>) -> Result<StatePath<N>> {
125        self.get_request_async(&format!("statePath/{commitment}")).await
126    }
127
128    /// Returns a list of state paths for the given list of `commitment`s.
129    fn get_state_paths_for_commitments(&self, commitments: &[Field<N>]) -> Result<Vec<StatePath<N>>> {
130        // Construct the comma separated string of commitments.
131        let commitments_string = commitments.iter().map(|cm| cm.to_string()).collect::<Vec<_>>().join(",");
132        self.get_request(&format!("statePaths?commitments={commitments_string}"))
133    }
134
135    /// Returns a list of state paths for the given list of `commitment`s.
136    #[cfg(feature = "async")]
137    async fn get_state_paths_for_commitments_async(&self, commitments: &[Field<N>]) -> Result<Vec<StatePath<N>>> {
138        // Construct the comma separated string of commitments.
139        let commitments_string = commitments.iter().map(|cm| cm.to_string()).collect::<Vec<_>>().join(",");
140        self.get_request_async(&format!("statePaths?commitments={commitments_string}")).await
141    }
142
143    /// Returns a state path for the given `commitment`.
144    fn current_block_height(&self) -> Result<u32> {
145        self.get_request("block/height/latest")
146    }
147
148    /// Returns a state path for the given `commitment`.
149    #[cfg(feature = "async")]
150    async fn current_block_height_async(&self) -> Result<u32> {
151        self.get_request_async("block/height/latest").await
152    }
153}
154
155impl<N: Network> RestQuery<N> {
156    /// The API version to use when querying REST endpoints.
157    const API_VERSION: &str = "v2";
158
159    /// Returns the transaction for the given transaction ID.
160    pub fn get_transaction(&self, transaction_id: &N::TransactionID) -> Result<Transaction<N>> {
161        self.get_request(&format!("transaction/{transaction_id}"))
162    }
163
164    /// Returns the transaction for the given transaction ID.
165    #[cfg(feature = "async")]
166    pub async fn get_transaction_async(&self, transaction_id: &N::TransactionID) -> Result<Transaction<N>> {
167        self.get_request_async(&format!("transaction/{transaction_id}")).await
168    }
169
170    /// Returns the program for the given program ID.
171    pub fn get_program(&self, program_id: &ProgramID<N>) -> Result<Program<N>> {
172        self.get_request(&format!("program/{program_id}"))
173    }
174
175    /// Returns the program for the given program ID.
176    #[cfg(feature = "async")]
177    pub async fn get_program_async(&self, program_id: &ProgramID<N>) -> Result<Program<N>> {
178        self.get_request_async(&format!("program/{program_id}")).await
179    }
180
181    /// Builds the full endpoint Uri from the base and path. Used internally
182    /// for all REST API calls.
183    ///
184    /// # Arguments
185    ///  - `route`: the route to the endpoint (e.g., `stateRoot/latest`). This cannot start with a slash.
186    fn build_endpoint(&self, route: &str) -> Result<String> {
187        // This function is only called internally but check for additional sanity.
188        ensure!(!route.starts_with('/'), "path cannot start with a slash");
189
190        // Work around a bug in the `http` crate where empty paths will be set to '/' but other paths are not appended with a slash.
191        // See [this issue](https://github.com/hyperium/http/issues/507).
192        let path = if self.base_url.path().ends_with('/') {
193            format!(
194                "{base_url}{api_version}/{network}/{route}",
195                base_url = self.base_url,
196                api_version = Self::API_VERSION,
197                network = N::SHORT_NAME
198            )
199        } else {
200            format!(
201                "{base_url}/{api_version}/{network}/{route}",
202                base_url = self.base_url,
203                api_version = Self::API_VERSION,
204                network = N::SHORT_NAME
205            )
206        };
207
208        Ok(path)
209    }
210
211    /// Performs a GET request to the given URL and deserializes the returned JSON.
212    ///
213    /// # Arguments
214    ///  - `route`: the specific API route to use, e.g., `stateRoot/latest`
215    fn get_request<T: DeserializeOwned>(&self, route: &str) -> Result<T> {
216        let endpoint = self.build_endpoint(route)?;
217        let mut response = ureq::get(&endpoint)
218            .config()
219            .http_status_as_error(false)
220            .build()
221            .call()
222            // This handles I/O errors.
223            .with_context(|| format!("Failed to fetch from {endpoint}"))?;
224
225        if response.status().is_success() {
226            response.body_mut().read_json().with_context(|| "Failed to parse JSON response")
227        } else {
228            // Convert returned error into an `anyhow::Error`.
229            let error: RestError =
230                response.body_mut().read_json().with_context(|| "Failed to parse JSON error response")?;
231            Err(error.parse().context(format!("Failed to fetch from {endpoint}")))
232        }
233    }
234
235    /// Async version of [`Self::get_request`]. Performs a GET request to the given URL and deserializes the returned JSON.
236    ///
237    /// # Arguments
238    ///  - `route`: the specific API route to use, e.g., `stateRoot/latest`
239    #[cfg(feature = "async")]
240    async fn get_request_async<T: DeserializeOwned>(&self, route: &str) -> Result<T> {
241        let endpoint = self.build_endpoint(route)?;
242        let response = reqwest::get(&endpoint).await.with_context(|| format!("Failed to fetch from {endpoint}"))?;
243
244        if response.status().is_success() {
245            response.json().await.with_context(|| "Failed to parse JSON response")
246        } else {
247            // Convert returned error into an `anyhow::Error`.
248            let error: RestError = response.json().await.with_context(|| "Failed to parse JSON error response")?;
249            Err(error.parse().context(format!("Failed to fetch from {endpoint}")))
250        }
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use crate::Query;
257
258    use snarkvm_console::network::TestnetV0;
259    use snarkvm_ledger_store::helpers::memory::BlockMemory;
260
261    use anyhow::Result;
262
263    type CurrentNetwork = TestnetV0;
264    type CurrentQuery = Query<CurrentNetwork, BlockMemory<CurrentNetwork>>;
265
266    /// Tests HTTP's behavior of printing an empty path `/`
267    ///
268    /// `generate_endpoint` can handle base_urls with and without a trailing slash.
269    /// However, this test is still useful to see if the behavior changes in the future and a second slash is not
270    /// appended to a URL with an existing trailing slash.
271    #[test]
272    fn test_rest_url_parse() -> Result<()> {
273        let noslash = "http://localhost:3030";
274        let withslash = format!("{noslash}/");
275        let route = "some/route";
276
277        let query = noslash.parse::<CurrentQuery>().unwrap();
278        let Query::REST(rest) = query else { panic!() };
279        assert_eq!(rest.base_url.path_and_query().unwrap().to_string(), "/");
280        assert_eq!(rest.base_url.to_string(), withslash);
281        assert_eq!(rest.build_endpoint(route)?, format!("{noslash}/v2/testnet/{route}"));
282
283        let query = withslash.parse::<CurrentQuery>().unwrap();
284        let Query::REST(rest) = query else { panic!() };
285        assert_eq!(rest.base_url.path_and_query().unwrap().to_string(), "/");
286        assert_eq!(rest.base_url.to_string(), withslash);
287        assert_eq!(rest.build_endpoint(route)?, format!("{noslash}/v2/testnet/{route}"));
288
289        Ok(())
290    }
291
292    #[test]
293    fn test_rest_url_with_colon_parse() {
294        let str = "http://myendpoint.addr/:var/foo/bar";
295        let query = str.parse::<CurrentQuery>().unwrap();
296
297        let Query::REST(rest) = query else { panic!() };
298        assert_eq!(rest.base_url.to_string(), format!("{str}"));
299        assert_eq!(rest.base_url.path_and_query().unwrap().to_string(), "/:var/foo/bar");
300    }
301
302    #[test]
303    fn test_rest_url_parse_with_suffix() -> Result<()> {
304        let base = "http://localhost:3030/a/prefix";
305        let route = "a/route";
306        let query = base.parse::<CurrentQuery>().unwrap();
307
308        // Test without trailing slash.
309        let Query::REST(rest) = query else { panic!() };
310        assert_eq!(rest.build_endpoint(route)?, format!("{base}/v2/testnet/{route}"));
311
312        // Set again with trailing slash.
313        let query = format!("{base}/").parse::<CurrentQuery>().unwrap();
314        let Query::REST(rest) = query else { panic!() };
315        assert_eq!(rest.build_endpoint(route)?, format!("{base}/v2/testnet/{route}"));
316
317        Ok(())
318    }
319}