Skip to content

Commit 8a4be60

Browse files
committed
controllers/krate/search: Read query parameters via axum extractor
This allows us to automatically derive OpenAPI descriptions and avoids unnecessary memory allocations from query parameters we do not care about in this endpoint.
1 parent 1ee4f63 commit 8a4be60

8 files changed

+284
-80
lines changed

src/controllers/krate/search.rs

Lines changed: 115 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
//! Endpoint for searching and discovery functionality
22
33
use crate::auth::AuthCheck;
4+
use axum::extract::FromRequestParts;
5+
use axum_extra::extract::Query;
46
use axum_extra::json;
57
use axum_extra::response::ErasedJson;
68
use diesel::dsl::{exists, sql, InnerJoinQuerySource, LeftJoinQuerySource};
@@ -9,8 +11,10 @@ use diesel::sql_types::{Bool, Text};
911
use diesel_async::{AsyncPgConnection, RunQueryDsl};
1012
use diesel_full_text_search::*;
1113
use http::request::Parts;
14+
use std::ops::Deref;
1215
use std::sync::OnceLock;
1316
use tracing::Instrument;
17+
use utoipa::IntoParams;
1418

1519
use crate::app::AppState;
1620
use crate::controllers::helpers::Paginate;
@@ -22,6 +26,7 @@ use crate::views::EncodableCrate;
2226
use crate::controllers::helpers::pagination::{Page, PaginationOptions};
2327
use crate::models::krate::ALL_COLUMNS;
2428
use crate::sql::{array_agg, canon_crate_name, lower};
29+
use crate::util::string_excl_null::StringExclNull;
2530
use crate::util::RequestUtils;
2631

