Skip to content
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
239 changes: 228 additions & 11 deletions crates/bin/docs_rs_web/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ use docs_rs_uri::EscapedURI;
use std::borrow::Cow;
use tracing::error;

/// A single navigation link offered on an error page to help the user recover.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct RecoveryLink {
/// The user-visible link text.
pub label: Cow<'static, str>,
/// The link target.
pub href: EscapedURI,
}

#[derive(Template)]
#[template(path = "error.html")]
#[derive(Debug, Clone, PartialEq)]
Expand All @@ -26,6 +35,8 @@ pub(crate) struct AxumErrorPage {
/// The error message, displayed as a description
pub message: Cow<'static, str>,
pub status: StatusCode,
/// Optional navigation links to help the user recover. Empty for most errors.
pub recovery: Vec<RecoveryLink>,
}

impl_axum_webpage! {
Expand All @@ -38,6 +49,20 @@ impl_axum_webpage! {
pub enum AxumNope {
#[error("Requested resource not found")]
ResourceNotFound,
/// A specific doc page/file was not found, but its crate and version *do*
/// exist and have docs. Renders a 404 that acknowledges the crate/version
/// and offers recovery links (issue #2568).
#[error("Requested resource not found in an existing crate version")]
ResourceNotFoundInVersion {
name: String,
version: String,
/// Whether the request used the `/latest/` alias (vs. a pinned version).
is_latest_url: bool,
/// Root of the docs for the requested version.
version_root_url: EscapedURI,
/// The crate details page for the requested version.
crate_details_url: EscapedURI,
},
#[error("Requested build not found")]
BuildNotFound,
#[error("Requested crate not found")]
Expand Down Expand Up @@ -76,6 +101,32 @@ impl AxumNope {
status: StatusCode::NOT_FOUND,
}
}
AxumNope::ResourceNotFoundInVersion {
name,
version,
is_latest_url,
..
} => {
// The crate & version exist and have docs, but this specific
// page within them does not (e.g. a module removed/renamed in a
// new release reached via `/latest/`). Acknowledge the version
// and offer recovery links instead of a bare 404 (issue #2568).
// The recovery links themselves are built in `recovery_links`.
let existing = if is_latest_url {
format!("The latest version of `{name}` ({version})")
} else {
format!("Version {version} of `{name}`")
};
ErrorInfo {
title: "This page does not exist",
message: format!(
"{existing} exists, but this page inside it could not be found. \
It may have been moved or removed."
)
.into(),
status: StatusCode::NOT_FOUND,
}
}
AxumNope::BuildNotFound => ErrorInfo {
title: "The requested build does not exist",
message: "no such build".into(),
Expand Down Expand Up @@ -137,6 +188,29 @@ impl AxumNope {
AxumNope::Redirect(_target, _cache_policy) => unreachable!(),
}
}

/// Navigation links offered on the error page to help the user recover.
/// Empty for every error except the contextual missing-page 404 (#2568).
fn recovery_links(&self) -> Vec<RecoveryLink> {
match self {
AxumNope::ResourceNotFoundInVersion {
name,
version_root_url,
crate_details_url,
..
} => vec![
RecoveryLink {
label: "Documentation home for this version".into(),
href: version_root_url.clone(),
},
RecoveryLink {
label: format!("All versions of {name}").into(),
href: crate_details_url.clone(),
},
],
_ => Vec::new(),
}
}
}

struct ErrorInfo {
Expand Down Expand Up @@ -169,6 +243,7 @@ impl IntoResponse for AxumNope {
}
AxumNope::Redirect(target, cache_policy) => redirect_with_policy(target, cache_policy),
_ => {
let recovery = self.recovery_links();
let ErrorInfo {
title,
message,
Expand All @@ -178,6 +253,7 @@ impl IntoResponse for AxumNope {
title,
message,
status,
recovery,
}
.into_response()
}
Expand All @@ -192,25 +268,38 @@ impl IntoResponse for JsonAxumNope {
fn into_response(self) -> AxumResponse {
match self.0 {
AxumNope::NoResults => {
// user did a search with no search terms; invalid,
// return 404
// User searched without providing search terms.
StatusCode::NOT_FOUND.into_response()
}
AxumNope::Redirect(target, cache_policy) => redirect_with_policy(target, cache_policy),
_ => {
let recovery = self.0.recovery_links();
let ErrorInfo {
title,
message,
status,
} = self.0.into_error_info();
(
status,
Json(serde_json::json!({
"title": title,
"message": message,
})),
)
.into_response()

let mut body = serde_json::json!({
"title": title,
"message": message,
});

if !recovery.is_empty() {
let links: Vec<_> = recovery
.iter()
.map(|link| {
serde_json::json!({
"label": link.label,
"href": link.href,
})
})
.collect();

body["links"] = serde_json::json!(links);
}

(status, Json(body)).into_response()
}
}
}
Expand Down Expand Up @@ -245,7 +334,7 @@ pub(crate) type JsonAxumResult<T> = Result<T, JsonAxumNope>;

#[cfg(test)]
mod tests {
use super::{AxumNope, EscapedURI, IntoResponse};
use super::{AxumNope, EscapedURI, IntoResponse, JsonAxumNope};
use crate::cache::CachePolicy;
use crate::testing::{
AxumResponseTestExt, AxumRouterTestExt, TestEnvironmentExt as _, async_wrapper,
Expand Down Expand Up @@ -387,4 +476,132 @@ mod tests {
Ok(())
});
}

/// Helper: hrefs of the recovery links rendered on an error page.
fn recovery_hrefs(html: &str) -> Vec<String> {
kuchikiki::parse_html()
.one(html)
.select("#recovery-links a")
.unwrap()
.map(|a| {
a.attributes
.borrow()
.get("href")
.unwrap_or_default()
.to_string()
})
.collect()
}

#[test]
fn check_404_missing_child_latest_offers_recovery_links() {
async_wrapper(|env| async move {
env.fake_release()
.await
.name("dummy")
.version("0.1.0")
.rustdoc_file("dummy/index.html")
.create()
.await?;

let response = env
.web_app()
.await
.get("/dummy/latest/dummy/removed_module/index.html")
.await?;
assert_eq!(response.status(), 404);

let body = response.text().await?;
let page = kuchikiki::parse_html().one(body.as_str());
assert_eq!(
page.select("#crate-title")
.unwrap()
.next()
.unwrap()
.text_contents(),
"This page does not exist",
);

// Two recovery links, preserving the `/latest/` alias.
let hrefs = recovery_hrefs(&body);
assert_eq!(hrefs.len(), 2);
assert!(hrefs.iter().any(|h| h == "/dummy/latest/dummy/"));
assert!(hrefs.iter().any(|h| h == "/crate/dummy/latest"));

Ok(())
});
}

#[test]
fn check_404_missing_child_pinned_keeps_pinned_version() {
async_wrapper(|env| async move {
env.fake_release()
.await
.name("dummy")
.version("0.1.0")
.rustdoc_file("dummy/index.html")
.create()
.await?;

let response = env
.web_app()
.await
.get("/dummy/0.1.0/dummy/removed_module/index.html")
.await?;
assert_eq!(response.status(), 404);

let body = response.text().await?;
let hrefs = recovery_hrefs(&body);
assert_eq!(hrefs.len(), 2);
assert!(hrefs.iter().any(|h| h == "/dummy/0.1.0/dummy/"));
assert!(hrefs.iter().any(|h| h == "/crate/dummy/0.1.0"));
// Pinned requests must not be rewritten to `latest`.
assert!(hrefs.iter().all(|h| !h.contains("latest")));

Ok(())
});
}

#[test]
fn check_404_generic_resource_has_no_recovery_links() {
async_wrapper(|env| async move {
// A resource outside any existing crate keeps the plain 404 with no
// recovery links (regression: other error types are unaffected).
let body = env
.web_app()
.await
.get("/resource-which-doesnt-exist.js")
.await?
.text()
.await?;
let page = kuchikiki::parse_html().one(body.as_str());
assert_eq!(page.select("#recovery-links").unwrap().count(), 0);

Ok(())
});
}

#[test]
fn json_error_body_includes_recovery_links() {
async_wrapper(|_env| async move {
let response = JsonAxumNope(AxumNope::ResourceNotFoundInVersion {
name: "dummy".into(),
version: "0.1.0".into(),
is_latest_url: true,
version_root_url: EscapedURI::from_path("/dummy/latest/dummy/"),
crate_details_url: EscapedURI::from_path("/crate/dummy/latest"),
})
.into_response();

assert_eq!(response.status(), 404);

let body: serde_json::Value = response.json().await?;
let links = body["links"].as_array().unwrap();
assert_eq!(links.len(), 2);
assert_eq!(links[0]["href"], "/dummy/latest/dummy/");
assert_eq!(links[1]["href"], "/crate/dummy/latest");

Ok(())
});
}
}
1 change: 1 addition & 0 deletions crates/bin/docs_rs_web/src/handlers/about.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ pub(crate) async fn about_handler(subpage: Option<Path<String>>) -> AxumResult<i
title: "The requested page does not exist",
message: msg.into(),
status: StatusCode::NOT_FOUND,
recovery: Vec::new(),
};
page.into_response()
}
Expand Down
13 changes: 12 additions & 1 deletion crates/bin/docs_rs_web/src/handlers/rustdoc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -725,7 +725,18 @@ pub(crate) async fn rustdoc_html_server_handler(
)
}

