Skip to content

Commit 2f784b6

Browse files
authored
Merge pull request #4260 from ProvableHQ/copilot/feature-batch-historical-mapping-lookup
Add batch historical mapping lookup endpoint and standardize batch key cap to 128
2 parents 80d573a + d53027b commit 2f784b6

3 files changed

Lines changed: 154 additions & 14 deletions

File tree

node/rest/src/helpers/error.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ use axum::{
2020
response::{IntoResponse, Response},
2121
};
2222
use serde::{Deserialize, Serialize};
23+
use std::fmt;
2324

2425
/// An enum of error handlers for the REST API server.
2526
#[derive(Debug)]
@@ -120,6 +121,33 @@ impl IntoResponse for RestError {
120121
}
121122
}
122123

124+
impl fmt::Display for RestError {
125+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126+
match self {
127+
RestError::BadRequest(err) => write!(f, "{err}"),
128+
RestError::NotFound(err) => write!(f, "{err}"),
129+
RestError::UnprocessableEntity(err) => write!(f, "{err}"),
130+
RestError::TooManyRequests(err) => write!(f, "{err}"),
131+
RestError::ServiceUnavailable(err) => write!(f, "{err}"),
132+
RestError::InternalServerError(err) => write!(f, "{err}"),
133+
}
134+
}
135+
}
136+
137+
impl PartialEq<StatusCode> for RestError {
138+
fn eq(&self, other: &StatusCode) -> bool {
139+
let status = match self {
140+
RestError::BadRequest(_) => StatusCode::BAD_REQUEST,
141+
RestError::NotFound(_) => StatusCode::NOT_FOUND,
142+
RestError::UnprocessableEntity(_) => StatusCode::UNPROCESSABLE_ENTITY,
143+
RestError::TooManyRequests(_) => StatusCode::TOO_MANY_REQUESTS,
144+
RestError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
145+
RestError::InternalServerError(_) => StatusCode::INTERNAL_SERVER_ERROR,
146+
};
147+
status == *other
148+
}
149+
}
150+
123151
impl From<anyhow::Error> for RestError {
124152
fn from(err: anyhow::Error) -> Self {
125153
// Default to 500 Internal Server Error

node/rest/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,9 @@ impl<N: Network, C: ConsensusStorage<N>, R: Routing<N>> Rest<N, C, R> {
270270

271271
// If the `history` feature is enabled, enable the additional endpoint.
272272
#[cfg(feature = "history")]
273-
let routes = routes.route("/program/{id}/mapping/{name}/{key}/history/{height}", get(Self::get_history));
273+
let routes = routes
274+
.route("/program/{id}/mapping/{name}/{key}/history/{height}", get(Self::get_history))
275+
.route("/program/{id}/mapping/{name}/history/{height}", get(Self::get_history_batch));
274276

275277
// If the `history-staking-rewards` feature is enabled, enable the additional endpoint.
276278
#[cfg(feature = "history-staking-rewards")]

node/rest/src/routes.rs

Lines changed: 123 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,38 @@ use std::{collections::HashMap, fs};
3838
use rayon::prelude::*;
3939
use version::VersionInfo;
4040

41+
#[cfg(feature = "history")]
42+
const MAX_KEYS_PER_REQUEST: usize = 1 << 7;
4143
#[cfg(feature = "history")]
4244
type HistoricalMappingKey<N> = (ProgramID<N>, Identifier<N>, Plaintext<N>, u32);
45+
#[cfg(feature = "history")]
46+
type HistoricalMappingRoute<N> = (ProgramID<N>, Identifier<N>, u32);
47+
48+
#[cfg(feature = "history")]
49+
fn parse_historical_mapping_keys<N: Network>(keys: &[String]) -> Result<Vec<Plaintext<N>>, RestError> {
50+
// Retrieve the number of keys.
51+
let num_keys = keys.len();
52+
// Return an error if no keys are provided.
53+
if num_keys == 0 {
54+
return Err(RestError::unprocessable_entity(anyhow!("No keys provided")));
55+
}
56+
// Return an error if the number of keys exceeds the maximum allowed.
57+
if num_keys > MAX_KEYS_PER_REQUEST {
58+
return Err(RestError::unprocessable_entity(anyhow!(
59+
"Too many keys provided (max: {MAX_KEYS_PER_REQUEST}, got: {num_keys})"
60+
)));
61+
}
62+
63+
// Deserialize the keys from the query.
64+
keys.iter()
65+
.enumerate()
66+
.map(|(index, key)| {
67+
key.parse::<Plaintext<N>>().map_err(|err| {
68+
RestError::unprocessable_entity(err.context(format!("Invalid key at index {index}: {key}")))
69+
})
70+
})
71+
.collect::<Result<Vec<_>, _>>()
72+
}
4373

4474
/// Deserialize a CSV string into a vector of strings.
4575
fn de_csv<'de, D>(de: D) -> std::result::Result<Vec<String>, D::Error>
@@ -90,6 +120,14 @@ pub(crate) struct Commitments {
90120
commitments: Vec<String>,
91121
}
92122

123+
/// The query object for `get_history_batch`.
124+
#[cfg(feature = "history")]
125+
#[derive(Clone, Deserialize, Serialize)]
126+
pub(crate) struct HistoricalKeys {
127+
#[serde(deserialize_with = "de_csv")]
128+
keys: Vec<String>,
129+
}
130+
93131
/// The return value for a transaction metadata query.
94132
#[skip_serializing_none]
95133
#[derive(Serialize)]
@@ -610,22 +648,31 @@ impl<N: Network, C: ConsensusStorage<N>, R: Routing<N>> Rest<N, C, R> {
610648
}
611649
// Return an error if the number of commitments exceeds the maximum allowed.
612650
if num_commitments > N::MAX_INPUTS {
613-
return Err(RestError::unprocessable_entity(anyhow!(
614-
"Too many commitments provided (max: {}, got: {})",
615-
N::MAX_INPUTS,
616-
num_commitments
617-
)));
651+
return Err(RestError::unprocessable_entity(anyhow!(format!(
652+
"Too many commitments provided (max: {}, got: {num_commitments})",
653+
N::MAX_INPUTS
654+
))));
618655
}
619656

620657
// Deserialize the commitments from the query.
621-
let commitments = commitments
622-
.commitments
623-
.iter()
624-
.map(|s| {
625-
s.parse::<Field<N>>()
626-
.map_err(|err| RestError::unprocessable_entity(err.context(format!("Invalid commitment: {s}"))))
627-
})
628-
.collect::<Result<Vec<_>, _>>()?;
658+
let commitments = match tokio::task::spawn_blocking(move || {
659+
commitments
660+
.commitments
661+
.iter()
662+
.map(|s| {
663+
s.parse::<Field<N>>()
664+
.map_err(|err| RestError::unprocessable_entity(err.context(format!("Invalid commitment: {s}"))))
665+
})
666+
.collect::<Result<Vec<_>, _>>()
667+
})
668+
.await
669+
{
670+
Ok(Ok(commitments)) => commitments,
671+
Ok(Err(err)) => {
672+
return Err(RestError::internal_server_error(anyhow!(err).context("Unable to parse commitments")));
673+
}
674+
Err(err) => return Err(RestError::internal_server_error(anyhow!(err).context("Tokio error"))),
675+
};
629676

630677
Ok(ErasedJson::pretty(rest.ledger.get_state_paths_for_commitments(&commitments)?))
631678
}
@@ -1051,6 +1098,42 @@ impl<N: Network, C: ConsensusStorage<N>, R: Routing<N>> Rest<N, C, R> {
10511098
Ok((StatusCode::OK, ErasedJson::pretty(value)))
10521099
}
10531100

1101+
/// GET /{network}/program/{id}/mapping/{name}/history/{height}?keys=key1,key2,...
1102+
#[cfg(feature = "history")]
1103+
pub(crate) async fn get_history_batch(
1104+
State(rest): State<Self>,
1105+
Path((program_id, mapping_name, height)): Path<HistoricalMappingRoute<N>>,
1106+
Query(historical_keys): Query<HistoricalKeys>,
1107+
) -> Result<impl axum::response::IntoResponse, RestError> {
1108+
let mapping_keys = parse_historical_mapping_keys::<N>(&historical_keys.keys)?;
1109+
1110+
let values = match tokio::task::spawn_blocking(move || cfg_into_iter!(historical_keys
1111+
.keys)
1112+
.zip(mapping_keys)
1113+
.map(|(key, mapping_key)| {
1114+
let value = rest
1115+
.ledger
1116+
.vm()
1117+
.finalize_store()
1118+
.get_historical_mapping_value(program_id, mapping_name, mapping_key, height)
1119+
.map_err(|err| {
1120+
RestError::not_found(err.context(format!(
1121+
"Could not load mapping '{mapping_name}/{key}' for program '{program_id}' from block '{height}'"
1122+
)))
1123+
})?;
1124+
1125+
Ok(json!({ "key": key, "value": value }))
1126+
})
1127+
.collect::<Result<Vec<_>, RestError>>())
1128+
.await {
1129+
Ok(Ok(values)) => values,
1130+
Ok(Err(err)) => return Err(RestError::internal_server_error(anyhow!(err).context("Unable to get historical mapping values"))),
1131+
Err(err) => return Err(RestError::internal_server_error(anyhow!("Tokio error: {err}"))),
1132+
};
1133+
1134+
Ok((StatusCode::OK, ErasedJson::pretty(values)))
1135+
}
1136+
10541137
/// GET /{network}/staking/rewards/{address}/{height}
10551138
#[cfg(feature = "history-staking-rewards")]
10561139
pub(crate) async fn get_staking_reward(
@@ -1112,3 +1195,30 @@ impl<N: Network, C: ConsensusStorage<N>, R: Routing<N>> Rest<N, C, R> {
11121195
}
11131196
}
11141197
}
1198+
1199+
#[cfg(all(test, feature = "history"))]
1200+
mod tests {
1201+
use super::*;
1202+
use snarkvm::prelude::MainnetV0;
1203+
1204+
#[test]
1205+
fn parse_historical_mapping_keys_rejects_empty() {
1206+
let err = parse_historical_mapping_keys::<MainnetV0>(&[]).unwrap_err();
1207+
assert_eq!(err, StatusCode::UNPROCESSABLE_ENTITY);
1208+
}
1209+
1210+
#[test]
1211+
fn parse_historical_mapping_keys_rejects_too_many() {
1212+
let keys = vec![String::from("1field"); MAX_KEYS_PER_REQUEST + 1];
1213+
let err = parse_historical_mapping_keys::<MainnetV0>(&keys).unwrap_err();
1214+
assert_eq!(err, StatusCode::UNPROCESSABLE_ENTITY);
1215+
}
1216+
1217+
#[test]
1218+
fn parse_historical_mapping_keys_rejects_invalid_key_with_index() {
1219+
let keys = vec![String::from("1field"), String::from("not_a_plaintext")];
1220+
let err = parse_historical_mapping_keys::<MainnetV0>(&keys).unwrap_err();
1221+
assert_eq!(err, StatusCode::UNPROCESSABLE_ENTITY);
1222+
assert!(err.to_string().contains("Invalid key at index 1"));
1223+
}
1224+
}

0 commit comments

Comments
 (0)