diff --git a/Cargo.lock b/Cargo.lock index 4fac4749c61..f3190a61a99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,5 +1,11 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9fe5e32de01730eb1f6b7f5b51c17e03e2325bf40a74f754f04f130043affff" + [[package]] name = "addr2line" version = "0.13.0" @@ -155,6 +161,17 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "badge" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0228ae65b89e72921e86c19c3574da63bda0628e9d7da5e164f569bbf4e477d" +dependencies = [ + "base64 0.12.3", + "once_cell", + "rusttype", +] + [[package]] name = "base-x" version = "0.2.6" @@ -258,6 +275,7 @@ version = "0.2.2" dependencies = [ "ammonia", "anyhow", + "badge", "base64 0.13.0", "cargo-registry-s3", "chrono", @@ -1740,6 +1758,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "owned_ttf_parser" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f923fb806c46266c02ab4a5b239735c144bdeda724a50ed058e5226f594cde3" +dependencies = [ + "ttf-parser", +] + [[package]] name = "parking_lot" version = "0.11.0" @@ -2129,6 +2156,16 @@ dependencies = [ "semver 0.9.0", ] +[[package]] +name = "rusttype" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc7c727aded0be18c5b80c1640eae0ac8e396abf6fa8477d96cb37d18ee5ec59" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + [[package]] name = "ryu" version = "1.0.5" @@ -2724,6 +2761,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" +[[package]] +name = "ttf-parser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e5d7cd7ab3e47dda6e56542f4bbf3824c15234958c6e1bd6aaa347e93499fdc" + [[package]] name = "twoway" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 8c7da644aaa..d866788f1f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ rustdoc-args = [ [dependencies] ammonia = "3.0.0" anyhow = "1.0" +badge = "0.3.0" base64 = "0.13" cargo-registry-s3 = { path = "src/s3", version = "0.2.0" } chrono = { version = "0.4.0", features = ["serde"] } diff --git a/src/controllers/krate.rs b/src/controllers/krate.rs index 94af283c977..49fb0868922 100644 --- a/src/controllers/krate.rs +++ b/src/controllers/krate.rs @@ -1,3 +1,4 @@ +pub mod badges; pub mod downloads; pub mod follow; pub mod metadata; diff --git a/src/controllers/krate/badges.rs b/src/controllers/krate/badges.rs new file mode 100644 index 00000000000..5220451eae1 --- /dev/null +++ b/src/controllers/krate/badges.rs @@ -0,0 +1,77 @@ +//! Endpoints that provide badges based on crate metadata + +use crate::controllers::frontend_prelude::*; + +use crate::models::{Badge, Crate, CrateBadge, MaintenanceStatus}; +use crate::schema::*; + +use conduit::{Body, Response}; + +/// Handles the `GET /crates/:crate_id/maintenance.svg` route. +pub fn maintenance(req: &mut dyn RequestExt) -> EndpointResult { + let name = &req.params()["crate_id"]; + let conn = req.db_read_only()?; + + let krate = Crate::by_name(name).first::(&*conn); + if krate.is_err() { + let response = Response::builder().status(404).body(Body::empty()).unwrap(); + + return Ok(response); + } + + let krate = krate.unwrap(); + + let maintenance_badge: Option = CrateBadge::belonging_to(&krate) + .select((badges::crate_id, badges::all_columns)) + .load::(&*conn)? + .into_iter() + .find(|cb| matches!(cb.badge, Badge::Maintenance { .. })); + + let status = maintenance_badge + .map(|it| match it.badge { + Badge::Maintenance { status } => Some(status), + _ => None, + }) + .flatten(); + + let badge = generate_badge(status); + + let response = Response::builder() + .status(200) + .body(Body::from_vec(badge.into_bytes())) + .unwrap(); + + Ok(response) +} + +fn generate_badge(status: Option) -> String { + let message = match status { + Some(MaintenanceStatus::ActivelyDeveloped) => "actively-developed", + Some(MaintenanceStatus::PassivelyMaintained) => "passively-maintained", + Some(MaintenanceStatus::AsIs) => "as-is", + Some(MaintenanceStatus::None) => "unknown", + Some(MaintenanceStatus::Experimental) => "experimental", + Some(MaintenanceStatus::LookingForMaintainer) => "looking-for-maintainer", + Some(MaintenanceStatus::Deprecated) => "deprecated", + None => "unknown", + }; + + let color = match status { + Some(MaintenanceStatus::ActivelyDeveloped) => "brightgreen", + Some(MaintenanceStatus::PassivelyMaintained) => "yellowgreen", + Some(MaintenanceStatus::AsIs) => "yellow", + Some(MaintenanceStatus::None) => "lightgrey", + Some(MaintenanceStatus::Experimental) => "blue", + Some(MaintenanceStatus::LookingForMaintainer) => "orange", + Some(MaintenanceStatus::Deprecated) => "red", + None => "lightgrey", + }; + + let badge_options = badge::BadgeOptions { + subject: "maintenance".to_owned(), + status: message.to_owned(), + color: color.to_string(), + }; + + badge::Badge::new(badge_options).unwrap().to_svg() +} diff --git a/src/router.rs b/src/router.rs index 45d5410e433..e9066fc72b3 100644 --- a/src/router.rs +++ b/src/router.rs @@ -66,6 +66,10 @@ pub fn build_router(app: &App) -> R404 { "/crates/:crate_id/reverse_dependencies", C(krate::metadata::reverse_dependencies), ); + api_router.get( + "/crates/:crate_id/maintenance.svg", + C(krate::badges::maintenance), + ); api_router.get("/keywords", C(keyword::index)); api_router.get("/keywords/:keyword_id", C(keyword::show)); api_router.get("/categories", C(category::index)); diff --git a/src/tests/all.rs b/src/tests/all.rs index afc99c7f14a..e8514e2d365 100644 --- a/src/tests/all.rs +++ b/src/tests/all.rs @@ -52,6 +52,7 @@ mod dump_db; mod git; mod keyword; mod krate; +mod maintenance_badge; mod owners; mod read_only_mode; mod record; @@ -197,10 +198,7 @@ fn bad_resp(r: &mut AppResponse) -> Option { Some(bad) } -fn json(r: &mut AppResponse) -> T -where - for<'de> T: serde::Deserialize<'de>, -{ +fn text(r: &mut AppResponse) -> String { use conduit::Body::*; let mut body = Body::empty(); @@ -211,8 +209,15 @@ where File(_) => unimplemented!(), }; - let s = std::str::from_utf8(&body).unwrap(); - match serde_json::from_str(s) { + std::str::from_utf8(&body).unwrap().to_owned() +} + +fn json(r: &mut AppResponse) -> T +where + for<'de> T: serde::Deserialize<'de>, +{ + let s = text(r); + match serde_json::from_str(&s) { Ok(t) => t, Err(e) => panic!("failed to decode: {:?}\n{}", e, s), } diff --git a/src/tests/maintenance_badge.rs b/src/tests/maintenance_badge.rs new file mode 100644 index 00000000000..e15c8db337d --- /dev/null +++ b/src/tests/maintenance_badge.rs @@ -0,0 +1,60 @@ +use std::collections::HashMap; + +use cargo_registry::models::Badge; +use conduit::StatusCode; + +use crate::util::{MockAnonymousUser, RequestHelper}; +use crate::{builders::CrateBuilder, TestApp}; + +fn set_up() -> MockAnonymousUser { + let (app, anon, user) = TestApp::init().with_user(); + let user = user.as_model(); + + app.db(|conn| { + let mut badges = HashMap::new(); + badges.insert("maintenance".to_owned(), { + let mut attributes = HashMap::new(); + attributes.insert("status".to_owned(), "looking-for-maintainer".to_owned()); + attributes + }); + + let krate = CrateBuilder::new("foo", user.id).expect_build(conn); + Badge::update_crate(conn, &krate, Some(&badges)).unwrap(); + + CrateBuilder::new("bar", user.id).expect_build(conn); + }); + + anon +} + +#[test] +fn crate_with_maintenance_badge() { + let anon = set_up(); + + let response = anon + .get::("/api/v1/crates/foo/maintenance.svg") + .good_text(); + + assert!(response.contains("looking-for-maintainer")); + assert!(response.contains("fill=\"orange\"")); +} + +#[test] +fn crate_without_maintenance_badge() { + let anon = set_up(); + + let response = anon + .get::("/api/v1/crates/bar/maintenance.svg") + .good_text(); + + assert!(response.contains("unknown")); + assert!(response.contains("fill=\"lightgrey\"")); +} + +#[test] +fn unknown_crate() { + let anon = set_up(); + + anon.get::<()>("/api/v1/crates/unknown/maintenance.svg") + .assert_status(StatusCode::NOT_FOUND); +} diff --git a/src/tests/util.rs b/src/tests/util.rs index 59956476a1e..3304a002fbb 100644 --- a/src/tests/util.rs +++ b/src/tests/util.rs @@ -584,7 +584,7 @@ impl Bad { /// A type providing helper methods for working with responses #[must_use] pub struct Response { - response: AppResponse, + pub response: AppResponse, callback_on_good: Option>, } @@ -606,6 +606,15 @@ where } } + /// Assert that the response is good and deserialize the message + #[track_caller] + pub fn good_text(mut self) -> String { + if !self.response.status().is_success() { + panic!("bad response: {:?}", self.response.status()); + } + crate::text(&mut self.response) + } + /// Assert that the response is good and deserialize the message #[track_caller] pub fn good(mut self) -> T {