return Err(AxumNope::ResourceNotFound);
// The crate and version exist and have docs, but this specific
// page within them does not (e.g. a module removed/renamed in a
// newer release reached via `/latest/`). Return a 404 that
// acknowledges the version and offers recovery links instead of a
// bare "resource not found" (issue #2568).
return Err(AxumNope::ResourceNotFoundInVersion {
name: params.name().to_string(),
version: krate.version.to_string(),
is_latest_url: params.req_version().is_latest(),
version_root_url: params.clone().with_inner_path("").rustdoc_url(),
crate_details_url: params.crate_details_url(),
});
}
};

Expand Down
9 changes: 8 additions & 1 deletion crates/bin/docs_rs_web/templates/error.html
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
{% extends "base.html" %}

{%- block header -%}
<div class="docsrs-package-container">
<div class="docsrs-package-container error-header">
<div class="container">
<h1 id="crate-title">{{ title }}</h1>
</div>
</div>
<div class="description">{{ message }}</div>
{%- if !recovery.is_empty() -%}
<div id="recovery-links">
{%- for link in recovery -%}
<a class="pure-button pure-button-normal" href="{{ link.href }}">{{ link.label }}</a>
{%- endfor -%}
</div>
{%- endif -%}
{%- endblock header -%}

{%- block topbar -%}
Expand Down
18 changes: 18 additions & 0 deletions crates/bin/docs_rs_web/templates/style/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,12 @@ div.docsrs-package-container {
border-bottom: 1px solid var(--color-border);
margin-bottom: 20px;

// On the error page the body text below is centered against the full page
// width, so the title bar has to be centered too or the two disagree.
&.error-header .container {
margin: 0 auto;
}

.container {
display: flex;
align-items: center;
Expand Down Expand Up @@ -884,6 +890,18 @@ div.search-page-search-form {
display: inline-block;
}

#recovery-links {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
padding: 10px 14px 14px;

.pure-button {
text-decoration: none;
}
}

#clipboard {
cursor: pointer;
}
Expand Down
Loading
Loading