From d7b5f7858704bc511ebef5d488499a2d1e891107 Mon Sep 17 00:00:00 2001 From: Wim Looman Date: Fri, 9 Jun 2023 13:34:37 +0200 Subject: [PATCH 1/5] Don't record metrics for feed and builds.json as static resources --- src/web/metrics.rs | 6 +++++- src/web/routes.rs | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/web/metrics.rs b/src/web/metrics.rs index 4a69af35e..1e9432b5c 100644 --- a/src/web/metrics.rs +++ b/src/web/metrics.rs @@ -173,13 +173,17 @@ mod tests { ("/", "/"), ("/crate/hexponent/0.2.0", "/crate/:name/:version"), ("/crate/rcc/0.0.0", "/crate/:name/:version"), + ( + "/crate/rcc/0.0.0/builds.json", + "/crate/:name/:version/builds.json", + ), ("/-/static/index.js", "static resource"), ("/-/static/menu.js", "static resource"), ("/-/static/keyboard.js", "static resource"), ("/-/static/source.js", "static resource"), ("/-/static/opensearch.xml", "static resource"), ("/releases", "/releases"), - ("/releases/feed", "static resource"), + ("/releases/feed", "/releases/feed"), ("/releases/queue", "/releases/queue"), ("/releases/recent-failures", "/releases/recent-failures"), ( diff --git a/src/web/routes.rs b/src/web/routes.rs index f7c5cc2a1..93822b236 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -190,7 +190,7 @@ pub(super) fn build_axum_routes() -> AxumRouter { ) .route_with_tsr( "/releases/feed", - get_static(super::releases::releases_feed_handler), + get_internal(super::releases::releases_feed_handler), ) .route_with_tsr( "/releases/:owner", @@ -218,7 +218,7 @@ pub(super) fn build_axum_routes() -> AxumRouter { ) .route( "/crate/:name/:version/builds.json", - get_static(super::builds::build_list_json_handler), + get_internal(super::builds::build_list_json_handler), ) .route_with_tsr( "/crate/:name/:version/builds/:id", From 04eb1b348c4e0a91b03afefd8a837d3237af6bfe Mon Sep 17 00:00:00 2001 From: Wim Looman Date: Fri, 9 Jun 2023 14:03:37 +0200 Subject: [PATCH 2/5] Add new endpoint giving simple rustdoc status for a version --- src/web/metrics.rs | 4 ++ src/web/mod.rs | 9 +++ src/web/routes.rs | 4 ++ src/web/status.rs | 143 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 160 insertions(+) create mode 100644 src/web/status.rs diff --git a/src/web/metrics.rs b/src/web/metrics.rs index 1e9432b5c..8f7b24364 100644 --- a/src/web/metrics.rs +++ b/src/web/metrics.rs @@ -177,6 +177,10 @@ mod tests { "/crate/rcc/0.0.0/builds.json", "/crate/:name/:version/builds.json", ), + ( + "/crate/rcc/0.0.0/status.json", + "/crate/:name/:version/status.json", + ), ("/-/static/index.js", "static resource"), ("/-/static/menu.js", "static resource"), ("/-/static/keyboard.js", "static resource"), diff --git a/src/web/mod.rs b/src/web/mod.rs index a4db97c33..4271f1436 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -27,6 +27,7 @@ mod rustdoc; mod sitemap; mod source; mod statics; +mod status; use crate::{db::Pool, impl_axum_webpage, Context}; use anyhow::Error; @@ -115,6 +116,14 @@ impl MatchSemver { | MatchSemver::Latest((v, i)) => (v, i), } } + + /// If the matched version was an exact match to a semver version, returns the + /// version string and id for the query. If the lookup required a semver match, returns + /// `VersionNotFound`. + fn assume_exact(self) -> Result<(String, i32), AxumNope> { + let MatchSemver::Exact(details) = self else { return Err(AxumNope::VersionNotFound) }; + Ok(details) + } } /// Checks the database for crate releases that match the given name and version. diff --git a/src/web/routes.rs b/src/web/routes.rs index 93822b236..7577cbf57 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -220,6 +220,10 @@ pub(super) fn build_axum_routes() -> AxumRouter { "/crate/:name/:version/builds.json", get_internal(super::builds::build_list_json_handler), ) + .route( + "/crate/:name/:version/status.json", + get_internal(super::status::status_handler), + ) .route_with_tsr( "/crate/:name/:version/builds/:id", get_internal(super::build_details::build_details_handler), diff --git a/src/web/status.rs b/src/web/status.rs new file mode 100644 index 000000000..7acc478b1 --- /dev/null +++ b/src/web/status.rs @@ -0,0 +1,143 @@ +use super::cache::CachePolicy; +use crate::{ + db::Pool, + utils::spawn_blocking, + web::{error::AxumResult, match_version_axum}, +}; +use axum::{ + extract::{Extension, Path}, + http::header::ACCESS_CONTROL_ALLOW_ORIGIN, + response::IntoResponse, + Json, +}; + +pub(crate) async fn status_handler( + Path((name, req_version)): Path<(String, String)>, + Extension(pool): Extension, +) -> AxumResult { + let (_, id) = match_version_axum(&pool, &name, Some(&req_version)) + .await? + .assume_exact()? + .assume_exact()?; + + let rustdoc_status: bool = spawn_blocking({ + move || { + Ok(pool + .get()? + .query_one( + "SELECT releases.rustdoc_status + FROM releases + WHERE releases.id = $1 + ", + &[&id], + )? + .get("rustdoc_status")) + } + }) + .await?; + + Ok(( + Extension(CachePolicy::NoStoreMustRevalidate), + [(ACCESS_CONTROL_ALLOW_ORIGIN, "*")], + Json(serde_json::json!({ "doc_status": rustdoc_status })), + )) +} + +#[cfg(test)] +mod tests { + use crate::{ + test::{assert_cache_control, wrapper}, + web::cache::CachePolicy, + }; + use reqwest::StatusCode; + + #[test] + fn success() { + wrapper(|env| { + env.fake_release().name("foo").version("0.1.0").create()?; + + let response = env.frontend().get("/crate/foo/0.1.0/status.json").send()?; + assert_cache_control(&response, CachePolicy::NoStoreMustRevalidate, &env.config()); + assert_eq!(response.headers()["access-control-allow-origin"], "*"); + let value: serde_json::Value = serde_json::from_str(&response.text()?)?; + + assert_eq!(value, serde_json::json!({"doc_status": true})); + + Ok(()) + }); + } + + #[test] + fn failure() { + wrapper(|env| { + env.fake_release() + .name("foo") + .version("0.1.0") + .build_result_failed() + .create()?; + + let response = env.frontend().get("/crate/foo/0.1.0/status.json").send()?; + assert_cache_control(&response, CachePolicy::NoStoreMustRevalidate, &env.config()); + assert_eq!(response.headers()["access-control-allow-origin"], "*"); + let value: serde_json::Value = serde_json::from_str(&response.text()?)?; + + assert_eq!(value, serde_json::json!({"doc_status": false})); + + Ok(()) + }); + } + + #[test] + fn crate_version_not_found() { + wrapper(|env| { + env.fake_release().name("foo").version("0.1.0").create()?; + + let response = env.frontend().get("/crate/foo/0.2.0/status.json").send()?; + assert!(response + .url() + .as_str() + .ends_with("/crate/foo/0.2.0/status.json")); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + Ok(()) + }); + } + + #[test] + fn invalid_semver() { + wrapper(|env| { + env.fake_release().name("foo").version("0.1.0").create()?; + + let response = env.frontend().get("/crate/foo/0,1,0/status.json").send()?; + assert!(response + .url() + .as_str() + .ends_with("/crate/foo/0,1,0/status.json")); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + Ok(()) + }); + } + + /// We only support asking for the status of exact versions + #[test] + fn no_semver() { + wrapper(|env| { + env.fake_release().name("foo").version("0.1.0").create()?; + + let response = env.frontend().get("/crate/foo/latest/status.json").send()?; + assert!(response + .url() + .as_str() + .ends_with("/crate/foo/latest/status.json")); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let response = env.frontend().get("/crate/foo/0.1/status.json").send()?; + assert!(response + .url() + .as_str() + .ends_with("/crate/foo/0.1/status.json")); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + Ok(()) + }); + } +} From 8b388a77b37a42d0de1c735ed726d56c9e8f4b05 Mon Sep 17 00:00:00 2001 From: Wim Looman Date: Thu, 29 Jun 2023 21:51:08 +0200 Subject: [PATCH 3/5] Rename assume_exact methods --- src/web/builds.rs | 4 ++-- src/web/crate_details.rs | 2 +- src/web/features.rs | 2 +- src/web/mod.rs | 6 +++--- src/web/rustdoc.rs | 2 +- src/web/status.rs | 4 ++-- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/web/builds.rs b/src/web/builds.rs index ced593892..9b24707a3 100644 --- a/src/web/builds.rs +++ b/src/web/builds.rs @@ -43,7 +43,7 @@ pub(crate) async fn build_list_handler( ) -> AxumResult { let (version, version_or_latest) = match match_version_axum(&pool, &name, Some(&req_version)) .await? - .assume_exact()? + .exact_name_only()? { MatchSemver::Exact((version, _)) => (version.clone(), version), MatchSemver::Latest((version, _)) => (version, "latest".to_string()), @@ -85,7 +85,7 @@ pub(crate) async fn build_list_json_handler( ) -> AxumResult { let version = match match_version_axum(&pool, &name, Some(&req_version)) .await? - .assume_exact()? + .exact_name_only()? { MatchSemver::Exact((version, _)) | MatchSemver::Latest((version, _)) => version, MatchSemver::Semver((version, _)) => { diff --git a/src/web/crate_details.rs b/src/web/crate_details.rs index 5a3dbe367..ba861be85 100644 --- a/src/web/crate_details.rs +++ b/src/web/crate_details.rs @@ -332,7 +332,7 @@ pub(crate) async fn crate_details_handler( let mut conn = pool.get()?; Ok( match_version(&mut conn, ¶ms.name, params.version.as_deref()) - .and_then(|m| m.assume_exact())?, + .and_then(|m| m.exact_name_only())?, ) } }) diff --git a/src/web/features.rs b/src/web/features.rs index 335632e8a..5213a858d 100644 --- a/src/web/features.rs +++ b/src/web/features.rs @@ -42,7 +42,7 @@ pub(crate) async fn build_features_handler( let (version, version_or_latest, is_latest_url) = match match_version_axum(&pool, &name, Some(&req_version)) .await? - .assume_exact()? + .exact_name_only()? { MatchSemver::Exact((version, _)) => (version.clone(), version, false), MatchSemver::Latest((version, _)) => (version, "latest".to_string(), true), diff --git a/src/web/mod.rs b/src/web/mod.rs index 4271f1436..c6c202289 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -84,7 +84,7 @@ impl MatchVersion { /// If the matched version was an exact match to the requested crate name, returns the /// `MatchSemver` for the query. If the lookup required a dash/underscore conversion, returns /// `CrateNotFound`. - fn assume_exact(self) -> Result { + fn exact_name_only(self) -> Result { if self.corrected_name.is_none() { Ok(self.version) } else { @@ -120,7 +120,7 @@ impl MatchSemver { /// If the matched version was an exact match to a semver version, returns the /// version string and id for the query. If the lookup required a semver match, returns /// `VersionNotFound`. - fn assume_exact(self) -> Result<(String, i32), AxumNope> { + fn exact_version_only(self) -> Result<(String, i32), AxumNope> { let MatchSemver::Exact(details) = self else { return Err(AxumNope::VersionNotFound) }; Ok(details) } @@ -605,7 +605,7 @@ mod test { fn version(v: Option<&str>, db: &TestDatabase) -> Option { let version = match_version(&mut db.conn(), "foo", v) .ok()? - .assume_exact() + .exact_name_only() .ok()? .into_parts() .0; diff --git a/src/web/rustdoc.rs b/src/web/rustdoc.rs index 87e0ce1ae..1cdaa7ff7 100644 --- a/src/web/rustdoc.rs +++ b/src/web/rustdoc.rs @@ -874,7 +874,7 @@ pub(crate) async fn download_handler( ) -> AxumResult { let (version, _) = match_version_axum(&pool, &name, Some(&req_version)) .await? - .assume_exact()? + .exact_name_only()? .into_parts(); let archive_path = rustdoc_archive_path(&name, &version); diff --git a/src/web/status.rs b/src/web/status.rs index 7acc478b1..8e4e5252f 100644 --- a/src/web/status.rs +++ b/src/web/status.rs @@ -17,8 +17,8 @@ pub(crate) async fn status_handler( ) -> AxumResult { let (_, id) = match_version_axum(&pool, &name, Some(&req_version)) .await? - .assume_exact()? - .assume_exact()?; + .exact_name_only()? + .exact_version_only()?; let rustdoc_status: bool = spawn_blocking({ move || { From 7d0c82faff04d5803a45330dd1c370807f2995da Mon Sep 17 00:00:00 2001 From: Wim Looman Date: Fri, 30 Jun 2023 13:10:07 +0200 Subject: [PATCH 4/5] Allow semver in status paths --- src/web/mod.rs | 8 --- src/web/status.rs | 161 +++++++++++++++++++++++----------------------- 2 files changed, 82 insertions(+), 87 deletions(-) diff --git a/src/web/mod.rs b/src/web/mod.rs index c6c202289..4997bdd13 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -116,14 +116,6 @@ impl MatchSemver { | MatchSemver::Latest((v, i)) => (v, i), } } - - /// If the matched version was an exact match to a semver version, returns the - /// version string and id for the query. If the lookup required a semver match, returns - /// `VersionNotFound`. - fn exact_version_only(self) -> Result<(String, i32), AxumNope> { - let MatchSemver::Exact(details) = self else { return Err(AxumNope::VersionNotFound) }; - Ok(details) - } } /// Checks the database for crate releases that match the given name and version. diff --git a/src/web/status.rs b/src/web/status.rs index 8e4e5252f..66666b2aa 100644 --- a/src/web/status.rs +++ b/src/web/status.rs @@ -14,33 +14,41 @@ use axum::{ pub(crate) async fn status_handler( Path((name, req_version)): Path<(String, String)>, Extension(pool): Extension, -) -> AxumResult { - let (_, id) = match_version_axum(&pool, &name, Some(&req_version)) - .await? - .exact_name_only()? - .exact_version_only()?; - - let rustdoc_status: bool = spawn_blocking({ - move || { - Ok(pool - .get()? - .query_one( - "SELECT releases.rustdoc_status - FROM releases - WHERE releases.id = $1 - ", - &[&id], - )? - .get("rustdoc_status")) - } - }) - .await?; - - Ok(( +) -> impl IntoResponse { + ( Extension(CachePolicy::NoStoreMustRevalidate), [(ACCESS_CONTROL_ALLOW_ORIGIN, "*")], - Json(serde_json::json!({ "doc_status": rustdoc_status })), - )) + // We use an async block to emulate a try block so that we can apply the above CORS header + // and cache policy to both successful and failed responses + async move { + let (version, id) = match_version_axum(&pool, &name, Some(&req_version)) + .await? + .exact_name_only()? + .into_parts(); + + let rustdoc_status: bool = spawn_blocking({ + move || { + Ok(pool + .get()? + .query_one( + "SELECT releases.rustdoc_status + FROM releases + WHERE releases.id = $1 + ", + &[&id], + )? + .get("rustdoc_status")) + } + }) + .await?; + + AxumResult::Ok(Json(serde_json::json!({ + "version": version, + "doc_status": rustdoc_status, + }))) + } + .await, + ) } #[cfg(test)] @@ -50,25 +58,40 @@ mod tests { web::cache::CachePolicy, }; use reqwest::StatusCode; + use test_case::test_case; - #[test] - fn success() { + #[test_case("latest")] + #[test_case("0.1")] + #[test_case("0.1.0")] + fn status(version: &str) { wrapper(|env| { env.fake_release().name("foo").version("0.1.0").create()?; - let response = env.frontend().get("/crate/foo/0.1.0/status.json").send()?; + let response = env + .frontend() + .get(&format!("/crate/foo/{version}/status.json")) + .send()?; assert_cache_control(&response, CachePolicy::NoStoreMustRevalidate, &env.config()); assert_eq!(response.headers()["access-control-allow-origin"], "*"); + assert_eq!(response.status(), StatusCode::OK); let value: serde_json::Value = serde_json::from_str(&response.text()?)?; - assert_eq!(value, serde_json::json!({"doc_status": true})); + assert_eq!( + value, + serde_json::json!({ + "version": "0.1.0", + "doc_status": true, + }) + ); Ok(()) }); } - #[test] - fn failure() { + #[test_case("latest")] + #[test_case("0.1")] + #[test_case("0.1.0")] + fn failure(version: &str) { wrapper(|env| { env.fake_release() .name("foo") @@ -76,67 +99,47 @@ mod tests { .build_result_failed() .create()?; - let response = env.frontend().get("/crate/foo/0.1.0/status.json").send()?; + let response = env + .frontend() + .get(&format!("/crate/foo/{version}/status.json")) + .send()?; assert_cache_control(&response, CachePolicy::NoStoreMustRevalidate, &env.config()); assert_eq!(response.headers()["access-control-allow-origin"], "*"); + assert_eq!(response.status(), StatusCode::OK); let value: serde_json::Value = serde_json::from_str(&response.text()?)?; - assert_eq!(value, serde_json::json!({"doc_status": false})); + assert_eq!( + value, + serde_json::json!({ + "version": "0.1.0", + "doc_status": false, + }) + ); Ok(()) }); } - #[test] - fn crate_version_not_found() { + // crate not found + #[test_case("bar", "0.1")] + #[test_case("bar", "0.1.0")] + // version not found + #[test_case("foo", "0.2")] + #[test_case("foo", "0.2.0")] + // invalid semver + #[test_case("foo", "0,1")] + #[test_case("foo", "0,1,0")] + fn not_found(krate: &str, version: &str) { wrapper(|env| { env.fake_release().name("foo").version("0.1.0").create()?; - let response = env.frontend().get("/crate/foo/0.2.0/status.json").send()?; - assert!(response - .url() - .as_str() - .ends_with("/crate/foo/0.2.0/status.json")); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - Ok(()) - }); - } - - #[test] - fn invalid_semver() { - wrapper(|env| { - env.fake_release().name("foo").version("0.1.0").create()?; - - let response = env.frontend().get("/crate/foo/0,1,0/status.json").send()?; - assert!(response - .url() - .as_str() - .ends_with("/crate/foo/0,1,0/status.json")); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - Ok(()) - }); - } - - /// We only support asking for the status of exact versions - #[test] - fn no_semver() { - wrapper(|env| { - env.fake_release().name("foo").version("0.1.0").create()?; - - let response = env.frontend().get("/crate/foo/latest/status.json").send()?; - assert!(response - .url() - .as_str() - .ends_with("/crate/foo/latest/status.json")); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - - let response = env.frontend().get("/crate/foo/0.1/status.json").send()?; - assert!(response - .url() - .as_str() - .ends_with("/crate/foo/0.1/status.json")); + let response = env + .frontend() + .get(&format!("/crate/{krate}/{version}/status.json")) + .send()?; + assert_cache_control(&response, CachePolicy::NoStoreMustRevalidate, &env.config()); + assert_eq!(response.headers()["access-control-allow-origin"], "*"); assert_eq!(response.status(), StatusCode::NOT_FOUND); - Ok(()) }); } From 77677d1c6261e7c701186568005f5cf96495bc3a Mon Sep 17 00:00:00 2001 From: Wim Looman Date: Wed, 5 Jul 2023 12:08:35 +0200 Subject: [PATCH 5/5] Use redirects for semver status requests --- src/test/mod.rs | 30 +++++++++++++++++++++--------- src/web/rustdoc.rs | 5 ++++- src/web/status.rs | 45 ++++++++++++++++++++++++++++++++++++++------- 3 files changed, 63 insertions(+), 17 deletions(-) diff --git a/src/test/mod.rs b/src/test/mod.rs index 1d9f59bd6..cae38f059 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -149,16 +149,20 @@ fn assert_redirect_common( } /// Makes sure that a URL redirects to a specific page, but doesn't check that the target exists +/// +/// Returns the redirect response #[context("expected redirect from {path} to {expected_target}")] pub(crate) fn assert_redirect_unchecked( path: &str, expected_target: &str, web: &TestFrontend, -) -> Result<()> { - assert_redirect_common(path, expected_target, web).map(|_| ()) +) -> Result { + assert_redirect_common(path, expected_target, web) } /// Makes sure that a URL redirects to a specific page, but doesn't check that the target exists +/// +/// Returns the redirect response #[context("expected redirect from {path} to {expected_target}")] pub(crate) fn assert_redirect_cached_unchecked( path: &str, @@ -166,16 +170,22 @@ pub(crate) fn assert_redirect_cached_unchecked( cache_policy: cache::CachePolicy, web: &TestFrontend, config: &Config, -) -> Result<()> { +) -> Result { let redirect_response = assert_redirect_common(path, expected_target, web)?; assert_cache_control(&redirect_response, cache_policy, config); - Ok(()) + Ok(redirect_response) } /// Make sure that a URL redirects to a specific page, and that the target exists and is not another redirect +/// +/// Returns the redirect response #[context("expected redirect from {path} to {expected_target}")] -pub(crate) fn assert_redirect(path: &str, expected_target: &str, web: &TestFrontend) -> Result<()> { - assert_redirect_common(path, expected_target, web)?; +pub(crate) fn assert_redirect( + path: &str, + expected_target: &str, + web: &TestFrontend, +) -> Result { + let redirect_response = assert_redirect_common(path, expected_target, web)?; let response = web.get_no_redirect(expected_target).send()?; let status = response.status(); @@ -183,11 +193,13 @@ pub(crate) fn assert_redirect(path: &str, expected_target: &str, web: &TestFront anyhow::bail!("failed to GET {expected_target}: {status}"); } - Ok(()) + Ok(redirect_response) } /// Make sure that a URL redirects to a specific page, and that the target exists and is not another redirect. /// Also verifies that the redirect's cache-control header matches the provided cache policy. +/// +/// Returns the redirect response #[context("expected redirect from {path} to {expected_target}")] pub(crate) fn assert_redirect_cached( path: &str, @@ -195,7 +207,7 @@ pub(crate) fn assert_redirect_cached( cache_policy: cache::CachePolicy, web: &TestFrontend, config: &Config, -) -> Result<()> { +) -> Result { let redirect_response = assert_redirect_common(path, expected_target, web)?; assert_cache_control(&redirect_response, cache_policy, config); @@ -205,7 +217,7 @@ pub(crate) fn assert_redirect_cached( anyhow::bail!("failed to GET {expected_target}: {status}"); } - Ok(()) + Ok(redirect_response) } pub(crate) struct TestEnvironment { diff --git a/src/web/rustdoc.rs b/src/web/rustdoc.rs index 1cdaa7ff7..485ffade0 100644 --- a/src/web/rustdoc.rs +++ b/src/web/rustdoc.rs @@ -2149,11 +2149,14 @@ mod test { .archive_storage(archive_storage) .rustdoc_file("tokio/time/index.html") .create()?; + assert_redirect( "/tokio/0.2.21/tokio/time", "/tokio/0.2.21/tokio/time/index.html", env.frontend(), - ) + )?; + + Ok(()) }) } diff --git a/src/web/status.rs b/src/web/status.rs index 66666b2aa..b84274304 100644 --- a/src/web/status.rs +++ b/src/web/status.rs @@ -2,7 +2,7 @@ use super::cache::CachePolicy; use crate::{ db::Pool, utils::spawn_blocking, - web::{error::AxumResult, match_version_axum}, + web::{axum_redirect, error::AxumResult, match_version_axum, MatchSemver}, }; use axum::{ extract::{Extension, Path}, @@ -21,10 +21,18 @@ pub(crate) async fn status_handler( // We use an async block to emulate a try block so that we can apply the above CORS header // and cache policy to both successful and failed responses async move { - let (version, id) = match_version_axum(&pool, &name, Some(&req_version)) + let (version, id) = match match_version_axum(&pool, &name, Some(&req_version)) .await? .exact_name_only()? - .into_parts(); + { + MatchSemver::Exact((version, id)) | MatchSemver::Latest((version, id)) => { + (version, id) + } + MatchSemver::Semver((version, _)) => { + let redirect = axum_redirect(format!("/crate/{name}/{version}/status.json"))?; + return Ok(redirect.into_response()); + } + }; let rustdoc_status: bool = spawn_blocking({ move || { @@ -42,10 +50,12 @@ pub(crate) async fn status_handler( }) .await?; - AxumResult::Ok(Json(serde_json::json!({ + let json = Json(serde_json::json!({ "version": version, "doc_status": rustdoc_status, - }))) + })); + + AxumResult::Ok(json.into_response()) } .await, ) @@ -54,7 +64,7 @@ pub(crate) async fn status_handler( #[cfg(test)] mod tests { use crate::{ - test::{assert_cache_control, wrapper}, + test::{assert_cache_control, assert_redirect, wrapper}, web::cache::CachePolicy, }; use reqwest::StatusCode; @@ -63,6 +73,7 @@ mod tests { #[test_case("latest")] #[test_case("0.1")] #[test_case("0.1.0")] + #[test_case("=0.1.0"; "exact_version")] fn status(version: &str) { wrapper(|env| { env.fake_release().name("foo").version("0.1.0").create()?; @@ -88,9 +99,28 @@ mod tests { }); } + #[test_case("0.1")] + #[test_case("*")] + fn redirect(version: &str) { + wrapper(|env| { + env.fake_release().name("foo").version("0.1.0").create()?; + + let redirect = assert_redirect( + &format!("/crate/foo/{version}/status.json"), + "/crate/foo/0.1.0/status.json", + env.frontend(), + )?; + assert_cache_control(&redirect, CachePolicy::NoStoreMustRevalidate, &env.config()); + assert_eq!(redirect.headers()["access-control-allow-origin"], "*"); + + Ok(()) + }); + } + #[test_case("latest")] #[test_case("0.1")] #[test_case("0.1.0")] + #[test_case("=0.1.0"; "exact_version")] fn failure(version: &str) { wrapper(|env| { env.fake_release() @@ -124,6 +154,7 @@ mod tests { #[test_case("bar", "0.1")] #[test_case("bar", "0.1.0")] // version not found + #[test_case("foo", "=0.1.0"; "exact_version")] #[test_case("foo", "0.2")] #[test_case("foo", "0.2.0")] // invalid semver @@ -131,7 +162,7 @@ mod tests { #[test_case("foo", "0,1,0")] fn not_found(krate: &str, version: &str) { wrapper(|env| { - env.fake_release().name("foo").version("0.1.0").create()?; + env.fake_release().name("foo").version("0.1.1").create()?; let response = env .frontend()