diff --git a/Cargo.toml b/Cargo.toml index 3aa4e20a67..979dfefdcd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -125,7 +125,7 @@ tracing-subscriber = { version = "=0.3.19", features = ["env-filter", "json"] } typomania = { version = "=0.1.2", default-features = false } url = "=2.5.4" unicode-xid = "=0.2.6" -utoipa = "=5.2.0" +utoipa = { version = "=5.2.0", features = ["chrono"] } utoipa-axum = "=0.1.2" [dev-dependencies] diff --git a/src/controllers/category.rs b/src/controllers/category.rs index 9ff2a54932..86063e4a55 100644 --- a/src/controllers/category.rs +++ b/src/controllers/category.rs @@ -3,23 +3,39 @@ use crate::app::AppState; use crate::models::Category; use crate::schema::categories; use crate::util::errors::AppResult; -use crate::util::RequestUtils; use crate::views::{EncodableCategory, EncodableCategoryWithSubcategories}; -use axum::extract::Path; +use axum::extract::{FromRequestParts, Path, Query}; use axum_extra::json; use axum_extra::response::ErasedJson; use diesel::QueryDsl; use diesel_async::RunQueryDsl; use http::request::Parts; +#[derive(Debug, Deserialize, FromRequestParts, utoipa::IntoParams)] +#[from_request(via(Query))] +#[into_params(parameter_in = Query)] +pub struct ListQueryParams { + /// The sort order of the categories. + /// + /// Valid values: `alpha`, and `crates`. + /// + /// Defaults to `alpha`. + sort: Option, +} + /// List all categories. #[utoipa::path( get, path = "/api/v1/categories", + params(ListQueryParams, PaginationQueryParams), tag = "categories", responses((status = 200, description = "Successful Response")), )] -pub async fn list_categories(app: AppState, req: Parts) -> AppResult { +pub async fn list_categories( + app: AppState, + params: ListQueryParams, + req: Parts, +) -> AppResult { // FIXME: There are 69 categories, 47 top level. This isn't going to // grow by an OoM. We need a limit for /summary, but we don't need // to paginate this. @@ -27,8 +43,7 @@ pub async fn list_categories(app: AppState, req: Parts) -> AppResult let mut conn = app.db_read().await?; - let query = req.query(); - let sort = query.get("sort").map_or("alpha", String::as_str); + let sort = params.sort.as_ref().map_or("alpha", String::as_str); let offset = options.offset().unwrap_or_default(); diff --git a/src/controllers/crate_owner_invitation.rs b/src/controllers/crate_owner_invitation.rs index 730485f5b9..9fd2c3a4d1 100644 --- a/src/controllers/crate_owner_invitation.rs +++ b/src/controllers/crate_owner_invitation.rs @@ -1,16 +1,16 @@ use crate::app::AppState; use crate::auth::AuthCheck; use crate::auth::Authentication; -use crate::controllers::helpers::pagination::{Page, PaginationOptions}; +use crate::controllers::helpers::pagination::{Page, PaginationOptions, PaginationQueryParams}; use crate::models::{Crate, CrateOwnerInvitation, Rights, User}; use crate::schema::{crate_owner_invitations, crates, users}; -use crate::util::errors::{bad_request, forbidden, internal, AppResult}; +use crate::util::errors::{bad_request, forbidden, internal, AppResult, BoxedAppError}; use crate::util::RequestUtils; use crate::views::{ EncodableCrateOwnerInvitation, EncodableCrateOwnerInvitationV1, EncodablePublicUser, InvitationResponse, }; -use axum::extract::Path; +use axum::extract::{FromRequestParts, Path, Query}; use axum::Json; use axum_extra::json; use axum_extra::response::ErasedJson; @@ -70,28 +70,38 @@ pub async fn list_crate_owner_invitations_for_user( })) } +#[derive(Debug, Deserialize, FromRequestParts, utoipa::IntoParams)] +#[from_request(via(Query))] +#[into_params(parameter_in = Query)] +pub struct ListQueryParams { + /// Filter crate owner invitations by crate name. + /// + /// Only crate owners can query pending invitations for their crate. + crate_name: Option, + + /// The ID of the user who was invited to be a crate owner. + /// + /// This parameter needs to match the authenticated user's ID. + invitee_id: Option, +} + /// List all crate owner invitations for a crate or user. #[utoipa::path( get, path = "/api/private/crate_owner_invitations", + params(ListQueryParams, PaginationQueryParams), tag = "owners", responses((status = 200, description = "Successful Response")), )] pub async fn list_crate_owner_invitations( app: AppState, + params: ListQueryParams, req: Parts, ) -> AppResult> { let mut conn = app.db_read().await?; let auth = AuthCheck::only_cookie().check(&req, &mut conn).await?; - let filter = if let Some(crate_name) = req.query().get("crate_name") { - ListFilter::CrateName(crate_name.clone()) - } else if let Some(id) = req.query().get("invitee_id").and_then(|i| i.parse().ok()) { - ListFilter::InviteeId(id) - } else { - return Err(bad_request("missing or invalid filter")); - }; - + let filter = params.try_into()?; let list = prepare_list(&app, &req, auth, filter, &mut conn).await?; Ok(Json(list)) } @@ -101,6 +111,22 @@ enum ListFilter { InviteeId(i32), } +impl TryFrom for ListFilter { + type Error = BoxedAppError; + + fn try_from(params: ListQueryParams) -> Result { + let filter = if let Some(crate_name) = params.crate_name { + ListFilter::CrateName(crate_name.clone()) + } else if let Some(id) = params.invitee_id { + ListFilter::InviteeId(id) + } else { + return Err(bad_request("missing or invalid filter")); + }; + + Ok(filter) + } +} + async fn prepare_list( state: &AppState, req: &Parts, diff --git a/src/controllers/helpers/pagination.rs b/src/controllers/helpers/pagination.rs index dfece901e1..83dd681b11 100644 --- a/src/controllers/helpers/pagination.rs +++ b/src/controllers/helpers/pagination.rs @@ -7,6 +7,7 @@ use crate::util::errors::{bad_request, AppResult}; use crate::util::HeaderMapExt; use std::num::NonZeroU32; +use axum::extract::FromRequestParts; use base64::{engine::general_purpose, Engine}; use diesel::pg::Pg; use diesel::prelude::*; @@ -55,7 +56,8 @@ impl PaginationOptions { } } -#[derive(Debug, Deserialize, utoipa::IntoParams)] +#[derive(Debug, Deserialize, FromRequestParts, utoipa::IntoParams)] +#[from_request(via(axum::extract::Query))] #[into_params(parameter_in = Query)] pub struct PaginationQueryParams { /// The page number to request. @@ -63,11 +65,11 @@ pub struct PaginationQueryParams { /// This parameter is mutually exclusive with `seek` and not supported for /// all requests. #[param(value_type = Option, minimum = 1)] - page: Option, + pub page: Option, /// The number of items to request per page. #[param(value_type = Option, minimum = 1)] - per_page: Option, + pub per_page: Option, /// The seek key to request. /// @@ -76,7 +78,7 @@ pub struct PaginationQueryParams { /// /// The seek key can usually be found in the `meta.next_page` field of /// paginated responses. - seek: Option, + pub seek: Option, } pub(crate) struct PaginationOptionsBuilder { diff --git a/src/controllers/krate/metadata.rs b/src/controllers/krate/metadata.rs index 0038b549ca..2b897ef3f8 100644 --- a/src/controllers/krate/metadata.rs +++ b/src/controllers/krate/metadata.rs @@ -12,16 +12,30 @@ use crate::models::{ }; use crate::schema::*; use crate::util::errors::{bad_request, crate_not_found, AppResult, BoxedAppError}; -use crate::util::RequestUtils; use crate::views::{EncodableCategory, EncodableCrate, EncodableKeyword, EncodableVersion}; +use axum::extract::{FromRequestParts, Query}; use axum_extra::json; use axum_extra::response::ErasedJson; use diesel::prelude::*; use diesel_async::RunQueryDsl; -use http::request::Parts; use std::cmp::Reverse; use std::str::FromStr; +#[derive(Debug, Deserialize, FromRequestParts, utoipa::IntoParams)] +#[from_request(via(Query))] +#[into_params(parameter_in = Query)] +pub struct FindQueryParams { + /// Additional data to include in the response. + /// + /// Valid values: `versions`, `keywords`, `categories`, `badges`, + /// `downloads`, or `full`. + /// + /// Defaults to `full` for backwards compatibility. + /// + /// This parameter expects a comma-separated list of values. + include: Option, +} + /// Get crate metadata (for the `new` crate). /// /// This endpoint works around a small limitation in `axum` and is delegating @@ -32,26 +46,29 @@ use std::str::FromStr; tag = "crates", responses((status = 200, description = "Successful Response")), )] -pub async fn find_new_crate(app: AppState, req: Parts) -> AppResult { +pub async fn find_new_crate(app: AppState, params: FindQueryParams) -> AppResult { let name = "new".to_string(); - find_crate(app, CratePath { name }, req).await + find_crate(app, CratePath { name }, params).await } /// Get crate metadata. #[utoipa::path( get, path = "/api/v1/crates/{name}", - params(CratePath), + params(CratePath, FindQueryParams), tag = "crates", responses((status = 200, description = "Successful Response")), )] -pub async fn find_crate(app: AppState, path: CratePath, req: Parts) -> AppResult { +pub async fn find_crate( + app: AppState, + path: CratePath, + params: FindQueryParams, +) -> AppResult { let mut conn = app.db_read().await?; - let include = req - .query() - .get("include") - .map(|mode| ShowIncludeMode::from_str(mode)) + let include = params + .include + .map(|mode| ShowIncludeMode::from_str(&mode)) .transpose()? .unwrap_or_default(); diff --git a/src/controllers/krate/versions.rs b/src/controllers/krate/versions.rs index 01cd719fe3..047c417b5d 100644 --- a/src/controllers/krate/versions.rs +++ b/src/controllers/krate/versions.rs @@ -1,5 +1,6 @@ //! Endpoint for versions of a crate +use axum::extract::{FromRequestParts, Query}; use axum_extra::json; use axum_extra::response::ErasedJson; use diesel::dsl::not; @@ -11,7 +12,9 @@ use indexmap::{IndexMap, IndexSet}; use std::str::FromStr; use crate::app::AppState; -use crate::controllers::helpers::pagination::{encode_seek, Page, PaginationOptions}; +use crate::controllers::helpers::pagination::{ + encode_seek, Page, PaginationOptions, PaginationQueryParams, +}; use crate::controllers::krate::CratePath; use crate::models::{User, Version, VersionOwnerAction}; use crate::schema::{users, versions}; @@ -19,40 +22,65 @@ use crate::util::errors::{bad_request, AppResult, BoxedAppError}; use crate::util::RequestUtils; use crate::views::EncodableVersion; +#[derive(Debug, Deserialize, FromRequestParts, utoipa::IntoParams)] +#[from_request(via(Query))] +#[into_params(parameter_in = Query)] +pub struct ListQueryParams { + /// Additional data to include in the response. + /// + /// Valid values: `release_tracks`. + /// + /// Defaults to no additional data. + /// + /// This parameter expects a comma-separated list of values. + include: Option, + + /// The sort order of the versions. + /// + /// Valid values: `date`, and `semver`. + /// + /// Defaults to `semver`. + sort: Option, +} + /// List all versions of a crate. #[utoipa::path( get, path = "/api/v1/crates/{name}/versions", - params(CratePath), + params(CratePath, ListQueryParams, PaginationQueryParams), tag = "versions", responses((status = 200, description = "Successful Response")), )] -pub async fn list_versions(state: AppState, path: CratePath, req: Parts) -> AppResult { +pub async fn list_versions( + state: AppState, + path: CratePath, + params: ListQueryParams, + pagination: PaginationQueryParams, + req: Parts, +) -> AppResult { let mut conn = state.db_read().await?; let crate_id = path.load_crate_id(&mut conn).await?; - let mut pagination = None; - let params = req.query(); // To keep backward compatibility, we paginate only if per_page is provided - if params.get("per_page").is_some() { - pagination = Some( + let pagination = match pagination.per_page { + Some(_) => Some( PaginationOptions::builder() .enable_seek(true) .enable_pages(false) .gather(&req)?, - ); - } + ), + None => None, + }; - let include = req - .query() - .get("include") - .map(|mode| ShowIncludeMode::from_str(mode)) + let include = params + .include + .map(|mode| ShowIncludeMode::from_str(&mode)) .transpose()? .unwrap_or_default(); // Sort by semver by default - let versions_and_publishers = match params.get("sort").map(|s| s.to_lowercase()).as_deref() { + let versions_and_publishers = match params.sort.map(|s| s.to_lowercase()).as_deref() { Some("date") => { list_by_date(crate_id, pagination.as_ref(), include, &req, &mut conn).await? } diff --git a/src/controllers/version/downloads.rs b/src/controllers/version/downloads.rs index cc2fab5940..b8d4374c68 100644 --- a/src/controllers/version/downloads.rs +++ b/src/controllers/version/downloads.rs @@ -9,6 +9,7 @@ use crate::schema::*; use crate::util::errors::AppResult; use crate::util::{redirect, RequestUtils}; use crate::views::EncodableVersionDownload; +use axum::extract::{FromRequestParts, Query}; use axum::response::{IntoResponse, Response}; use axum_extra::json; use axum_extra::response::ErasedJson; @@ -41,29 +42,37 @@ pub async fn download_version( } } +#[derive(Debug, Deserialize, FromRequestParts, utoipa::IntoParams)] +#[from_request(via(Query))] +#[into_params(parameter_in = Query)] +pub struct DownloadsQueryParams { + /// Only return download counts before this date. + #[param(example = "2024-06-28")] + before_date: Option, +} + /// Get the download counts for a crate version. /// /// This includes the per-day downloads for the last 90 days. #[utoipa::path( get, path = "/api/v1/crates/{name}/{version}/downloads", - params(CrateVersionPath), + params(CrateVersionPath, DownloadsQueryParams), tag = "versions", responses((status = 200, description = "Successful Response")), )] pub async fn get_version_downloads( app: AppState, path: CrateVersionPath, - req: Parts, + params: DownloadsQueryParams, ) -> AppResult { let mut conn = app.db_read().await?; let version = path.load_version(&mut conn).await?; - let cutoff_end_date = req - .query() - .get("before_date") - .and_then(|d| NaiveDate::parse_from_str(d, "%F").ok()) + let cutoff_end_date = params + .before_date .unwrap_or_else(|| Utc::now().date_naive()); + let cutoff_start_date = cutoff_end_date - Duration::days(89); let downloads = VersionDownload::belonging_to(&version) diff --git a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap index 296e11f5bc..507fab5e6d 100644 --- a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap +++ b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap @@ -23,6 +23,73 @@ snapshot_kind: text "/api/private/crate_owner_invitations": { "get": { "operationId": "list_crate_owner_invitations", + "parameters": [ + { + "description": "Filter crate owner invitations by crate name.\n\nOnly crate owners can query pending invitations for their crate.", + "in": "query", + "name": "crate_name", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "description": "The ID of the user who was invited to be a crate owner.\n\nThis parameter needs to match the authenticated user's ID.", + "in": "query", + "name": "invitee_id", + "required": false, + "schema": { + "format": "int32", + "type": [ + "integer", + "null" + ] + } + }, + { + "description": "The page number to request.\n\nThis parameter is mutually exclusive with `seek` and not supported for\nall requests.", + "in": "query", + "name": "page", + "required": false, + "schema": { + "format": "int32", + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + }, + { + "description": "The number of items to request per page.", + "in": "query", + "name": "per_page", + "required": false, + "schema": { + "format": "int32", + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + }, + { + "description": "The seek key to request.\n\nThis parameter is mutually exclusive with `page` and not supported for\nall requests.\n\nThe seek key can usually be found in the `meta.next_page` field of\npaginated responses.", + "in": "query", + "name": "seek", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], "responses": { "200": { "description": "Successful Response" @@ -81,6 +148,60 @@ snapshot_kind: text "/api/v1/categories": { "get": { "operationId": "list_categories", + "parameters": [ + { + "description": "The sort order of the categories.\n\nValid values: `alpha`, and `crates`.\n\nDefaults to `alpha`.", + "in": "query", + "name": "sort", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "description": "The page number to request.\n\nThis parameter is mutually exclusive with `seek` and not supported for\nall requests.", + "in": "query", + "name": "page", + "required": false, + "schema": { + "format": "int32", + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + }, + { + "description": "The number of items to request per page.", + "in": "query", + "name": "per_page", + "required": false, + "schema": { + "format": "int32", + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + }, + { + "description": "The seek key to request.\n\nThis parameter is mutually exclusive with `page` and not supported for\nall requests.\n\nThe seek key can usually be found in the `meta.next_page` field of\npaginated responses.", + "in": "query", + "name": "seek", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], "responses": { "200": { "description": "Successful Response" @@ -439,6 +560,18 @@ snapshot_kind: text "schema": { "type": "string" } + }, + { + "description": "Additional data to include in the response.\n\nValid values: `versions`, `keywords`, `categories`, `badges`,\n`downloads`, or `full`.\n\nDefaults to `full` for backwards compatibility.\n\nThis parameter expects a comma-separated list of values.", + "in": "query", + "name": "include", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } } ], "responses": { @@ -709,6 +842,70 @@ snapshot_kind: text "schema": { "type": "string" } + }, + { + "description": "Additional data to include in the response.\n\nValid values: `release_tracks`.\n\nDefaults to no additional data.\n\nThis parameter expects a comma-separated list of values.", + "in": "query", + "name": "include", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "description": "The sort order of the versions.\n\nValid values: `date`, and `semver`.\n\nDefaults to `semver`.", + "in": "query", + "name": "sort", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "description": "The page number to request.\n\nThis parameter is mutually exclusive with `seek` and not supported for\nall requests.", + "in": "query", + "name": "page", + "required": false, + "schema": { + "format": "int32", + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + }, + { + "description": "The number of items to request per page.", + "in": "query", + "name": "per_page", + "required": false, + "schema": { + "format": "int32", + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + }, + { + "description": "The seek key to request.\n\nThis parameter is mutually exclusive with `page` and not supported for\nall requests.\n\nThe seek key can usually be found in the `meta.next_page` field of\npaginated responses.", + "in": "query", + "name": "seek", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } } ], "responses": { @@ -923,6 +1120,20 @@ snapshot_kind: text "schema": { "type": "string" } + }, + { + "description": "Only return download counts before this date.", + "example": "2024-06-28", + "in": "query", + "name": "before_date", + "required": false, + "schema": { + "format": "date", + "type": [ + "string", + "null" + ] + } } ], "responses": { diff --git a/src/util/request_helpers.rs b/src/util/request_helpers.rs index 0fd9f38376..f16e8f9ae0 100644 --- a/src/util/request_helpers.rs +++ b/src/util/request_helpers.rs @@ -27,18 +27,11 @@ pub fn redirect(url: String) -> Response { } pub trait RequestUtils { - fn query(&self) -> IndexMap; fn wants_json(&self) -> bool; fn query_with_params(&self, params: IndexMap) -> String; } impl RequestUtils for T { - fn query(&self) -> IndexMap { - url::form_urlencoded::parse(self.uri().query().unwrap_or("").as_bytes()) - .into_owned() - .collect() - } - fn wants_json(&self) -> bool { self.headers() .get_all(header::ACCEPT)