Skip to main content

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        // 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))]
106impl<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
156impl<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)]
279mod tests {
280    use crate::Query;
281
282    use snarkvm_console::network::TestnetV0;
283    use snarkvm_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}