Skip to content

Commit 5b5fd9d

Browse files
authored
Merge pull request #10529 from eth3lbert/download-include-versions
Add `?include=versions` support for `GET /api/v1/crates/{name}/downloads` endpoint
2 parents dca6e84 + 6124efd commit 5b5fd9d

File tree

10 files changed

+228
-56
lines changed

10 files changed

+228
-56
lines changed

app/adapters/crate.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ export default class CrateAdapter extends ApplicationAdapter {
99
return super.findRecord(store, type, id, setDefaultInclude(snapshot));
1010
}
1111

12+
findHasMany(store, snapshot, url, relationship) {
13+
if (relationship.key === 'version_downloads') {
14+
// This ensures that related versions are included so we don't have to wait for versions
15+
// request to finish for `belongsTo` relationships.
16+
url += '?include=versions';
17+
}
18+
return super.findHasMany(store, snapshot, url, relationship);
19+
}
20+
1221
queryRecord(store, type, query, adapterOptions) {
1322
return super.queryRecord(store, type, setDefaultInclude(query), adapterOptions);
1423
}

app/routes/crate/version.js

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,6 @@ export default class VersionRoute extends Route {
1515
async model(params, transition) {
1616
let crate = this.modelFor('crate');
1717

18-
// TODO: Resolved version without waiting for versions to be resolved
19-
// The main blocker for this right now is that we have a "belongsTo" relationship between
20-
// `version-download` and `version` in sync mode. This requires us to wait for `versions` to
21-
// exist before `version-download` can be created.
22-
try {
23-
await crate.loadVersionsTask.perform();
24-
} catch (error) {
25-
let title = `${crate.name}: Failed to load version data`;
26-
return this.router.replaceWith('catch-all', { transition, error, title, tryAgain: true });
27-
}
28-
2918
let version;
3019
let requestedVersion = params.version_num;
3120
let num = requestedVersion || crate.default_version;

e2e/acceptance/crate.spec.ts

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -111,22 +111,6 @@ test.describe('Acceptance | crate page', { tag: '@acceptance' }, () => {
111111
await expect(page.locator('[data-test-try-again]')).toHaveCount(0);
112112
});
113113

114-
test('other versions loading error shows an error message', async ({ page, msw }) => {
115-
let crate = msw.db.crate.create({ name: 'nanomsg' });
116-
msw.db.version.create({ crate, num: '0.6.0' });
117-
msw.db.version.create({ crate, num: '0.6.1' });
118-
119-
await msw.worker.use(http.get('/api/v1/crates/:crate_name/versions', () => HttpResponse.json({}, { status: 500 })));
120-
121-
await page.goto('/');
122-
await page.click('[data-test-just-updated] [data-test-crate-link="0"]');
123-
await expect(page).toHaveURL('/crates/nanomsg/0.6.0');
124-
await expect(page.locator('[data-test-404-page]')).toBeVisible();
125-
await expect(page.locator('[data-test-title]')).toHaveText('nanomsg: Failed to load version data');
126-
await expect(page.locator('[data-test-go-back]')).toHaveCount(0);
127-
await expect(page.locator('[data-test-try-again]')).toBeVisible();
128-
});
129-
130114
test('works for non-canonical names', async ({ page, msw }) => {
131115
let crate = msw.db.crate.create({ name: 'foo-bar' });
132116
msw.db.version.create({ crate });
Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,29 @@
11
import { http, HttpResponse } from 'msw';
22

33
import { db } from '../../index.js';
4+
import { serializeVersion } from '../../serializers/version.js';
45
import { notFound } from '../../utils/handlers.js';
56

6-
export default http.get('/api/v1/crates/:name/downloads', async ({ params }) => {
7+
export default http.get('/api/v1/crates/:name/downloads', async ({ request, params }) => {
78
let crate = db.crate.findFirst({ where: { name: { equals: params.name } } });
89
if (!crate) return notFound();
910

1011
let downloads = db.versionDownload.findMany({ where: { version: { crate: { id: { equals: crate.id } } } } });
11-
12-
return HttpResponse.json({
12+
let resp = {
1313
version_downloads: downloads.map(download => ({
1414
date: download.date,
1515
downloads: download.downloads,
1616
version: download.version.id,
1717
})),
1818
meta: { extra_downloads: crate._extra_downloads },
19-
});
19+
};
20+
21+
let url = new URL(request.url);
22+
let include = url.searchParams.get('include') ?? '';
23+
let includes = include ? include.split(',') : [];
24+
if (includes.includes('versions')) {
25+
let versions = [...new Set(downloads.map(it => it.version))];
26+
resp.versions = versions.map(it => serializeVersion(it));
27+
}
28+
return HttpResponse.json(resp);
2029
});

packages/crates-io-msw/handlers/crates/downloads.test.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,80 @@ test('returns a list of version downloads belonging to the specified crate versi
5353
},
5454
});
5555
});
56+
57+
test('includes related versions', async function () {
58+
let crate = db.crate.create({ name: 'rand' });
59+
let versions = Array.from({ length: 2 }, () => db.version.create({ crate }));
60+
db.versionDownload.create({ version: versions[0], date: '2020-01-13' });
61+
db.versionDownload.create({ version: versions[1], date: '2020-01-14' });
62+
db.versionDownload.create({ version: versions[1], date: '2020-01-15' });
63+
64+
let response = await fetch('/api/v1/crates/rand/downloads?include=versions');
65+
assert.strictEqual(response.status, 200);
66+
assert.deepEqual(await response.json(), {
67+
version_downloads: [
68+
{
69+
date: '2020-01-13',
70+
downloads: 7035,
71+
version: 1,
72+
},
73+
{
74+
date: '2020-01-14',
75+
downloads: 14_070,
76+
version: 2,
77+
},
78+
{
79+
date: '2020-01-15',
80+
downloads: 21_105,
81+
version: 2,
82+
},
83+
],
84+
versions: [
85+
{
86+
crate: 'rand',
87+
crate_size: 162_963,
88+
created_at: '2010-06-16T21:30:45Z',
89+
dl_path: '/api/v1/crates/rand/1.0.0/download',
90+
downloads: 3702,
91+
features: {},
92+
id: 1,
93+
license: 'MIT',
94+
links: {
95+
dependencies: '/api/v1/crates/rand/1.0.0/dependencies',
96+
version_downloads: '/api/v1/crates/rand/1.0.0/downloads',
97+
},
98+
num: '1.0.0',
99+
published_by: null,
100+
readme_path: '/api/v1/crates/rand/1.0.0/readme',
101+
rust_version: null,
102+
updated_at: '2017-02-24T12:34:56Z',
103+
yank_message: null,
104+
yanked: false,
105+
},
106+
{
107+
crate: 'rand',
108+
crate_size: 325_926,
109+
created_at: '2010-06-16T21:30:45Z',
110+
dl_path: '/api/v1/crates/rand/1.0.1/download',
111+
downloads: 7404,
112+
features: {},
113+
id: 2,
114+
license: 'Apache-2.0',
115+
links: {
116+
dependencies: '/api/v1/crates/rand/1.0.1/dependencies',
117+
version_downloads: '/api/v1/crates/rand/1.0.1/downloads',
118+
},
119+
num: '1.0.1',
120+
published_by: null,
121+
readme_path: '/api/v1/crates/rand/1.0.1/readme',
122+
rust_version: null,
123+
updated_at: '2017-02-24T12:34:56Z',
124+
yank_message: null,
125+
yanked: false,
126+
},
127+
],
128+
meta: {
129+
extra_downloads: [],
130+
},
131+
});
132+
});

src/controllers/krate/downloads.rs

Lines changed: 118 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,36 @@
66
use crate::app::AppState;
77
use crate::controllers::krate::CratePath;
88
use crate::models::download::Version;
9-
use crate::models::VersionDownload;
10-
use crate::schema::{version_downloads, versions};
11-
use crate::util::errors::AppResult;
12-
use crate::views::EncodableVersionDownload;
9+
use crate::models::{User, Version as FullVersion, VersionDownload, VersionOwnerAction};
10+
use crate::schema::{version_downloads, version_owner_actions, versions};
11+
use crate::util::errors::{bad_request, AppResult, BoxedAppError};
12+
use crate::views::{EncodableVersion, EncodableVersionDownload};
13+
use axum::extract::FromRequestParts;
14+
use axum_extra::extract::Query;
1315
use axum_extra::json;
1416
use axum_extra::response::ErasedJson;
17+
use crates_io_database::schema::users;
1518
use crates_io_diesel_helpers::to_char;
1619
use diesel::prelude::*;
17-
use diesel_async::RunQueryDsl;
20+
use diesel_async::{AsyncPgConnection, RunQueryDsl};
21+
use futures_util::future::BoxFuture;
1822
use futures_util::FutureExt;
1923
use std::cmp;
24+
use std::str::FromStr;
25+
26+
#[derive(Debug, Deserialize, FromRequestParts, utoipa::IntoParams)]
27+
#[from_request(via(Query))]
28+
#[into_params(parameter_in = Query)]
29+
pub struct DownloadsQueryParams {
30+
/// Additional data to include in the response.
31+
///
32+
/// Valid values: `versions`.
33+
///
34+
/// Defaults to no additional data.
35+
///
36+
/// This parameter expects a comma-separated list of values.
37+
include: Option<String>,
38+
}
2039

2140
/// Get the download counts for a crate.
2241
///
@@ -25,12 +44,16 @@ use std::cmp;
2544
#[utoipa::path(
2645
get,
2746
path = "/api/v1/crates/{name}/downloads",
28-
params(CratePath),
47+
params(CratePath, DownloadsQueryParams),
2948
tag = "crates",
3049
responses((status = 200, description = "Successful Response")),
3150
)]
3251

