From ece1dfe4973a33f3bd3b58035200799b781eb987 Mon Sep 17 00:00:00 2001 From: Alan Zabihi Date: Wed, 25 Feb 2026 10:11:22 +0100 Subject: [PATCH] feat: add sort_by query parameter to GET /v1/packages --- crates/api/src/handlers.rs | 23 ++++-- crates/common/src/db.rs | 142 ++++++++++++++++++++++-------------- crates/common/src/models.rs | 10 +++ 3 files changed, 115 insertions(+), 60 deletions(-) diff --git a/crates/api/src/handlers.rs b/crates/api/src/handlers.rs index 6cd2bf8..66df698 100644 --- a/crates/api/src/handlers.rs +++ b/crates/api/src/handlers.rs @@ -9,8 +9,8 @@ use axum::{ }; use common::{ AgenticThreatSummary, BulkLookupRequest, CveSummary, MaintainerInfo, PackageCapabilities, - PackageListItem, PackageListResponse, PackageResponse, PaginationParams, PublisherInfo, - Registry, ScanJob, ScanPriority, ScanRequest, ScanRequestResponse, + PackageListItem, PackageListResponse, PackageResponse, PackageSortBy, PaginationParams, + PublisherInfo, Registry, ScanJob, ScanPriority, ScanRequest, ScanRequestResponse, }; use serde_json::json; use std::io::Write; @@ -29,6 +29,17 @@ pub async fn list_packages( let limit = params.limit.unwrap_or(50).min(100); // Default 50, max 100 let offset = params.offset.unwrap_or(0); + let sort_by = match params.sort_by.as_deref() { + None | Some("weekly_downloads") => PackageSortBy::WeeklyDownloads, + Some("scanned_at") => PackageSortBy::ScannedAt, + Some(_) => { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "invalid sort_by value" })), + )); + } + }; + let latest = params.latest.unwrap_or(false); let registry = params.registry; let risk_level = params.risk_level; @@ -37,23 +48,23 @@ pub async fn list_packages( if latest { state .db - .search_packages_latest(q, limit, offset, registry, risk_level) + .search_packages_latest(q, limit, offset, registry, risk_level, sort_by) .await } else { state .db - .search_packages(q, limit, offset, registry, risk_level) + .search_packages(q, limit, offset, registry, risk_level, sort_by) .await } } else if latest { state .db - .get_packages_paginated_latest(limit, offset, registry, risk_level) + .get_packages_paginated_latest(limit, offset, registry, risk_level, sort_by) .await } else { state .db - .get_packages_paginated(limit, offset, registry, risk_level) + .get_packages_paginated(limit, offset, registry, risk_level, sort_by) .await } .map_err(|e| { diff --git a/crates/common/src/db.rs b/crates/common/src/db.rs index a606d6a..adce033 100644 --- a/crates/common/src/db.rs +++ b/crates/common/src/db.rs @@ -349,11 +349,16 @@ impl Database { offset: i64, registry: Option, risk_level: Option, + sort_by: PackageSortBy, ) -> Result<(Vec, i64)> { let registry_str = registry.map(|r| r.to_string()); let risk_level_str = risk_level.map(|r| r.to_string()); + let order_clause = match sort_by { + PackageSortBy::WeeklyDownloads => "p.weekly_downloads DESC NULLS LAST, p.name ASC", + PackageSortBy::ScannedAt => "p.scanned_at DESC", + }; - let packages: Vec = sqlx::query_as( + let sql = format!( r#" SELECT p.id, p.name, p.version, p.registry, p.risk_level, p.trust_score, @@ -363,16 +368,19 @@ impl Database { FROM packages p WHERE ($3::text IS NULL OR p.registry = $3) AND ($4::text IS NULL OR p.risk_level = $4) - ORDER BY p.weekly_downloads DESC NULLS LAST, p.name ASC + ORDER BY {} LIMIT $1 OFFSET $2 "#, - ) - .bind(limit) - .bind(offset) - .bind(®istry_str) - .bind(&risk_level_str) - .fetch_all(&self.pool) - .await?; + order_clause + ); + + let packages: Vec = sqlx::query_as(&sql) + .bind(limit) + .bind(offset) + .bind(®istry_str) + .bind(&risk_level_str) + .fetch_all(&self.pool) + .await?; let total: (i64,) = sqlx::query_as( r#" @@ -398,12 +406,25 @@ impl Database { offset: i64, registry: Option, risk_level: Option, + sort_by: PackageSortBy, ) -> Result<(Vec, i64)> { let pattern = format!("%{}%", query); let registry_str = registry.map(|r| r.to_string()); let risk_level_str = risk_level.map(|r| r.to_string()); + let order_clause = match sort_by { + PackageSortBy::WeeklyDownloads => { + "CASE \ + WHEN LOWER(p.name) = LOWER($2) THEN 0 \ + WHEN LOWER(p.name) LIKE LOWER($2) || '%' THEN 1 \ + ELSE 2 \ + END, \ + p.weekly_downloads DESC NULLS LAST, \ + p.name ASC" + } + PackageSortBy::ScannedAt => "p.scanned_at DESC", + }; - let packages: Vec = sqlx::query_as( + let sql = format!( r#" SELECT p.id, p.name, p.version, p.registry, p.risk_level, p.trust_score, @@ -414,25 +435,21 @@ impl Database { WHERE p.name ILIKE $1 AND ($5::text IS NULL OR p.registry = $5) AND ($6::text IS NULL OR p.risk_level = $6) - ORDER BY - CASE - WHEN LOWER(p.name) = LOWER($2) THEN 0 - WHEN LOWER(p.name) LIKE LOWER($2) || '%' THEN 1 - ELSE 2 - END, - p.weekly_downloads DESC NULLS LAST, - p.name ASC + ORDER BY {} LIMIT $3 OFFSET $4 "#, - ) - .bind(&pattern) - .bind(query) - .bind(limit) - .bind(offset) - .bind(®istry_str) - .bind(&risk_level_str) - .fetch_all(&self.pool) - .await?; + order_clause + ); + + let packages: Vec = sqlx::query_as(&sql) + .bind(&pattern) + .bind(query) + .bind(limit) + .bind(offset) + .bind(®istry_str) + .bind(&risk_level_str) + .fetch_all(&self.pool) + .await?; let total: (i64,) = sqlx::query_as( r#" @@ -458,11 +475,16 @@ impl Database { offset: i64, registry: Option, risk_level: Option, + sort_by: PackageSortBy, ) -> Result<(Vec, i64)> { let registry_str = registry.map(|r| r.to_string()); let risk_level_str = risk_level.map(|r| r.to_string()); + let order_clause = match sort_by { + PackageSortBy::WeeklyDownloads => "p.weekly_downloads DESC NULLS LAST, p.name ASC", + PackageSortBy::ScannedAt => "p.scanned_at DESC", + }; - let packages: Vec = sqlx::query_as( + let sql = format!( r#" WITH latest AS ( SELECT DISTINCT ON (name, registry) * @@ -477,16 +499,19 @@ impl Database { COALESCE((SELECT COUNT(*) FROM agentic_threats WHERE package_id = p.id AND verification_status = 'verified'), 0) as threat_count FROM latest p WHERE ($4::text IS NULL OR p.risk_level = $4) - ORDER BY p.weekly_downloads DESC NULLS LAST, p.name ASC + ORDER BY {} LIMIT $1 OFFSET $2 "#, - ) - .bind(limit) - .bind(offset) - .bind(®istry_str) - .bind(&risk_level_str) - .fetch_all(&self.pool) - .await?; + order_clause + ); + + let packages: Vec = sqlx::query_as(&sql) + .bind(limit) + .bind(offset) + .bind(®istry_str) + .bind(&risk_level_str) + .fetch_all(&self.pool) + .await?; let total: (i64,) = sqlx::query_as( r#" @@ -515,12 +540,25 @@ impl Database { offset: i64, registry: Option, risk_level: Option, + sort_by: PackageSortBy, ) -> Result<(Vec, i64)> { let pattern = format!("%{}%", query); let registry_str = registry.map(|r| r.to_string()); let risk_level_str = risk_level.map(|r| r.to_string()); + let order_clause = match sort_by { + PackageSortBy::WeeklyDownloads => { + "CASE \ + WHEN LOWER(p.name) = LOWER($2) THEN 0 \ + WHEN LOWER(p.name) LIKE LOWER($2) || '%' THEN 1 \ + ELSE 2 \ + END, \ + p.weekly_downloads DESC NULLS LAST, \ + p.name ASC" + } + PackageSortBy::ScannedAt => "p.scanned_at DESC", + }; - let packages: Vec = sqlx::query_as( + let sql = format!( r#" WITH latest AS ( SELECT DISTINCT ON (name, registry) * @@ -536,25 +574,21 @@ impl Database { COALESCE((SELECT COUNT(*) FROM agentic_threats WHERE package_id = p.id AND verification_status = 'verified'), 0) as threat_count FROM latest p WHERE ($6::text IS NULL OR p.risk_level = $6) - ORDER BY - CASE - WHEN LOWER(p.name) = LOWER($2) THEN 0 - WHEN LOWER(p.name) LIKE LOWER($2) || '%' THEN 1 - ELSE 2 - END, - p.weekly_downloads DESC NULLS LAST, - p.name ASC + ORDER BY {} LIMIT $3 OFFSET $4 "#, - ) - .bind(&pattern) - .bind(query) - .bind(limit) - .bind(offset) - .bind(®istry_str) - .bind(&risk_level_str) - .fetch_all(&self.pool) - .await?; + order_clause + ); + + let packages: Vec = sqlx::query_as(&sql) + .bind(&pattern) + .bind(query) + .bind(limit) + .bind(offset) + .bind(®istry_str) + .bind(&risk_level_str) + .fetch_all(&self.pool) + .await?; let total: (i64,) = sqlx::query_as( r#" diff --git a/crates/common/src/models.rs b/crates/common/src/models.rs index 1214963..bd9205f 100644 --- a/crates/common/src/models.rs +++ b/crates/common/src/models.rs @@ -432,6 +432,14 @@ pub struct PackageListResponse { pub offset: i64, } +/// Sort order for package list queries +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PackageSortBy { + #[default] + WeeklyDownloads, + ScannedAt, +} + /// Pagination query parameters #[derive(Debug, Clone, Deserialize)] pub struct PaginationParams { @@ -445,6 +453,8 @@ pub struct PaginationParams { pub registry: Option, /// Filter by risk level (clean, warning, critical) pub risk_level: Option, + /// Sort order: "weekly_downloads" (default) or "scanned_at" + pub sort_by: Option, } /// Full package response for API