Skip to content

Commit b214731

Browse files
authored
trustpub: Add cursor-based pagination to GitHub configs list endpoint (#12236)
Add cursor-based pagination to the `/api/v1/trusted_publishing/github_configs` endpoint using the `id` field as the cursor. Pagination is always enabled with a default of 10 items per page (following the codebase standard). Key changes: - Add `ListResponseMeta` with `total` and `next_page` fields - Implement seek-based pagination ordered by `id` ascending - Support `?per_page=N` (max 100) and `?seek=<encoded>` query parameters - Optimize to avoid COUNT query when first page fits all results - Add comprehensive pagination test covering multiple pages The implementation follows existing pagination patterns from the versions endpoint, using the `seek!()` macro for type-safe cursor serialization.
1 parent 68a2979 commit b214731

14 files changed

+461
-14
lines changed

src/controllers/trustpub/github_configs/json.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,18 @@ pub struct CreateResponse {
1818
#[derive(Debug, Serialize, utoipa::ToSchema)]
1919
pub struct ListResponse {
2020
pub github_configs: Vec<GitHubConfig>,
21+
22+
#[schema(inline)]
23+
pub meta: ListResponseMeta,
24+
}
25+
26+
#[derive(Debug, Serialize, utoipa::ToSchema)]
27+
pub struct ListResponseMeta {
28+
/// The total number of GitHub configs belonging to the crate.
29+
#[schema(example = 42)]
30+
pub total: i64,
31+
32+
/// Query string to the next page of results, if any.
33+
#[schema(example = "?seek=abc123")]
34+
pub next_page: Option<String>,
2135
}

src/controllers/trustpub/github_configs/list/mod.rs

Lines changed: 101 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
use crate::app::AppState;
22
use crate::auth::AuthCheck;
3+
use crate::controllers::helpers::pagination::{
4+
Page, PaginationOptions, PaginationQueryParams, encode_seek,
5+
};
36
use crate::controllers::krate::load_crate;
4-
use crate::controllers::trustpub::github_configs::json::{self, ListResponse};
7+
use crate::controllers::trustpub::github_configs::json::{self, ListResponse, ListResponseMeta};
8+
use crate::util::RequestUtils;
59
use crate::util::errors::{AppResult, bad_request};
610
use axum::Json;
711
use axum::extract::{FromRequestParts, Query};
@@ -13,6 +17,7 @@ use diesel::dsl::{exists, select};
1317
use diesel::prelude::*;
1418
use diesel_async::RunQueryDsl;
1519
use http::request::Parts;
20+
use indexmap::IndexMap;
1621
use serde::Deserialize;
1722

1823
#[derive(Debug, Deserialize, FromRequestParts, utoipa::IntoParams)]
@@ -28,7 +33,7 @@ pub struct ListQueryParams {
2833
#[utoipa::path(
2934
get,
3035
path = "/api/v1/trusted_publishing/github_configs",
31-
params(ListQueryParams),
36+
params(ListQueryParams, PaginationQueryParams),
3237
security(("cookie" = []), ("api_token" = [])),
3338
tag = "trusted_publishing",
3439
responses((status = 200, description = "Successful Response", body = inline(ListResponse))),
@@ -64,10 +69,13 @@ pub async fn list_trustpub_github_configs(
6469
return Err(bad_request("You are not an owner of this crate"));
6570
}
6671

67-
let configs = GitHubConfig::query()
68-
.filter(trustpub_configs_github::crate_id.eq(krate.id))
69-
.load(&mut conn)
70-
.await?;
72+
let pagination = PaginationOptions::builder()
73+
.enable_seek(true)
74+
.enable_pages(false)
75+
.gather(&parts)?;
76+
77+
let (configs, total, next_page) =
78+
list_configs(&mut conn, krate.id, &pagination, &parts).await?;
7179

7280
let github_configs = configs
7381
.into_iter()
@@ -83,5 +91,91 @@ pub async fn list_trustpub_github_configs(
8391
})
8492
.collect();
8593

86-
Ok(Json(ListResponse { github_configs }))
94+
Ok(Json(ListResponse {
95+
github_configs,
96+
meta: ListResponseMeta { total, next_page },
97+
}))
98+
}
99+
100+
async fn list_configs(
101+
conn: &mut diesel_async::AsyncPgConnection,
102+
crate_id: i32,
103+
options: &PaginationOptions,
104+
req: &Parts,
105+
) -> AppResult<(Vec<GitHubConfig>, i64, Option<String>)> {
106+
use seek::*;
107+
108+
let seek = Seek::Id;
109+
110+
assert!(
111+
!matches!(&options.page, Page::Numeric(_)),
112+
"?page= is not supported"
113+
);
114+
115+
let make_base_query = || {
116+
GitHubConfig::query()
117+
.filter(trustpub_configs_github::crate_id.eq(crate_id))
118+
.into_boxed()
119+
};
120+
121+
let mut query = make_base_query();
122+
query = query.limit(options.per_page);
123+
query = query.order(trustpub_configs_github::id.asc());
124+
125+
if let Some(SeekPayload::Id(Id { id })) = seek.after(&options.page)? {
126+
query = query.filter(trustpub_configs_github::id.gt(id));
127+
}
128+
129+
let data: Vec<GitHubConfig> = query.load(conn).await?;
130+
131+
let next_page = next_seek_params(&data, options, |last| seek.to_payload(last))?
132+
.map(|p| req.query_with_params(p));
133+
134+
// Avoid the count query if we're on the first page and got fewer results than requested
135+
let total =
136+
if matches!(options.page, Page::Unspecified) && data.len() < options.per_page as usize {
137+
data.len() as i64
138+
} else {
139+
make_base_query().count().get_result(conn).await?
140+
};
141+
142+
Ok((data, total, next_page))
143+
}
144+
145+
fn next_seek_params<T, S, F>(
146+
records: &[T],
147+
options: &PaginationOptions,
148+
f: F,
149+
) -> AppResult<Option<IndexMap<String, String>>>
150+
where
151+
F: Fn(&T) -> S,
152+
S: serde::Serialize,
153+
{
154+
if records.len() < options.per_page as usize {
155+
return Ok(None);
156+
}
157+
158+
let seek = f(records.last().unwrap());
159+
let mut opts = IndexMap::new();
160+
opts.insert("seek".into(), encode_seek(seek)?);
161+
Ok(Some(opts))
162+
}
163+
164+
mod seek {
165+
use crate::controllers::helpers::pagination::seek;
166+
use crates_io_database::models::trustpub::GitHubConfig;
167+
168+
seek!(
169+
pub enum Seek {
170+
Id { id: i32 },
171+
}
172+
);
173+
174+
impl Seek {
175+
pub(crate) fn to_payload(&self, record: &GitHubConfig) -> SeekPayload {
176+
match *self {
177+
Seek::Id => SeekPayload::Id(Id { id: record.id }),
178+
}
179+
}
180+
}
87181
}

src/tests/routes/trustpub/github_configs/list.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,3 +257,67 @@ async fn test_token_auth_with_wildcard_crate_scope() -> anyhow::Result<()> {
257257

258258
Ok(())
259259
}
260+
261+
#[tokio::test(flavor = "multi_thread")]
262+
async fn test_pagination() -> anyhow::Result<()> {
263+
let (app, _, cookie_client) = TestApp::full().with_user().await;
264+
let mut conn = app.db_conn().await;
265+
266+
let owner_id = cookie_client.as_model().id;
267+
let krate = CrateBuilder::new("foo", owner_id).build(&mut conn).await?;
268+
269+
// Create 15 configs
270+
for i in 0..15 {
271+
create_config(&mut conn, krate.id, &format!("repo-{i}")).await?;
272+
}
273+
274+
// Request first page with per_page=5
275+
let response = cookie_client
276+
.get_with_query::<()>(URL, "crate=foo&per_page=5")
277+
.await;
278+
assert_snapshot!(response.status(), @"200 OK");
279+
let json = response.json();
280+
assert_json_snapshot!(json, {
281+
".github_configs[].created_at" => "[datetime]",
282+
});
283+
284+
// Extract the next_page URL and make a second request
285+
let next_page = json["meta"]["next_page"]
286+
.as_str()
287+
.expect("next_page should be present");
288+
let next_url = format!("{}{}", URL, next_page);
289+
let response = cookie_client.get::<()>(&next_url).await;
290+
assert_snapshot!(response.status(), @"200 OK");
291+
let json = response.json();
292+
assert_json_snapshot!(json, {
293+
".github_configs[].created_at" => "[datetime]",
294+
});
295+
296+
// Third page (last page with data)
297+
let next_page = json["meta"]["next_page"]
298+
.as_str()
299+
.expect("next_page should be present");
300+
let next_url = format!("{}{}", URL, next_page);
301+
let response = cookie_client.get::<()>(&next_url).await;
302+
assert_snapshot!(response.status(), @"200 OK");
303+
let json = response.json();
304+
assert_json_snapshot!(json, {
305+
".github_configs[].created_at" => "[datetime]",
306+
});
307+
308+
// The third page has exactly 5 items, so next_page will be present
309+
// (cursor-based pagination is conservative about indicating more pages)
310+
// Following it should give us an empty fourth page
311+
let next_page = json["meta"]["next_page"]
312+
.as_str()
313+
.expect("next_page should be present on third page");
314+
let next_url = format!("{}{}", URL, next_page);
315+
let response = cookie_client.get::<()>(&next_url).await;
316+
assert_snapshot!(response.status(), @"200 OK");
317+
let json = response.json();
318+
assert_json_snapshot!(json, {
319+
".github_configs[].created_at" => "[datetime]",
320+
});
321+
322+
Ok(())
323+
}

src/tests/routes/trustpub/github_configs/snapshots/integration__routes__trustpub__github_configs__list__crate_with_no_configs-2.snap

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,9 @@ source: src/tests/routes/trustpub/github_configs/list.rs
33
expression: response.json()
44
---
55
{
6-
"github_configs": []
6+
"github_configs": [],
7+
"meta": {
8+
"next_page": null,
9+
"total": 0
10+
}
711
}

src/tests/routes/trustpub/github_configs/snapshots/integration__routes__trustpub__github_configs__list__happy_path-2.snap

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,9 @@ expression: response.json()
2424
"repository_owner_id": 42,
2525
"workflow_filename": "publish.yml"
2626
}
27-
]
27+
],
28+
"meta": {
29+
"next_page": null,
30+
"total": 2
31+
}
2832
}

