Skip to content

Commit 6db04e3

Browse files
authored
Merge pull request #10196 from Turbo87/pagination-query
controllers/helpers/pagination: Read query parameters via `axum::Query` extractor
2 parents 59d4020 + d4e0a86 commit 6db04e3

File tree

3 files changed

+48
-62
lines changed

3 files changed

+48
-62
lines changed

src/controllers/helpers/pagination.rs

Lines changed: 45 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ use crate::middleware::log_request::RequestLogExt;
44
use crate::middleware::real_ip::RealIp;
55
use crate::models::helpers::with_count::*;
66
use crate::util::errors::{bad_request, AppResult};
7-
use crate::util::{HeaderMapExt, RequestUtils};
7+
use crate::util::HeaderMapExt;
8+
use std::num::NonZeroU32;
89

910
use base64::{engine::general_purpose, Engine};
1011
use diesel::pg::Pg;
@@ -77,28 +78,30 @@ impl PaginationOptionsBuilder {
7778
}
7879

7980
pub(crate) fn gather(self, parts: &Parts) -> AppResult<PaginationOptions> {
80-
let params = parts.query();
81-
let page_param = params.get("page");
82-
let seek_param = params.get("seek");
81+
use axum::extract::Query;
8382

84-
if seek_param.is_some() && page_param.is_some() {
83+
#[derive(Debug, Deserialize)]
84+
struct QueryParams {
85+
page: Option<NonZeroU32>,
86+
per_page: Option<NonZeroU32>,
87+
seek: Option<String>,
88+
}
89+
90+
let Query(params) = Query::<QueryParams>::try_from_uri(&parts.uri)
91+
.map_err(|err| bad_request(err.body_text()))?;
92+
93+
if params.seek.is_some() && params.page.is_some() {
8594
return Err(bad_request(
8695
"providing both ?page= and ?seek= is unsupported",
8796
));
8897
}
8998

90-
let page = if let Some(s) = page_param {
99+
let page = if let Some(s) = params.page {
91100
if !self.enable_pages {
92101
return Err(bad_request("?page= is not supported for this request"));
93102
}
94103

95-
let numeric_page = s.parse().map_err(bad_request)?;
96-
if numeric_page < 1 {
97-
return Err(bad_request(format_args!(
98-
"page indexing starts from 1, page {numeric_page} is invalid",
99-
)));
100-
}
101-
104+
let numeric_page = s.get();
102105
if numeric_page > MAX_PAGE_BEFORE_SUSPECTED_BOT {
103106
parts.request_log().add("bot", "suspected");
104107
}
@@ -119,28 +122,22 @@ impl PaginationOptionsBuilder {
119122
}
120123

121124
Page::Numeric(numeric_page)
122-
} else if let Some(s) = seek_param {
125+
} else if let Some(s) = params.seek {
123126
if !self.enable_seek {
124127
return Err(bad_request("?seek= is not supported for this request"));
125128
}
126129

127-
Page::Seek(RawSeekPayload(s.clone()))
130+
Page::Seek(RawSeekPayload(s))
128131
} else {
129132
Page::Unspecified
130133
};
131134

132-
let per_page = params
133-
.get("per_page")
134-
.map(|s| s.parse().map_err(bad_request))
135-
.unwrap_or(Ok(DEFAULT_PER_PAGE))?;
135+
let per_page = params.per_page.map(|p| p.get() as i64);
136+
let per_page = per_page.unwrap_or(DEFAULT_PER_PAGE);
136137
if per_page > MAX_PER_PAGE {
137138
return Err(bad_request(format_args!(
138139
"cannot request more than {MAX_PER_PAGE} items",
139140
)));
140-
} else if per_page < 1 {
141-
return Err(bad_request(format_args!(
142-
"cannot request less than 1 item, per_page {per_page} is invalid",
143-
)));
144141
}
145142

146143
Ok(PaginationOptions { page, per_page })
@@ -529,6 +526,7 @@ pub(crate) use seek;
529526
mod tests {
530527
use super::*;
531528
use http::{Method, Request, StatusCode};
529+
use insta::assert_snapshot;
532530

533531
#[test]
534532
fn no_pagination_param() {
@@ -539,13 +537,12 @@ mod tests {
539537

540538
#[test]
541539
fn page_param_parsing() {
542-
let assert_error =
543-
|query, msg| assert_pagination_error(PaginationOptions::builder(), query, msg);
540+
let error = |query| pagination_error(PaginationOptions::builder(), query);
544541

545-
assert_error("page=", "cannot parse integer from empty string");
546-
assert_error("page=not_a_number", "invalid digit found in string");
547-
assert_error("page=1.0", "invalid digit found in string");
548-
assert_error("page=0", "page indexing starts from 1, page 0 is invalid");
542+
assert_snapshot!(error("page="), @"Failed to deserialize query string: cannot parse integer from empty string");
543+
assert_snapshot!(error("page=not_a_number"), @"Failed to deserialize query string: invalid digit found in string");
544+
assert_snapshot!(error("page=1.0"), @"Failed to deserialize query string: invalid digit found in string");
545+
assert_snapshot!(error("page=0"), @"Failed to deserialize query string: invalid value: integer `0`, expected a nonzero u32");
549546

550547
let pagination = PaginationOptions::builder()
551548
.gather(&mock("page=5"))
@@ -555,17 +552,13 @@ mod tests {
555552

556553
#[test]
557554
fn per_page_param_parsing() {
558-
let assert_error =
559-
|query, msg| assert_pagination_error(PaginationOptions::builder(), query, msg);
560-
561-
assert_error("per_page=", "cannot parse integer from empty string");
562-
assert_error("per_page=not_a_number", "invalid digit found in string");
563-
assert_error("per_page=1.0", "invalid digit found in string");
564-
assert_error("per_page=101", "cannot request more than 100 items");
565-
assert_error(
566-
"per_page=0",
567-
"cannot request less than 1 item, per_page 0 is invalid",
568-
);
555+
let error = |query| pagination_error(PaginationOptions::builder(), query);
556+
557+
assert_snapshot!(error("per_page="), @"Failed to deserialize query string: cannot parse integer from empty string");
558+
assert_snapshot!(error("per_page=not_a_number"), @"Failed to deserialize query string: invalid digit found in string");
559+
assert_snapshot!(error("per_page=1.0"), @"Failed to deserialize query string: invalid digit found in string");
560+
assert_snapshot!(error("per_page=101"), @"cannot request more than 100 items");
561+
assert_snapshot!(error("per_page=0"), @"Failed to deserialize query string: invalid value: integer `0`, expected a nonzero u32");
569562

570563
let pagination = PaginationOptions::builder()
571564
.gather(&mock("per_page=5"))
@@ -575,11 +568,8 @@ mod tests {
575568

576569
#[test]
577570
fn seek_param_parsing() {
578-
assert_pagination_error(
579-
PaginationOptions::builder(),
580-
"seek=OTg",
581-
"?seek= is not supported for this request",
582-
);
571+
let error = pagination_error(PaginationOptions::builder(), "seek=OTg");
572+
assert_snapshot!(error, @"?seek= is not supported for this request");
583573

584574
let pagination = PaginationOptions::builder()
585575
.enable_seek(true)
@@ -598,25 +588,20 @@ mod tests {
598588

599589
#[test]
600590
fn both_page_and_seek() {
601-
assert_pagination_error(
602-
PaginationOptions::builder(),
603-
"page=1&seek=OTg",
604-
"providing both ?page= and ?seek= is unsupported",
605-
);
606-
assert_pagination_error(
591+
let error = pagination_error(PaginationOptions::builder(), "page=1&seek=OTg");
592+
assert_snapshot!(error, @"providing both ?page= and ?seek= is unsupported");
593+
594+
let error = pagination_error(
607595
PaginationOptions::builder().enable_seek(true),
608596
"page=1&seek=OTg",
609-
"providing both ?page= and ?seek= is unsupported",
610597
);
598+
assert_snapshot!(error, @"providing both ?page= and ?seek= is unsupported");
611599
}
612600

613601
#[test]
614602
fn disabled_pages() {
615-
assert_pagination_error(
616-
PaginationOptions::builder().enable_pages(false),
617-
"page=1",
618-
"?page= is not supported for this request",
619-
);
603+
let error = pagination_error(PaginationOptions::builder().enable_pages(false), "page=1");
604+
assert_snapshot!(error, @"?page= is not supported for this request");
620605
}
621606

622607
#[test]
@@ -749,11 +734,12 @@ mod tests {
749734
.0
750735
}
751736

752-
fn assert_pagination_error(options: PaginationOptionsBuilder, query: &str, message: &str) {
737+
fn pagination_error(options: PaginationOptionsBuilder, query: &str) -> String {
753738
let error = options.gather(&mock(query)).unwrap_err();
754-
assert_eq!(error.to_string(), message);
755739

756740
let response = error.response();
757741
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
742+
743+
error.to_string()
758744
}
759745
}

src/tests/routes/crates/list.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1172,13 +1172,13 @@ async fn pagination_parameters_only_accept_integers() {
11721172
.get_with_query::<()>("/api/v1/crates", "page=1&per_page=100%22%EF%BC%8Cexception")
11731173
.await;
11741174
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1175-
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"invalid digit found in string"}]}"#);
1175+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Failed to deserialize query string: invalid digit found in string"}]}"#);
11761176

11771177
let response = anon
11781178
.get_with_query::<()>("/api/v1/crates", "page=100%22%EF%BC%8Cexception&per_page=1")
11791179
.await;
11801180
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1181-
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"invalid digit found in string"}]}"#);
1181+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Failed to deserialize query string: invalid digit found in string"}]}"#);
11821182
}
11831183

11841184
#[tokio::test(flavor = "multi_thread")]

src/tests/routes/me/updates.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,5 +103,5 @@ async fn following() {
103103
.get_with_query::<()>("/api/v1/me/updates", "page=0")
104104
.await;
105105
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
106-
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"page indexing starts from 1, page 0 is invalid"}]}"#);
106+
assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Failed to deserialize query string: invalid value: integer `0`, expected a nonzero u32"}]}"#);
107107
}

0 commit comments

Comments
 (0)