Skip to content

Commit 71c0f7e

Browse files
committed
controllers/krate/downloads: Add versions include mode
1 parent 15133c4 commit 71c0f7e

File tree

4 files changed

+129
-9
lines changed

4 files changed

+129
-9
lines changed

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)