2732
/// Returns a list of crates.
@@ -33,10 +38,15 @@ use crate::util::RequestUtils;
3338
#[utoipa::path(
3439
get,
3540
path = "/api/v1/crates",
41+
params(ListQueryParams),
3642
tag = "crates",
3743
responses((status = 200, description = "Successful Response")),
3844
)]
39-
pub async fn list_crates(app: AppState, req: Parts) -> AppResult<ErasedJson> {
45+
pub async fn list_crates(
46+
app: AppState,
47+
params: ListQueryParams,
48+
req: Parts,
49+
) -> AppResult<ErasedJson> {
4050
// Notes:
4151
// The different use cases this function covers is handled through passing
4252
// in parameters in the GET request.
@@ -57,32 +67,8 @@ pub async fn list_crates(app: AppState, req: Parts) -> AppResult<ErasedJson> {
5767
use diesel::sql_types::Float;
5868
use seek::*;
5969

60-
let params = req.query();
61-
let option_param = |s| match params.get(s).map(|v| v.as_str()) {
62-
Some(v) if v.contains('\0') => Err(bad_request(format!(
63-
"parameter {s} cannot contain a null byte"
64-
))),
65-
Some(v) => Ok(Some(v)),
66-
None => Ok(None),
67-
};
68-
let sort = option_param("sort")?;
69-
let include_yanked = option_param("include_yanked")?
70-
.map(|s| s == "yes")
71-
.unwrap_or(true);
72-
73-
let filter_params = FilterParams {
74-
q_string: option_param("q")?,
75-
include_yanked,
76-
category: option_param("category")?,
77-
all_keywords: option_param("all_keywords")?,
78-
keyword: option_param("keyword")?,
79-
letter: option_param("letter")?,
80-
user_id: option_param("user_id")?.and_then(|s| s.parse::<i32>().ok()),
81-
team_id: option_param("team_id")?.and_then(|s| s.parse::<i32>().ok()),
82-
following: option_param("following")?.is_some(),
83-
has_ids: option_param("ids[]")?.is_some(),
84-
..Default::default()
85-
};
70+
let filter_params: FilterParams = params.into();
71+
let sort = filter_params.sort.as_deref();
8672

8773
let selection = (
8874
ALL_COLUMNS,
@@ -106,6 +92,8 @@ pub async fn list_crates(app: AppState, req: Parts) -> AppResult<ErasedJson> {
10692

10793
if let Some(q_string) = &filter_params.q_string {
10894
if !q_string.is_empty() {
95+
let q_string = q_string.as_str();
96+
10997
let sort = sort.unwrap_or("relevance");
11098

11199
query = query.order(Crate::with_name(q_string).desc());
@@ -258,40 +246,93 @@ pub async fn list_crates(app: AppState, req: Parts) -> AppResult<ErasedJson> {
258246
}))
259247
}
260248

261-
#[derive(Default)]
262-
struct FilterParams<'a> {
263-
q_string: Option<&'a str>,
264-
include_yanked: bool,
265-
category: Option<&'a str>,
266-
all_keywords: Option<&'a str>,
267-
keyword: Option<&'a str>,
268-
letter: Option<&'a str>,
249+
#[derive(Debug, Deserialize, FromRequestParts, IntoParams)]
250+
#[from_request(via(Query))]
251+
#[into_params(parameter_in = Query)]
252+
pub struct ListQueryParams {
253+
/// The sort order of the crates.
254+
///
255+
/// Valid values: `alphabetical`, `relevance`, `downloads`,
256+
/// `recent-downloads`, `recent-updates`, `new`.
257+
///
258+
/// Defaults to `relevance` if `q_string` is set, otherwise `alphabetical`.
259+
sort: Option<String>,
260+
261+
/// A search query string.
262+
#[serde(rename = "q")]
263+
#[param(inline)]
264+
q_string: Option<StringExclNull>,
265+
266+
/// Includes yanked crates in the results if set to `yes`.
267+
include_yanked: Option<String>,
268+
269+
/// Only return crates in the specified category.
270+
#[param(inline)]
271+
category: Option<StringExclNull>,
272+
273+
/// Only return crates with all the specified keywords.
274+
///
275+
/// This parameter expects a space-separated list of keywords.
276+
#[param(inline)]
277+
all_keywords: Option<StringExclNull>,
278+
279+
/// Only return crates with the specified keyword.
280+
#[param(inline)]
281+
keyword: Option<StringExclNull>,
282+
283+
/// Only return crates with names starting with the specified letter.
284+
#[param(inline)]
285+
letter: Option<StringExclNull>,
286+
287+
/// Only return crates owned by the specified user.
269288
user_id: Option<i32>,
289+
290+
/// Only return crates owned by the specified team.
270291
team_id: Option<i32>,
271-
following: bool,
272-
has_ids: bool,
292+
293+
/// Only return crates followed by the authenticated user.
294+
following: Option<String>,
295+
296+
/// Only return crates with the specified names.
297+
#[serde(rename = "ids[]", default)]
298+
#[param(inline)]
299+
ids: Vec<StringExclNull>,
300+
}
301+
302+
impl ListQueryParams {
303+
pub fn include_yanked(&self) -> bool {
304+
let include_yanked = self.include_yanked.as_ref();
305+
include_yanked.map(|s| s == "yes").unwrap_or(true)
306+
}
307+
308+
pub fn following(&self) -> bool {
309+
self.following.is_some()
310+
}
311+
}
312+
313+
struct FilterParams {
314+
search_params: ListQueryParams,
273315
_auth_user_id: OnceLock<i32>,
274-
_ids: OnceLock<Option<Vec<String>>>,
275316
}
276317

277-
impl<'a> FilterParams<'a> {
278-
fn ids(&self, req: &Parts) -> Option<&[String]> {
279-
self._ids
280-
.get_or_init(|| {
281-
if self.has_ids {
282-
let query_bytes = req.uri.query().unwrap_or("").as_bytes();
283-
let v = url::form_urlencoded::parse(query_bytes)
284-
.filter(|(key, _)| key == "ids[]")
285-
.map(|(_, value)| value.to_string())
286-
.collect::<Vec<_>>();
287-
Some(v)
288-
} else {
289-
None
290-
}
291-
})
292-
.as_deref()
318+
impl Deref for FilterParams {
319+
type Target = ListQueryParams;
320+
321+
fn deref(&self) -> &Self::Target {
322+
&self.search_params
293323
}
324+
}
294325

326+
impl From<ListQueryParams> for FilterParams {
327+
fn from(search_params: ListQueryParams) -> Self {
328+
Self {
329+
search_params,
330+
_auth_user_id: OnceLock::new(),
331+
}
332+
}
333+
}
334+
335+
impl FilterParams {
295336
async fn authed_user_id(&self, req: &Parts, conn: &mut AsyncPgConnection) -> AppResult<i32> {
296337
if let Some(val) = self._auth_user_id.get() {
297338
return Ok(*val);
@@ -306,40 +347,40 @@ impl<'a> FilterParams<'a> {
306347
}
307348

308349
async fn make_query(
309-
&'a self,
350+
&self,
310351
req: &Parts,
311352
conn: &mut AsyncPgConnection,
312-
) -> AppResult<crates::BoxedQuery<'a, diesel::pg::Pg>> {
353+
) -> AppResult<crates::BoxedQuery<'_, diesel::pg::Pg>> {
313354
let mut query = crates::table.into_boxed();
314355

315-
if let Some(q_string) = self.q_string {
356+
if let Some(q_string) = &self.q_string {
316357
if !q_string.is_empty() {
317358
let q = sql::<TsQuery>("plainto_tsquery('english', ")
318-
.bind::<Text, _>(q_string)
359+
.bind::<Text, _>(q_string.as_str())
319360
.sql(")");
320361
query = query.filter(
321362
q.matches(crates::textsearchable_index_col)
322-
.or(Crate::loosly_matches_name(q_string)),
363+
.or(Crate::loosly_matches_name(q_string.as_str())),
323364
);
324365
}
325366
}
326367

327-
if let Some(cat) = self.category {
368+
if let Some(cat) = &self.category {
328369
query = query.filter(
329370
crates::id.eq_any(
330371
crates_categories::table
331372
.select(crates_categories::crate_id)
332373
.inner_join(categories::table)
333374
.filter(
334375
categories::slug
335-
.eq(cat)
376+
.eq(cat.as_str())
336377
.or(categories::slug.like(format!("{cat}::%"))),
337378
),
338379
),
339380
);
340381
}
341382

342-
if let Some(kws) = self.all_keywords {
383+
if let Some(kws) = &self.all_keywords {
343384
let names: Vec<_> = kws
344385
.split_whitespace()
345386
.map(|name| name.to_lowercase())
@@ -353,16 +394,16 @@ impl<'a> FilterParams<'a> {
353394
.single_value()
354395
.contains(names),
355396
);
356-
} else if let Some(kw) = self.keyword {
397+
} else if let Some(kw) = &self.keyword {
357398
query = query.filter(
358399
crates::id.eq_any(
359400
crates_keywords::table
360401
.select(crates_keywords::crate_id)
361402
.inner_join(keywords::table)
362-
.filter(lower(keywords::keyword).eq(lower(kw))),
403+
.filter(lower(keywords::keyword).eq(lower(kw.as_str()))),
363404
),
364405
);
365-
} else if let Some(letter) = self.letter {
406+
} else if let Some(letter) = &self.letter {
366407
let pattern = format!(
367408
"{}%",
368409
letter
@@ -389,7 +430,7 @@ impl<'a> FilterParams<'a> {
389430
.filter(crate_owners::owner_id.eq(team_id)),
390431
),
391432
);
392-
} else if self.following {
433+
} else if self.following() {
393434
let user_id = self.authed_user_id(req, conn).await?;
394435
query = query.filter(
395436
crates::id.eq_any(
@@ -398,11 +439,11 @@ impl<'a> FilterParams<'a> {
398439
.filter(follows::user_id.eq(user_id)),
399440
),
400441
);
401-
} else if self.ids(req).is_some() {
402-
query = query.filter(crates::name.eq_any(self.ids(req).unwrap()));
442+
} else if !self.ids.is_empty() {
443+
query = query.filter(crates::name.eq_any(self.ids.iter().map(|s| s.as_str())));
403444
}
404445

405-
if !self.include_yanked {
446+
if !self.include_yanked() {
406447
query = query.filter(exists(
407448
versions::table
408449
.filter(versions::crate_id.eq(crates::id))
@@ -413,7 +454,7 @@ impl<'a> FilterParams<'a> {
413454
Ok(query)
414455
}
415456

416-
fn seek_after(&self, seek_payload: &seek::SeekPayload) -> BoxedCondition<'a> {
457+
fn seek_after(&self, seek_payload: &seek::SeekPayload) -> BoxedCondition<'_> {
417458
use seek::*;
418459

419460
let crates_aliased = alias!(crates as crates_aliased);
@@ -509,7 +550,7 @@ impl<'a> FilterParams<'a> {
509550
// Equivalent of:
510551
// `WHERE (exact_match = exact_match' AND name < name') OR exact_match <
511552
// exact_match'`
512-
let q_string = self.q_string.expect("q_string should not be None");
553+
let q_string = self.q_string.as_ref().expect("q_string should not be None");
513554
let name_exact_match = Crate::with_name(q_string);
514555
vec![
515556
Box::new(
@@ -530,12 +571,12 @@ impl<'a> FilterParams<'a> {
530571
// `WHERE (exact_match = exact_match' AND rank = rank' AND name > name')
531572
// OR (exact_match = exact_match' AND rank < rank')
532573
// OR exact_match < exact_match'`
533-
let q_string = self.q_string.expect("q_string should not be None");
574+
let q_string = self.q_string.as_ref().expect("q_string should not be None");
534575
let q = sql::<TsQuery>("plainto_tsquery('english', ")
535-
.bind::<Text, _>(q_string)
576+
.bind::<Text, _>(q_string.as_str())
536577
.sql(")");
537578
let rank = ts_rank_cd(crates::textsearchable_index_col, q);
538-
let name_exact_match = Crate::with_name(q_string);
579+
let name_exact_match = Crate::with_name(q_string.as_str());
539580
vec![
540581
Box::new(
541582
name_exact_match

0 commit comments

Comments
 (0)