33-
pub async fn get_crate_downloads(state: AppState, path: CratePath) -> AppResult<ErasedJson> {
52+
pub async fn get_crate_downloads(
53+
state: AppState,
54+
path: CratePath,
55+
params: DownloadsQueryParams,
56+
) -> AppResult<ErasedJson> {
3457
let mut conn = state.db_read().await?;
3558

3659
use diesel::dsl::*;
@@ -47,8 +70,15 @@ pub async fn get_crate_downloads(state: AppState, path: CratePath) -> AppResult<
4770
versions.sort_unstable_by(|a, b| b.num.cmp(&a.num));
4871
let (latest_five, rest) = versions.split_at(cmp::min(5, versions.len()));
4972

73+
let include = params
74+
.include
75+
.as_ref()
76+
.map(|mode| ShowIncludeMode::from_str(mode))
77+
.transpose()?
78+
.unwrap_or_default();
79+
5080
let sum_downloads = sql::<BigInt>("SUM(version_downloads.downloads)");
51-
let (downloads, extra) = tokio::try_join!(
81+
let (downloads, extra, versions_and_publishers, actions) = tokio::try_join!(
5282
VersionDownload::belonging_to(latest_five)
5383
.filter(version_downloads::date.gt(date(now - 90.days())))
5484
.order((
@@ -67,6 +97,8 @@ pub async fn get_crate_downloads(state: AppState, path: CratePath) -> AppResult<
6797
.order(version_downloads::date.asc())
6898
.load::<ExtraDownload>(&mut conn)
6999
.boxed(),
100+
load_versions_and_publishers(&mut conn, latest_five, include.versions),
101+
load_actions(&mut conn, latest_five, include.versions),
70102
)?;
71103

72104
let downloads = downloads
@@ -80,10 +112,88 @@ pub async fn get_crate_downloads(state: AppState, path: CratePath) -> AppResult<
80112
downloads: i64,
81113
}
82114

115+
if include.versions {
116+
let versions_and_publishers = versions_and_publishers.grouped_by(latest_five);
117+
let actions = actions.grouped_by(latest_five);
118+
let versions = versions_and_publishers
119+
.into_iter()
120+
.zip(actions)
121+
.filter_map(|(vp, actions)| {
122+
vp.into_iter().next().map(|(version, publisher)| {
123+
EncodableVersion::from(version, &path.name, publisher, actions)
124+
})
125+
})
126+
.collect::<Vec<_>>();
127+
128+
return Ok(json!({
129+
"version_downloads": downloads,
130+
"versions": versions,
131+
"meta": {
132+
"extra_downloads": extra,
133+
},
134+
}));
135+
}
136+
83137
Ok(json!({
84138
"version_downloads": downloads,
85139
"meta": {
86140
"extra_downloads": extra,
87141
},
88142
}))
89143
}
144+
145+
type VersionsAndPublishers = (FullVersion, Option<User>);
146+
fn load_versions_and_publishers<'a>(
147+
conn: &mut AsyncPgConnection,
148+
versions: &'a [Version],
149+
includes: bool,
150+
) -> BoxFuture<'a, QueryResult<Vec<VersionsAndPublishers>>> {
151+
if !includes {
152+
return futures_util::future::always_ready(|| Ok(vec![])).boxed();
153+
}
154+
FullVersion::belonging_to(versions)
155+
.left_outer_join(users::table)
156+
.select(VersionsAndPublishers::as_select())
157+
.load(conn)
158+
.boxed()
159+
}
160+
161+
fn load_actions<'a>(
162+
conn: &mut AsyncPgConnection,
163+
versions: &'a [Version],
164+
includes: bool,
165+
) -> BoxFuture<'a, QueryResult<Vec<(VersionOwnerAction, User)>>> {
166+
if !includes {
167+
return futures_util::future::always_ready(|| Ok(vec![])).boxed();
168+
}
169+
VersionOwnerAction::belonging_to(versions)
170+
.inner_join(users::table)
171+
.order(version_owner_actions::id)
172+
.load(conn)
173+
.boxed()
174+
}
175+
176+
#[derive(Debug, Default)]
177+
struct ShowIncludeMode {
178+
versions: bool,
179+
}
180+
181+
impl ShowIncludeMode {
182+
const INVALID_COMPONENT: &'static str = "invalid component for ?include= (expected 'versions')";
183+
}
184+
185+
impl FromStr for ShowIncludeMode {
186+
type Err = BoxedAppError;
187+
188+
fn from_str(s: &str) -> Result<Self, Self::Err> {
189+
let mut mode = Self { versions: false };
190+
for component in s.split(',') {
191+
match component {
192+
"" => {}
193+
"versions" => mode.versions = true,
194+
_ => return Err(bad_request(Self::INVALID_COMPONENT)),
195+
}
196+
}
197+
Ok(mode)
198+
}
199+
}

