Skip to content

Commit 6d17a0f

Browse files
committed
Some improvements related to cursor paging model for the events endpoint in Crystal API. Re #194.
1 parent f7078f5 commit 6d17a0f

File tree

9 files changed

+100
-52
lines changed

9 files changed

+100
-52
lines changed

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ async-trait = "0.1"
3232
anyhow = "1"
3333
axum = "0.8"
3434
axum-embed = "0.1"
35-
base64 = "0.22.1"
35+
base64 = "0.22"
3636
chrono = { version = "0.4", features = ["serde"] }
3737
clap = { version = "4.5", features = ["env", "derive"] }
3838
convert_case = "0.10"

submerge-crystal/api-spec/submerge-crystal-api.json

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1440,7 +1440,7 @@
14401440
{
14411441
"name": "next_cursor",
14421442
"in": "query",
1443-
"description": "Opaque cursor for pagination. If provided, all filter params are ignored.",
1443+
"description": "Opaque cursor for pagination - returned in the endpoint response.\nThis parameter is mutually exclusive with all other parameters,\nand will return bad request if any other parameter is set.",
14441444
"required": false,
14451445
"schema": {
14461446
"type": "string"
@@ -3545,10 +3545,7 @@
35453545
],
35463546
"properties": {
35473547
"nextCursor": {
3548-
"type": [
3549-
"string",
3550-
"null"
3551-
],
3548+
"type": "string",
35523549
"description": "Cursor for the next page, `null` if there's no next page."
35533550
},
35543551
"pageSize": {
@@ -4752,7 +4749,7 @@
47524749
},
47534750
"SignatureHexString": {
47544751
"type": "string",
4755-
"description": "and ECDSA (65 bytes → 130 hex chars). **Always** `0x`-prefixed and lowercase in responses.\nInputs elsewhere may accept mixed case or missing `0x`; the API normalizes outputs.",
4752+
"description": "Hex-encoded Substrate extrinsic signature. Supports Sr25519/Ed25519 (64 bytes → 128 hex chars)\nand ECDSA (65 bytes → 130 hex chars). **Always** `0x`-prefixed and lowercase in responses.\nInputs elsewhere may accept mixed case or missing `0x`; the API normalizes outputs.",
47564753
"examples": [
47574754
"0xabababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab",
47584755
"0xababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab"

submerge-crystal/src/api/mod.rs

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,18 @@ pub(crate) mod legacy;
5757
pub(crate) mod v1;
5858

5959
const DEFAULT_PAGE: u32 = 1;
60-
const DEFAULT_PAGE_SIZE: u32 = 1000;
61-
const MAX_PAGE_SIZE: u32 = 1000;
60+
const DEFAULT_PAGE_SIZE: u32 = 100;
61+
const MAX_PAGE_SIZE: u32 = 250;
62+
const DEFAULT_PAGE_SIZE_WITH_ARGS: u32 = 25;
6263
const MAX_PAGE_SIZE_WITH_ARGS: u32 = 25;
6364
const MAX_RESPONSE_MESSAGE_BYTES: usize = 64 * 1024;
6465

6566
fn get_page_size(page_size: Option<u32>, include_args: bool) -> Result<u32, APIError> {
66-
let page_size = page_size.unwrap_or(DEFAULT_PAGE_SIZE);
67+
let page_size = page_size.unwrap_or(if include_args {
68+
DEFAULT_PAGE_SIZE_WITH_ARGS
69+
} else {
70+
DEFAULT_PAGE_SIZE
71+
});
6772
if page_size < 1 {
6873
Err(APIError::BadRequest(
6974
"Page size cannot be less than 1.".to_string(),
@@ -87,25 +92,12 @@ fn get_page_number_and_size(
8792
include_args: bool,
8893
) -> Result<(u32, u32), APIError> {
8994
let page = page.unwrap_or(DEFAULT_PAGE);
90-
let page_size = page_size.unwrap_or(DEFAULT_PAGE_SIZE);
9195
if page < 1 {
9296
Err(APIError::BadRequest(
9397
"Page number cannot be less than 1.".to_string(),
9498
))
95-
} else if page_size < 1 {
96-
Err(APIError::BadRequest(
97-
"Page size cannot be less than 1.".to_string(),
98-
))
99-
} else if !include_args && page_size > MAX_PAGE_SIZE {
100-
Err(APIError::BadRequest(format!(
101-
"Page size cannot be greater than {MAX_PAGE_SIZE}."
102-
)))
103-
} else if include_args && page_size > MAX_PAGE_SIZE_WITH_ARGS {
104-
Err(APIError::BadRequest(format!(
105-
"Page size for requests with arguments included cannot be greater than {MAX_PAGE_SIZE_WITH_ARGS}."
106-
)))
10799
} else {
108-
Ok((page, page_size))
100+
Ok((page, get_page_size(page_size, include_args)?))
109101
}
110102
}
111103

submerge-crystal/src/api/v1/event.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,14 @@ use crate::{
1717
pagination::{CursorPaginationData, PaginationData},
1818
request::{
1919
block::BlockReference,
20-
event::{
21-
BlockEventQuery, EventCursorPayload, EventCursorPosition, EventQuery,
22-
IncludeEventArgsParam,
23-
},
20+
event::{BlockEventQuery, EventQuery, IncludeEventArgsParam},
2421
},
2522
response::{
2623
error::{BadRequest, InternalServerError, NotFound, TooManyRequests},
27-
event::{CursorEventList, EventArgs, EventDTO, EventList, PaginatedEventList},
24+
event::{
25+
CursorEventList, EventArgs, EventCursorPayload, EventCursorPosition, EventDTO,
26+
EventList, PaginatedEventList,
27+
},
2828
},
2929
},
3030
error::APIError,
@@ -61,6 +61,7 @@ pub(crate) async fn get_events(
6161
State(state): State<ServiceState>,
6262
Query(query): Query<EventQuery>,
6363
) -> Result<Json<CursorEventList>, APIError> {
64+
query.validate_next_cursor_mutually_exclusive()?;
6465
let (cursor_position, query) = if let Some(cursor) = query.next_cursor {
6566
// TODO validate that no other query params are set
6667
let decoded = URL_SAFE_NO_PAD.decode(cursor)?;

submerge-crystal/src/persistence/api/event.rs

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
use crate::types::{api::dto::request::event::EventCursorPosition, persistence::EventCompositeRow};
1+
use crate::types::{
2+
api::dto::response::event::EventCursorPosition, persistence::EventCompositeRow,
3+
};
24
use serde_json::Value as JSONValue;
35
use sqlx::{Postgres, QueryBuilder};
46
use submerge_persistence::postgres::{escape_like_pattern, PostgreSQLStorage};
@@ -31,7 +33,7 @@ fn get_select_query(include_args: bool) -> String {
3133
pub(crate) trait CrystalEventAPIPostgreSQLStorage {
3234
async fn get_events(
3335
&self,
34-
cursor: Option<EventCursorPosition>,
36+
cursor_position: Option<EventCursorPosition>,
3537
min_block_number: Option<i64>,
3638
max_block_number: Option<i64>,
3739
pallet_name: &Option<String>,
@@ -141,7 +143,7 @@ pub(crate) trait CrystalEventAPIPostgreSQLStorage {
141143
impl CrystalEventAPIPostgreSQLStorage for PostgreSQLStorage {
142144
async fn get_events(
143145
&self,
144-
cursor: Option<EventCursorPosition>,
146+
cursor_position: Option<EventCursorPosition>,
145147
min_block_number: Option<i64>,
146148
max_block_number: Option<i64>,
147149
pallet_name: &Option<String>,
@@ -159,28 +161,28 @@ impl CrystalEventAPIPostgreSQLStorage for PostgreSQLStorage {
159161
query_builder.push(" AND E.block_number <= ").push_bind(max);
160162
}
161163

162-
if let Some(cursor) = cursor {
163-
let block_hash = hex::decode(cursor.block_hash_hex.trim_start_matches("0x"))?;
164+
if let Some(cursor_position) = cursor_position {
165+
let block_hash = cursor_position.get_block_hash()?;
164166
query_builder.push(" AND (");
165167
query_builder
166168
.push("E.block_number < ")
167-
.push_bind(cursor.block_number as i64);
169+
.push_bind(cursor_position.block_number as i64);
168170
query_builder
169171
.push(" OR (E.block_number = ")
170-
.push_bind(cursor.block_number as i64);
172+
.push_bind(cursor_position.block_number as i64);
171173
query_builder
172174
.push(" AND E.block_hash > ")
173175
.push_bind(block_hash.clone());
174176
query_builder.push(")");
175177
query_builder
176178
.push(" OR (E.block_number = ")
177-
.push_bind(cursor.block_number as i64);
179+
.push_bind(cursor_position.block_number as i64);
178180
query_builder
179181
.push(" AND E.block_hash = ")
180182
.push_bind(block_hash.clone());
181183
query_builder
182184
.push(" AND E.index > ")
183-
.push_bind(cursor.index as i32);
185+
.push_bind(cursor_position.index as i32);
184186
query_builder.push(")");
185187
query_builder.push(")");
186188
}

submerge-crystal/src/types/api/dto/pagination.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,6 @@ pub(crate) struct CursorPaginationData {
4545
#[schema(example = 1)]
4646
pub page_size: u32,
4747
/// Cursor for the next page, `null` if there's no next page.
48+
#[schema(required = false, nullable = false)]
4849
pub next_cursor: Option<String>,
4950
}

submerge-crystal/src/types/api/dto/request/event.rs

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
use serde::{Deserialize, Serialize};
22
use utoipa::IntoParams;
33

4+
use crate::types::api::error::APIError;
5+
46
/// Query parameters for fetching and filtering events.
57
#[derive(Debug, Serialize, Deserialize, IntoParams)]
68
#[serde(deny_unknown_fields)]
79
pub(crate) struct EventQuery {
8-
/// Opaque cursor for pagination. If provided, all filter params are ignored.
10+
/// Opaque cursor for pagination - returned in the endpoint response.
11+
/// This parameter is mutually exclusive with all other parameters,
12+
/// and will return bad request if any other parameter is set.
913
#[param(required = false, nullable = false)]
1014
#[serde(skip_serializing_if = "Option::is_none")]
1115
pub next_cursor: Option<String>,
@@ -70,17 +74,48 @@ pub(crate) struct EventQuery {
7074
pub include_args: bool,
7175
}
7276

73-
#[derive(Debug, Serialize, Deserialize)]
74-
pub(crate) struct EventCursorPosition {
75-
pub(crate) block_number: u64,
76-
pub(crate) block_hash_hex: String,
77-
pub(crate) index: u32,
78-
}
79-
80-
#[derive(Debug, Serialize, Deserialize)]
81-
pub(crate) struct EventCursorPayload {
82-
pub(crate) cursor_position: EventCursorPosition,
83-
pub(crate) query: EventQuery,
77+
impl EventQuery {
78+
pub(crate) fn validate_next_cursor_mutually_exclusive(&self) -> Result<(), APIError> {
79+
if self.next_cursor.is_some() {
80+
let mut other_fields = Vec::new();
81+
if self.page_size.is_some() {
82+
other_fields.push("page_size");
83+
}
84+
if self.min_block_number.is_some() {
85+
other_fields.push("min_block_number");
86+
}
87+
if self.max_block_number.is_some() {
88+
other_fields.push("max_block_number");
89+
}
90+
if self.min_block_timestamp.is_some() {
91+
other_fields.push("min_block_timestamp");
92+
}
93+
if self.max_block_timestamp.is_some() {
94+
other_fields.push("max_block_timestamp");
95+
}
96+
if self.min_spec_version.is_some() {
97+
other_fields.push("min_spec_version");
98+
}
99+
if self.max_spec_version.is_some() {
100+
other_fields.push("max_spec_version");
101+
}
102+
if self.pallet_name.is_some() {
103+
other_fields.push("pallet_name");
104+
}
105+
if self.event_name.is_some() {
106+
other_fields.push("event_name");
107+
}
108+
if !other_fields.is_empty() {
109+
return Err(APIError::BadRequest(
110+
format!(
111+
"No other parameter should not be set when next_cursor is set. Please remove these parameters and try again: {}",
112+
other_fields.join(", ")
113+
)
114+
));
115+
}
116+
}
117+
Ok(())
118+
}
84119
}
85120

86121
/// Query parameters for fetching and filtering events within a block.

submerge-crystal/src/types/api/dto/response/event.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
use serde::Serialize;
1+
use serde::{Deserialize, Serialize};
22
use serde_json::Value as JSONValue;
33
use utoipa::{ToResponse, ToSchema};
44

55
use crate::types::{
66
api::dto::{
77
pagination::{CursorPaginationData, PaginationData},
8+
request::event::EventQuery,
89
response::{
910
example::event::event_example, hex::Hash256Hex, schema::event::event_args_schema,
1011
},
@@ -108,6 +109,25 @@ pub(crate) struct PaginatedEventList {
108109
pub pagination: PaginationData,
109110
}
110111

112+
#[derive(Debug, Serialize, Deserialize)]
113+
pub(crate) struct EventCursorPosition {
114+
pub(crate) block_number: u64,
115+
pub(crate) block_hash_hex: String,
116+
pub(crate) index: u32,
117+
}
118+
119+
impl EventCursorPosition {
120+
pub(crate) fn get_block_hash(&self) -> anyhow::Result<Vec<u8>> {
121+
Ok(hex::decode(self.block_hash_hex.trim_start_matches("0x"))?)
122+
}
123+
}
124+
125+
#[derive(Debug, Serialize, Deserialize)]
126+
pub(crate) struct EventCursorPayload {
127+
pub(crate) cursor_position: EventCursorPosition,
128+
pub(crate) query: EventQuery,
129+
}
130+
111131
#[derive(Debug, Serialize, ToResponse, ToSchema)]
112132
#[response(
113133
description = "List of matching events, with a cursor for the next page.",

submerge-crystal/src/types/api/dto/response/hex.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ impl From<&[u8; 32]> for Address32Hex {
8080
}
8181
}
8282

83-
// Hex-encoded Substrate extrinsic signature. Supports Sr25519/Ed25519 (64 bytes → 128 hex chars)
83+
/// Hex-encoded Substrate extrinsic signature. Supports Sr25519/Ed25519 (64 bytes → 128 hex chars)
8484
/// and ECDSA (65 bytes → 130 hex chars). **Always** `0x`-prefixed and lowercase in responses.
8585
/// Inputs elsewhere may accept mixed case or missing `0x`; the API normalizes outputs.
8686
#[derive(Debug, Clone, Serialize, ToSchema)]

0 commit comments

Comments
 (0)