Skip to content

Commit a75d8ce

Browse files
authored
Merge pull request #8037 from eth3lbert/versions-pagination
Add basic support for seek-based pagination to versions endpoint
2 parents 8a3989d + 93e7362 commit a75d8ce

File tree

7 files changed

+525
-38
lines changed

7 files changed

+525
-38
lines changed

src/controllers/krate.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ pub mod metadata;
44
pub mod owners;
55
pub mod publish;
66
pub mod search;
7+
pub mod versions;

src/controllers/krate/metadata.rs

Lines changed: 0 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -328,43 +328,6 @@ pub async fn readme(
328328
}
329329
}
330330

331-
/// Handles the `GET /crates/:crate_id/versions` route.
332-
// FIXME: Not sure why this is necessary since /crates/:crate_id returns
333-
// this information already, but ember is definitely requesting it
334-
pub async fn versions(state: AppState, Path(crate_name): Path<String>) -> AppResult<Json<Value>> {
335-
spawn_blocking(move || {
336-
let conn = &mut *state.db_read()?;
337-
338-
let krate: Crate = Crate::by_name(&crate_name)
339-
.first(conn)
340-
.optional()?
341-
.ok_or_else(|| crate_not_found(&crate_name))?;
342-
343-
let mut versions_and_publishers: Vec<(Version, Option<User>)> = krate
344-
.all_versions()
345-
.left_outer_join(users::table)
346-
.select((versions::all_columns, users::all_columns.nullable()))
347-
.load(conn)?;
348-
349-
versions_and_publishers
350-
.sort_by_cached_key(|(version, _)| Reverse(semver::Version::parse(&version.num).ok()));
351-
352-
let versions = versions_and_publishers
353-
.iter()
354-
.map(|(v, _)| v)
355-
.cloned()
356-
.collect::<Vec<_>>();
357-
let versions = versions_and_publishers
358-
.into_iter()
359-
.zip(VersionOwnerAction::for_versions(conn, &versions)?)
360-
.map(|((v, pb), aas)| EncodableVersion::from(v, &crate_name, pb, aas))
361-
.collect::<Vec<_>>();
362-
363-
Ok(Json(json!({ "versions": versions })))
364-
})
365-
.await
366-
}
367-
368331
/// Handles the `GET /crates/:crate_id/reverse_dependencies` route.
369332
pub async fn reverse_dependencies(
370333
app: AppState,

src/controllers/krate/versions.rs

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
//! Endpoint for versions of a crate
2+
3+
use std::cmp::Reverse;
4+
5+
use diesel::connection::DefaultLoadingMode;
6+
use indexmap::IndexMap;
7+
8+
use crate::controllers::frontend_prelude::*;
9+
use crate::controllers::helpers::pagination::{encode_seek, Page, PaginationOptions};
10+
11+
use crate::models::{Crate, CrateVersions, User, Version, VersionOwnerAction};
12+
use crate::schema::{users, versions};
13+
use crate::util::errors::crate_not_found;
14+
use crate::views::EncodableVersion;
15+
16+
/// Handles the `GET /crates/:crate_id/versions` route.
17+
pub async fn versions(
18+
state: AppState,
19+
Path(crate_name): Path<String>,
20+
req: Parts,
21+
) -> AppResult<Json<Value>> {
22+
spawn_blocking(move || {
23+
let conn = &mut *state.db_read()?;
24+
25+
let krate: Crate = Crate::by_name(&crate_name)
26+
.first(conn)
27+
.optional()?
28+
.ok_or_else(|| crate_not_found(&crate_name))?;
29+
30+
let mut pagination = None;
31+
let params = req.query();
32+
// To keep backward compatibility, we paginate only if per_page is provided
33+
if params.get("per_page").is_some() {
34+
pagination = Some(
35+
PaginationOptions::builder()
36+
.enable_seek(true)
37+
.enable_pages(false)
38+
.gather(&req)?,
39+
);
40+
}
41+
42+
// Sort by semver by default
43+
let versions_and_publishers = match params.get("sort").map(|s| s.to_lowercase()).as_deref()
44+
{
45+
Some("date") => list_by_date(&krate, pagination.as_ref(), &req, conn)?,
46+
_ => list_by_semver(&krate, pagination.as_ref(), &req, conn)?,
47+
};
48+
49+
let versions = versions_and_publishers
50+
.data
51+
.iter()
52+
.map(|(v, _)| v)
53+
.cloned()
54+
.collect::<Vec<_>>();
55+
let versions = versions_and_publishers
56+
.data
57+
.into_iter()
58+
.zip(VersionOwnerAction::for_versions(conn, &versions)?)
59+
.map(|((v, pb), aas)| EncodableVersion::from(v, &crate_name, pb, aas))
60+
.collect::<Vec<_>>();
61+
62+
Ok(Json(match pagination {
63+
Some(_) => json!({ "versions": versions, "meta": versions_and_publishers.meta }),
64+
None => json!({ "versions": versions }),
65+
}))
66+
})
67+
.await
68+
}
69+
70+
/// Seek-based pagination of versions by date
71+
///
72+
/// # Panics
73+
///
74+
/// This function will panic if `option` is built with `enable_pages` set to true.
75+
fn list_by_date(
76+
krate: &Crate,
77+
options: Option<&PaginationOptions>,
78+
req: &Parts,
79+
conn: &mut PgConnection,
80+
) -> AppResult<PaginatedVersionsAndPublishers> {
81+
use seek::*;
82+
83+
let mut query = krate
84+
.all_versions()
85+
.left_outer_join(users::table)
86+
.select((versions::all_columns, users::all_columns.nullable()));
87+
88+
if let Some(options) = options {
89+
assert!(
90+
!matches!(&options.page, Page::Numeric(_)),
91+
"?page= is not supported"
92+
);
93+
if let Some(SeekPayload::Date(Date(created_at, id))) = Seek::Date.after(&options.page)? {
94+
query = query.filter(
95+
versions::created_at
96+
.eq(created_at)
97+
.and(versions::id.lt(id))
98+
.or(versions::created_at.lt(created_at)),
99+
)
100+
}
101+
query = query.limit(options.per_page);
102+
}
103+
104+
query = query.order((versions::created_at.desc(), versions::id.desc()));
105+
106+
let data: Vec<(Version, Option<User>)> = query.load(conn)?;
107+
let mut next_page = None;
108+
if let Some(options) = options {
109+
next_page = next_seek_params(&data, options, |last| Seek::Date.to_payload(last))?
110+
.map(|p| req.query_with_params(p));
111+
};
112+
113+
// Since the total count is retrieved through an additional query, to maintain consistency
114+
// with other pagination methods, we only make a count query while data is not empty.
115+
let total = if !data.is_empty() {
116+
krate.all_versions().count().get_result(conn)?
117+
} else {
118+
0
119+
};
120+
121+
Ok(PaginatedVersionsAndPublishers {
122+
data,
123+
meta: ResponseMeta { total, next_page },
124+
})
125+
}
126+
127+
/// Seek-based pagination of versions by semver
128+
///
129+
/// # Panics
130+
///
131+
/// This function will panic if `option` is built with `enable_pages` set to true.
132+
133+
// Unfortunately, Heroku Postgres has no support for the semver PG extension.
134+
// Therefore, we need to perform both sorting and pagination manually on the server.
135+
fn list_by_semver(
136+
krate: &Crate,
137+
options: Option<&PaginationOptions>,
138+
req: &Parts,
139+
conn: &mut PgConnection,
140+
) -> AppResult<PaginatedVersionsAndPublishers> {
141+
use seek::*;
142+
143+
let (data, total) = if let Some(options) = options {
144+
// Since versions will only increase in the future and both sorting and pagination need to
145+
// happen on the app server, implementing it with fetching only the data needed for sorting
146+
// and pagination, then making another query for the data to respond with, would minimize
147+
// payload and memory usage. This way, we can utilize the sorted map and enrich it later
148+
// without sorting twice.
149+
// Sorting by semver but opted for id as the seek key because num can be quite lengthy,
150+
// while id values are significantly smaller.
151+
let mut sorted_versions = IndexMap::new();
152+
for result in krate
153+
.all_versions()
154+
.select((versions::id, versions::num))
155+
.load_iter::<(i32, String), DefaultLoadingMode>(conn)?
156+
{
157+
let (id, num) = result?;
158+
sorted_versions.insert(id, (num, None));
159+
}
160+
sorted_versions.sort_by_cached_key(|_, (num, _)| Reverse(semver::Version::parse(num).ok()));
161+
162+
assert!(
163+
!matches!(&options.page, Page::Numeric(_)),
164+
"?page= is not supported"
165+
);
166+
let mut idx = Some(0);
167+
if let Some(SeekPayload::Semver(Semver(id))) = Seek::Semver.after(&options.page)? {
168+
idx = sorted_versions
169+
.get_index_of(&id)
170+
.filter(|i| i + 1 < sorted_versions.len())
171+
.map(|i| i + 1);
172+
}
173+
if let Some(start) = idx {
174+
let end = (start + options.per_page as usize).min(sorted_versions.len());
175+
let ids = sorted_versions[start..end].keys().collect::<Vec<_>>();
176+
for result in krate
177+
.all_versions()
178+
.left_outer_join(users::table)
179+
.select((versions::all_columns, users::all_columns.nullable()))
180+
.filter(versions::id.eq_any(ids))
181+
.load_iter::<(Version, Option<User>), DefaultLoadingMode>(conn)?
182+
{
183+
let row = result?;
184+
sorted_versions.insert(row.0.id, (row.0.num.to_owned(), Some(row)));
185+
}
186+
187+
let len = sorted_versions.len();
188+
(
189+
sorted_versions
190+
.into_values()
191+
.filter_map(|(_, v)| v)
192+
.collect(),
193+
len,
194+
)
195+
} else {
196+
(vec![], 0)
197+
}
198+
} else {
199+
let mut data: Vec<(Version, Option<User>)> = krate
200+
.all_versions()
201+
.left_outer_join(users::table)
202+
.select((versions::all_columns, users::all_columns.nullable()))
203+
.load(conn)?;
204+
data.sort_by_cached_key(|(version, _)| Reverse(semver::Version::parse(&version.num).ok()));
205+
let total = data.len();
206+
(data, total)
207+
};
208+
209+
let mut next_page = None;
210+
if let Some(options) = options {
211+
next_page = next_seek_params(&data, options, |last| Seek::Semver.to_payload(last))?
212+
.map(|p| req.query_with_params(p))
213+
};
214+
215+
Ok(PaginatedVersionsAndPublishers {
216+
data,
217+
meta: ResponseMeta {
218+
total: total as i64,
219+
next_page,
220+
},
221+
})
222+
}
223+
224+
mod seek {
225+
use crate::controllers::helpers::pagination::seek;
226+
use crate::models::{User, Version};
227+
use chrono::naive::serde::ts_microseconds;
228+
229+
// We might consider refactoring this to use named fields, which would be clearer and more
230+
// flexible. It's also worth noting that we currently encode seek compactly as a Vec, which
231+
// doesn't include field names.
232+
seek! {
233+
pub enum Seek {
234+
Semver(i32)
235+
Date(#[serde(with="ts_microseconds")] chrono::NaiveDateTime, i32)
236+
}
237+
}
238+
239+
impl Seek {
240+
pub(crate) fn to_payload(&self, record: &(Version, Option<User>)) -> SeekPayload {
241+
match *self {
242+
Seek::Semver => SeekPayload::Semver(Semver(record.0.id)),
243+
Seek::Date => SeekPayload::Date(Date(record.0.created_at, record.0.id)),
244+
}
245+
}
246+
}
247+
}
248+
249+
fn next_seek_params<T, S, F>(
250+
records: &[T],
251+
options: &PaginationOptions,
252+
f: F,
253+
) -> AppResult<Option<IndexMap<String, String>>>
254+
where
255+
F: Fn(&T) -> S,
256+
S: serde::Serialize,
257+
{
258+
if matches!(options.page, Page::Numeric(_)) || records.len() < options.per_page as usize {
259+
return Ok(None);
260+
}
261+
262+
let mut opts = IndexMap::new();
263+
match options.page {
264+
Page::Unspecified | Page::Seek(_) => {
265+
let seek = f(records.last().unwrap());
266+
opts.insert("seek".into(), encode_seek(seek)?);
267+
}
268+
Page::Numeric(_) => unreachable!(),
269+
};
270+
Ok(Some(opts))
271+
}
272+
273+
struct PaginatedVersionsAndPublishers {
274+
data: Vec<(Version, Option<User>)>,
275+
meta: ResponseMeta,
276+
}
277+
278+
#[derive(Serialize)]
279+
struct ResponseMeta {
280+
total: i64,
281+
next_page: Option<String>,
282+
}

src/router.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ pub fn build_axum_router(state: AppState) -> Router<()> {
6868
)
6969
.route(
7070
"/api/v1/crates/:crate_id/versions",
71-
get(krate::metadata::versions),
71+
get(krate::versions::versions),
7272
)
7373
.route(
7474
"/api/v1/crates/:crate_id/follow",

0 commit comments

Comments
 (0)