src/models/action.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ impl From<VersionAction> for String {
4141
belongs_to(Version),
4242
belongs_to(User, foreign_key = user_id),
4343
belongs_to(ApiToken, foreign_key = api_token_id),
44+
belongs_to(crate::models::download::Version, foreign_key = version_id),
4445
)]
4546
pub struct VersionOwnerAction {
4647
pub id: i32,

src/models/version.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use crate::schema::*;
1313

1414
// Queryable has a custom implementation below
1515
#[derive(Clone, Identifiable, Associations, Debug, Queryable, Selectable)]
16-
#[diesel(belongs_to(Crate))]
16+
#[diesel(belongs_to(Crate), belongs_to(crate::models::download::Version, foreign_key=id))]
1717
pub struct Version {
1818
pub id: i32,
1919
pub crate_id: i32,

src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,15 @@ expression: response.json()
564564
"schema": {
565565
"type": "string"
566566
}
567+
},
568+
{
569+
"description": "Additional data to include in the response.\n\nValid values: `versions`.\n\nDefaults to no additional data.\n\nThis parameter expects a comma-separated list of values.",
570+
"in": "query",
571+
"name": "include",
572+
"required": false,
573+
"schema": {
574+
"type": "string"
575+
}
567576
}
568577
],
569578
"responses": {

0 commit comments

Comments
 (0)