Skip to content

Commit 3eb7339

Browse files
authored
Merge pull request #10314 from eth3lbert/versions-query-ids
Add multiple `nums[]` parameters support for `GET /api/v1/crates/:name/versions`
2 parents fbd455e + 285e483 commit 3eb7339

File tree

5 files changed

+132
-16
lines changed

5 files changed

+132
-16
lines changed

mirage/route-handlers/crates.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,12 @@ export function register(server) {
153153
let crate = schema.crates.findBy({ name });
154154
if (!crate) return notFound();
155155

156-
let versions = crate.versions.sort((a, b) => compareIsoDates(b.created_at, a.created_at));
156+
let versions = crate.versions;
157+
let { nums } = request.queryParams;
158+
if (nums) {
159+
versions = versions.filter(version => nums.includes(version.num));
160+
}
161+
versions = versions.sort((a, b) => compareIsoDates(b.created_at, a.created_at));
157162
let total = versions.length;
158163
let include = request.queryParams?.include ?? '';
159164
let release_tracks = include.split(',').includes('release_tracks') && releaseTracks(crate.versions);

src/controllers/krate/versions.rs

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Endpoint for versions of a crate
22
3-
use axum::extract::{FromRequestParts, Query};
3+
use axum::extract::FromRequestParts;
4+
use axum_extra::extract::Query;
45
use axum_extra::json;
56
use axum_extra::response::ErasedJson;
67
use diesel::dsl::not;
@@ -19,6 +20,7 @@ use crate::controllers::krate::CratePath;
1920
use crate::models::{User, Version, VersionOwnerAction};
2021
use crate::schema::{users, versions};
2122
use crate::util::errors::{bad_request, AppResult, BoxedAppError};
23+
use crate::util::string_excl_null::StringExclNull;
2224
use crate::util::RequestUtils;
2325
use crate::views::EncodableVersion;
2426

@@ -41,6 +43,11 @@ pub struct ListQueryParams {
4143
///
4244
/// Defaults to `semver`.
4345
sort: Option<String>,
46+
47+
/// If set, only versions with the specified semver strings are returned.
48+
#[serde(rename = "nums[]", default)]
49+
#[param(inline)]
50+
nums: Vec<StringExclNull>,
4451
}
4552

4653
impl ListQueryParams {
@@ -123,11 +130,20 @@ async fn list_by_date(
123130
) -> AppResult<PaginatedVersionsAndPublishers> {
124131
use seek::*;
125132

126-
let mut query = versions::table
127-
.filter(versions::crate_id.eq(crate_id))
128-
.left_outer_join(users::table)
129-
.select(<(Version, Option<User>)>::as_select())
130-
.into_boxed();
133+
let make_base_query = || {
134+
let mut query = versions::table
135+
.filter(versions::crate_id.eq(crate_id))
136+
.left_outer_join(users::table)
137+
.select(<(Version, Option<User>)>::as_select())
138+
.into_boxed();
139+
140+
if !params.nums.is_empty() {
141+
query = query.filter(versions::num.eq_any(params.nums.iter().map(|s| s.as_str())));
142+
}
143+
query
144+
};
145+
146+
let mut query = make_base_query();
131147

132148
if let Some(options) = options {
133149
assert!(
@@ -192,11 +208,7 @@ async fn list_by_date(
192208
// Since the total count is retrieved through an additional query, to maintain consistency
193209
// with other pagination methods, we only make a count query while data is not empty.
194210
let total = if !data.is_empty() {
195-
versions::table
196-
.filter(versions::crate_id.eq(crate_id))
197-
.count()
198-
.get_result(conn)
199-
.await?
211+
make_base_query().count().get_result(conn).await?
200212
} else {
201213
0
202214
};
@@ -229,6 +241,14 @@ async fn list_by_semver(
229241
use seek::*;
230242

231243
let include = params.include()?;
244+
let mut query = versions::table
245+
.filter(versions::crate_id.eq(crate_id))
246+
.into_boxed();
247+
248+
if !params.nums.is_empty() {
249+
query = query.filter(versions::num.eq_any(params.nums.iter().map(|s| s.as_str())));
250+
}
251+
232252
let (data, total, release_tracks) = if let Some(options) = options {
233253
// Since versions will only increase in the future and both sorting and pagination need to
234254
// happen on the app server, implementing it with fetching only the data needed for sorting
@@ -239,8 +259,7 @@ async fn list_by_semver(
239259
// while id values are significantly smaller.
240260

241261
let mut sorted_versions = IndexMap::new();
242-
versions::table
243-
.filter(versions::crate_id.eq(crate_id))
262+
query
244263
.select((versions::id, versions::num, versions::yanked))
245264
.load_stream::<(i32, String, bool)>(conn)
246265
.await?
@@ -313,8 +332,7 @@ async fn list_by_semver(
313332
}
314333
} else {
315334
let mut data = IndexMap::new();
316-
versions::table
317-
.filter(versions::crate_id.eq(crate_id))
335+
query
318336
.left_outer_join(users::table)
319337
.select(<(Version, Option<User>)>::as_select())
320338
.load_stream::<(Version, Option<User>)>(conn)

src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -855,6 +855,19 @@ expression: response.json()
855855
"type": "string"
856856
}
857857
},
858+
{
859+
"description": "If set, only versions with the specified semver strings are returned.",
860+
"in": "query",
861+
"name": "nums[]",
862+
"required": false,
863+
"schema": {
864+
"items": {
865+
"description": "A string that does not contain null bytes (`\\0`).",
866+
"type": "string"
867+
},
868+
"type": "array"
869+
}
870+
},
858871
{
859872
"description": "The page number to request.\n\nThis parameter is mutually exclusive with `seek` and not supported for\nall requests.",
860873
"in": "query",

src/tests/routes/crates/versions/list.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,71 @@ async fn test_sorting() -> anyhow::Result<()> {
153153
Ok(())
154154
}
155155

156+
#[tokio::test(flavor = "multi_thread")]
157+
async fn multiple_ids() -> anyhow::Result<()> {
158+
let (app, anon, user) = TestApp::init().with_user().await;
159+
let mut conn = app.db_conn().await;
160+
let user = user.as_model();
161+
let mut builder = CrateBuilder::new("foo_versions", user.id);
162+
163+
let versions = [
164+
"2.0.0",
165+
"2.0.0-alpha",
166+
"1.0.0-alpha.beta",
167+
"1.0.0-beta.11",
168+
"1.0.0-beta",
169+
"1.0.0",
170+
"0.5.1",
171+
"0.5.0",
172+
];
173+
for version in versions {
174+
builder = builder.version(version);
175+
}
176+
builder.expect_build(&mut conn).await;
177+
178+
// Sort by semver without pagination
179+
let url = "/api/v1/crates/foo_versions/versions";
180+
let query = [
181+
"nums[]=0.5.1",
182+
"nums[]=1.0.0-alpha.beta",
183+
"nums[]=1.0.0-beta",
184+
"nums[]=2.0.0",
185+
"nums[]=unknown",
186+
]
187+
.join("&");
188+
let json: VersionList = anon.get_with_query(url, &query).await.good();
189+
let expects = ["2.0.0", "1.0.0-beta", "1.0.0-alpha.beta", "0.5.1"];
190+
assert_eq!(nums(&json.versions), expects);
191+
assert!(json.meta.next_page.is_none());
192+
assert_eq!(json.meta.total as usize, expects.len());
193+
assert_eq!(json.meta.release_tracks, None);
194+
195+
let (resp, calls) = page_with_seek(&anon, &format!("{url}?{query}")).await;
196+
for (json, expect) in resp.iter().zip(expects) {
197+
assert_eq!(json.versions[0].num, expect);
198+
assert_eq!(json.meta.total as usize, expects.len());
199+
}
200+
assert_eq!(calls as usize, expects.len() + 1);
201+
202+
// Sort by date without pagination
203+
let query = format!("{query}&sort=date");
204+
let json: VersionList = anon.get_with_query(url, &query).await.good();
205+
let expects = ["0.5.1", "1.0.0-beta", "1.0.0-alpha.beta", "2.0.0"];
206+
assert_eq!(nums(&json.versions), expects);
207+
assert!(json.meta.next_page.is_none());
208+
assert_eq!(json.meta.total as usize, expects.len());
209+
assert_eq!(json.meta.release_tracks, None);
210+
211+
let (resp, calls) = page_with_seek(&anon, &format!("{url}?{query}")).await;
212+
for (json, expect) in resp.iter().zip(expects) {
213+
assert_eq!(json.versions[0].num, expect);
214+
assert_eq!(json.meta.total as usize, expects.len());
215+
}
216+
assert_eq!(calls as usize, expects.len() + 1);
217+
218+
Ok(())
219+
}
220+
156221
#[tokio::test(flavor = "multi_thread")]
157222
async fn test_seek_based_pagination_semver_sorting() -> anyhow::Result<()> {
158223
let (app, anon, user) = TestApp::init().with_user().await;

tests/mirage/crates/versions/list-test.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,21 @@ module('Mirage | GET /api/v1/crates/:name/versions', function (hooks) {
108108
});
109109
});
110110

111+
test('supports multiple `ids[]` parameters', async function (assert) {
112+
let user = this.server.create('user');
113+
let crate = this.server.create('crate', { name: 'rand' });
114+
this.server.create('version', { crate, num: '1.0.0' });
115+
this.server.create('version', { crate, num: '1.1.0', publishedBy: user });
116+
this.server.create('version', { crate, num: '1.2.0', rust_version: '1.69' });
117+
let response = await fetch('/api/v1/crates/rand/versions?nums[]=1.0.0&nums[]=1.2.0');
118+
assert.strictEqual(response.status, 200);
119+
let json = await response.json();
120+
assert.deepEqual(
121+
json.versions.map(v => v.num),
122+
['1.0.0', '1.2.0'],
123+
);
124+
});
125+
111126
test('include `release_tracks` meta', async function (assert) {
112127
let user = this.server.create('user');
113128
let crate = this.server.create('crate', { name: 'rand' });

0 commit comments

Comments
 (0)