Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extract QueryParams structs #10238

Merged
merged 7 commits into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
25 changes: 20 additions & 5 deletions src/controllers/category.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,47 @@ 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<String>,
}

/// 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<ErasedJson> {
pub async fn list_categories(
app: AppState,
params: ListQueryParams,
req: Parts,
) -> AppResult<ErasedJson> {
// 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.
let options = PaginationOptions::builder().gather(&req)?;

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();

Expand Down
48 changes: 37 additions & 11 deletions src/controllers/crate_owner_invitation.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<String>,

/// 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<i32>,
}

/// 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<Json<PrivateListResponse>> {
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))
}
Expand All @@ -101,6 +111,22 @@ enum ListFilter {
InviteeId(i32),
}

impl TryFrom<ListQueryParams> for ListFilter {
type Error = BoxedAppError;

fn try_from(params: ListQueryParams) -> Result<Self, Self::Error> {
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,
Expand Down
10 changes: 6 additions & 4 deletions src/controllers/helpers/pagination.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down Expand Up @@ -55,19 +56,20 @@ 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.
///
/// This parameter is mutually exclusive with `seek` and not supported for
/// all requests.
#[param(value_type = Option<u32>, minimum = 1)]
page: Option<NonZeroU32>,
pub page: Option<NonZeroU32>,

/// The number of items to request per page.
#[param(value_type = Option<u32>, minimum = 1)]
per_page: Option<NonZeroU32>,
pub per_page: Option<NonZeroU32>,

/// The seek key to request.
///
Expand All @@ -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<String>,
pub seek: Option<String>,
}

pub(crate) struct PaginationOptionsBuilder {
Expand Down
37 changes: 27 additions & 10 deletions src/controllers/krate/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

/// Get crate metadata (for the `new` crate).
///
/// This endpoint works around a small limitation in `axum` and is delegating
Expand All @@ -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<ErasedJson> {
pub async fn find_new_crate(app: AppState, params: FindQueryParams) -> AppResult<ErasedJson> {
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<ErasedJson> {
pub async fn find_crate(
app: AppState,
path: CratePath,
params: FindQueryParams,
) -> AppResult<ErasedJson> {
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();

Expand Down
56 changes: 42 additions & 14 deletions src/controllers/krate/versions.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,48 +12,75 @@ 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};
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<String>,

/// The sort order of the versions.
///
/// Valid values: `date`, and `semver`.
///
/// Defaults to `semver`.
sort: Option<String>,
}

/// 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<ErasedJson> {
pub async fn list_versions(
state: AppState,
path: CratePath,
params: ListQueryParams,
pagination: PaginationQueryParams,
req: Parts,
) -> AppResult<ErasedJson> {
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?
}
Expand Down
Loading