src/tests/routes/trustpub/github_configs/snapshots/integration__routes__trustpub__github_configs__list__happy_path-4.snap

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,9 @@ expression: response.json()
1414
"repository_owner_id": 42,
1515
"workflow_filename": "publish.yml"
1616
}
17-
]
17+
],
18+
"meta": {
19+
"next_page": null,
20+
"total": 1
21+
}
1822
}

src/tests/routes/trustpub/github_configs/snapshots/integration__routes__trustpub__github_configs__list__legacy_token_auth-2.snap

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,9 @@ expression: response.json()
1414
"repository_owner_id": 42,
1515
"workflow_filename": "publish.yml"
1616
}
17-
]
17+
],
18+
"meta": {
19+
"next_page": null,
20+
"total": 1
21+
}
1822
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
source: src/tests/routes/trustpub/github_configs/list.rs
3+
expression: json
4+
---
5+
{
6+
"github_configs": [
7+
{
8+
"crate": "foo",
9+
"created_at": "[datetime]",
10+
"environment": null,
11+
"id": 1,
12+
"repository_name": "repo-0",
13+
"repository_owner": "rust-lang",
14+
"repository_owner_id": 42,
15+
"workflow_filename": "publish.yml"
16+
},
17+
{
18+
"crate": "foo",
19+
"created_at": "[datetime]",
20+
"environment": null,
21+
"id": 2,
22+
"repository_name": "repo-1",
23+
"repository_owner": "rust-lang",
24+
"repository_owner_id": 42,
25+
"workflow_filename": "publish.yml"
26+
},
27+
{
28+
"crate": "foo",
29+
"created_at": "[datetime]",
30+
"environment": null,
31+
"id": 3,
32+
"repository_name": "repo-2",
33+
"repository_owner": "rust-lang",
34+
"repository_owner_id": 42,
35+
"workflow_filename": "publish.yml"
36+
},
37+
{
38+
"crate": "foo",
39+
"created_at": "[datetime]",
40+
"environment": null,
41+
"id": 4,
42+
"repository_name": "repo-3",
43+
"repository_owner": "rust-lang",
44+
"repository_owner_id": 42,
45+
"workflow_filename": "publish.yml"
46+
},
47+
{
48+
"crate": "foo",
49+
"created_at": "[datetime]",
50+
"environment": null,
51+
"id": 5,
52+
"repository_name": "repo-4",
53+
"repository_owner": "rust-lang",
54+
"repository_owner_id": 42,
55+
"workflow_filename": "publish.yml"
56+
}
57+
],
58+
"meta": {
59+
"next_page": "?crate=foo&per_page=5&seek=NQ",
60+
"total": 15
61+
}
62+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
source: src/tests/routes/trustpub/github_configs/list.rs
3+
expression: json
4+
---
5+
{
6+
"github_configs": [
7+
{
8+
"crate": "foo",
9+
"created_at": "[datetime]",
10+
"environment": null,
11+
"id": 6,
12+
"repository_name": "repo-5",
13+
"repository_owner": "rust-lang",
14+
"repository_owner_id": 42,
15+
"workflow_filename": "publish.yml"
16+
},
17+
{
18+
"crate": "foo",
19+
"created_at": "[datetime]",
20+
"environment": null,
21+
"id": 7,
22+
"repository_name": "repo-6",
23+
"repository_owner": "rust-lang",
24+
"repository_owner_id": 42,
25+
"workflow_filename": "publish.yml"
26+
},
27+
{
28+
"crate": "foo",
29+
"created_at": "[datetime]",
30+
"environment": null,
31+
"id": 8,
32+
"repository_name": "repo-7",
33+
"repository_owner": "rust-lang",
34+
"repository_owner_id": 42,
35+
"workflow_filename": "publish.yml"
36+
},
37+
{
38+
"crate": "foo",
39+
"created_at": "[datetime]",
40+
"environment": null,
41+
"id": 9,
42+
"repository_name": "repo-8",
43+
"repository_owner": "rust-lang",
44+
"repository_owner_id": 42,
45+
"workflow_filename": "publish.yml"
46+
},
47+
{
48+
"crate": "foo",
49+
"created_at": "[datetime]",
50+
"environment": null,
51+
"id": 10,
52+
"repository_name": "repo-9",
53+
"repository_owner": "rust-lang",
54+
"repository_owner_id": 42,
55+
"workflow_filename": "publish.yml"
56+
}
57+
],
58+
"meta": {
59+
"next_page": "?crate=foo&per_page=5&seek=MTA",
60+
"total": 15
61+
}
62+
}

0 commit comments

Comments
 (0)