openzeppelin_relayer/models/
pagination.rs

1use crate::models::TransactionStatus;
2use serde::de::IntoDeserializer;
3use serde::{Deserialize, Deserializer};
4use utoipa::ToSchema;
5
6#[derive(Debug, Deserialize, Clone, ToSchema)]
7pub struct PaginationQuery {
8    #[serde(default = "default_page")]
9    pub page: u32,
10    #[serde(default = "default_per_page")]
11    pub per_page: u32,
12}
13
14/// Pagination + optional status filter for listing transactions.
15#[derive(Debug, Deserialize, Clone, ToSchema)]
16pub struct TransactionListQuery {
17    #[serde(default = "default_page")]
18    pub page: u32,
19    #[serde(default = "default_per_page")]
20    pub per_page: u32,
21    /// Optional status filter. Accepted case-insensitively (e.g. `submitted`,
22    /// `Submitted`, and `SUBMITTED` all match), though the canonical form is
23    /// lowercase.
24    #[serde(default, deserialize_with = "deserialize_optional_status_ci")]
25    pub status: Option<TransactionStatus>,
26}
27
28/// Deserializes an optional [`TransactionStatus`] from a query string
29/// case-insensitively. `TransactionStatus` serializes as lowercase, so we lower
30/// the incoming value before matching — this means `?status=Submitted` works as
31/// well as `?status=submitted` rather than returning a 400.
32fn deserialize_optional_status_ci<'de, D>(
33    deserializer: D,
34) -> Result<Option<TransactionStatus>, D::Error>
35where
36    D: Deserializer<'de>,
37{
38    let raw = String::deserialize(deserializer)?;
39    let lowered = raw.to_ascii_lowercase();
40    TransactionStatus::deserialize(lowered.as_str().into_deserializer())
41        .map(Some)
42        .map_err(|_: serde::de::value::Error| {
43            serde::de::Error::custom(format!("unknown transaction status: {raw}"))
44        })
45}
46
47impl From<TransactionListQuery> for PaginationQuery {
48    fn from(q: TransactionListQuery) -> Self {
49        PaginationQuery {
50            page: q.page,
51            per_page: q.per_page,
52        }
53    }
54}
55
56fn default_page() -> u32 {
57    1
58}
59fn default_per_page() -> u32 {
60    10
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66
67    // `web::Query` is exactly how the `?status=` param is decoded at the route layer,
68    // so parsing through it mirrors real request handling.
69    fn parse(qs: &str) -> Result<TransactionListQuery, actix_web::error::QueryPayloadError> {
70        actix_web::web::Query::<TransactionListQuery>::from_query(qs).map(|q| q.into_inner())
71    }
72
73    #[test]
74    fn status_is_parsed_case_insensitively() {
75        for qs in ["status=submitted", "status=Submitted", "status=SUBMITTED"] {
76            let q = parse(qs).unwrap_or_else(|e| panic!("{qs} should parse: {e}"));
77            assert_eq!(q.status, Some(TransactionStatus::Submitted), "for {qs}");
78        }
79    }
80
81    #[test]
82    fn status_absent_is_none_with_defaults() {
83        let q = parse("").unwrap();
84        assert_eq!(q.status, None);
85        assert_eq!(q.page, 1);
86        assert_eq!(q.per_page, 10);
87    }
88
89    #[test]
90    fn other_statuses_and_pagination_round_trip() {
91        let q = parse("page=2&per_page=5&status=CANCELED").unwrap();
92        assert_eq!(q.status, Some(TransactionStatus::Canceled));
93        assert_eq!(q.page, 2);
94        assert_eq!(q.per_page, 5);
95    }
96
97    #[test]
98    fn invalid_status_is_rejected() {
99        assert!(parse("status=bogus").is_err());
100    }
101}