From 6d6722504596d5ee9b347d817f798435708af517 Mon Sep 17 00:00:00 2001 From: Denis Cornehl Date: Mon, 24 Nov 2025 21:44:11 +0100 Subject: [PATCH 1/3] add ETag & conditional get for rustdoc HTML pages --- Cargo.lock | 35 ++++- Cargo.toml | 1 + src/db/types/version.rs | 14 ++ src/registry_api.rs | 12 +- src/test/mod.rs | 80 ++++++++--- src/utils/cargo_metadata.rs | 16 +++ src/web/cache.rs | 15 +- src/web/escaped_uri.rs | 17 +++ src/web/extractors/rustdoc.rs | 4 +- src/web/headers/mod.rs | 43 +++++- src/web/licenses.rs | 2 +- src/web/mod.rs | 28 +++- src/web/rustdoc.rs | 257 +++++++++++++++++++++++++++------- templates/rustdoc/topbar.html | 24 ++-- 14 files changed, 452 insertions(+), 96 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 76d00c686..005e2d6d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1080,6 +1080,26 @@ dependencies = [ "serde", ] +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1998,6 +2018,7 @@ dependencies = [ "axum-extra", "backtrace", "base64 0.22.1", + "bincode 2.0.1", "bzip2", "chrono", "clap", @@ -7982,7 +8003,7 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" dependencies = [ - "bincode", + "bincode 1.3.3", "flate2", "fnv", "once_cell", @@ -8657,6 +8678,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "ureq" version = "3.1.4" @@ -8752,6 +8779,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + [[package]] name = "vsimd" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index 3cabb69ab..de2fde142 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,6 +86,7 @@ uuid = { version = "1.1.2", features = ["v4"]} serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_with = "3.4.0" +bincode = "2.0.1" # axum dependencies async-trait = "0.1.83" diff --git a/src/db/types/version.rs b/src/db/types/version.rs index 6824c0ac2..f0ca2b8a2 100644 --- a/src/db/types/version.rs +++ b/src/db/types/version.rs @@ -40,6 +40,20 @@ mod version_impl { } } + impl bincode::Encode for Version { + fn encode( + &self, + encoder: &mut E, + ) -> Result<(), bincode::error::EncodeError> { + self.0.major.encode(encoder)?; + self.0.minor.encode(encoder)?; + self.0.patch.encode(encoder)?; + bincode::Encode::encode(self.0.pre.as_str(), encoder)?; + bincode::Encode::encode(self.0.build.as_str(), encoder)?; + Ok(()) + } + } + impl Type for Version { fn type_info() -> PgTypeInfo { >::type_info() diff --git a/src/registry_api.rs b/src/registry_api.rs index da6a018fe..d3371d068 100644 --- a/src/registry_api.rs +++ b/src/registry_api.rs @@ -44,7 +44,17 @@ pub struct CrateOwner { } #[derive( - Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, sqlx::Type, + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Serialize, + Deserialize, + sqlx::Type, + bincode::Encode, )] #[sqlx(type_name = "owner_kind", rename_all = "lowercase")] #[serde(rename_all = "lowercase")] diff --git a/src/test/mod.rs b/src/test/mod.rs index 8a1230ff9..18addfb02 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -22,18 +22,21 @@ use crate::{ page::TemplateData, }, }; -use anyhow::Context as _; +use anyhow::{Context as _, anyhow}; use axum::body::Bytes; use axum::{Router, body::Body, http::Request, response::Response as AxumResponse}; use axum_extra::headers::{ETag, HeaderMapExt as _}; use fn_error_context::context; use futures_util::stream::TryStreamExt; -use http::{HeaderMap, StatusCode, header::CACHE_CONTROL}; +use http::{ + HeaderMap, HeaderName, HeaderValue, StatusCode, + header::{CACHE_CONTROL, CONTENT_TYPE}, +}; use http_body_util::BodyExt; use opentelemetry_sdk::metrics::InMemoryMetricExporter; use serde::de::DeserializeOwned; use sqlx::Connection as _; -use std::{fs, future::Future, panic, rc::Rc, str::FromStr, sync::Arc}; +use std::{collections::HashMap, fs, future::Future, panic, rc::Rc, str::FromStr, sync::Arc}; use tokio::{runtime, task::block_in_place}; use tower::ServiceExt; use tracing::error; @@ -137,11 +140,13 @@ pub(crate) trait AxumRouterTestExt { config: &Config, ) -> Result; async fn assert_not_found(&self, path: &str) -> Result<()>; - async fn assert_success_and_conditional_get( + async fn assert_conditional_get( &self, - path: &str, - expected_body: &str, + initial_path: &str, + uncached_response: &AxumResponse, ) -> Result<()>; + async fn assert_success_and_conditional_get(&self, path: &str) -> Result<()>; + async fn assert_success_cached( &self, path: &str, @@ -187,37 +192,66 @@ impl AxumRouterTestExt for axum::Router { Ok(response) } - async fn assert_success_and_conditional_get( + async fn assert_conditional_get( &self, - path: &str, - expected_body: &str, + initial_path: &str, + uncached_response: &AxumResponse, ) -> Result<()> { - let etag: ETag = { - // uncached response - let response = self.assert_success(path).await?; - let etag: ETag = response.headers().typed_get().unwrap(); + let etag: ETag = uncached_response + .headers() + .typed_get() + .ok_or_else(|| anyhow!("missing ETag header"))?; - assert_eq!(response.text().await?, expected_body); + let if_none_match = IfNoneMatch::from(etag.clone()); - etag - }; + // general rule: + // + // if a header influences how any client or intermediate proxy should treat the response, + // it should be repeated on the 304 response. + // + // This logic assumes _all_ headers have to be repeated, except for a few known ones. + const NON_CACHE_HEADERS: &[&HeaderName] = &[&CONTENT_TYPE]; - let if_none_match = IfNoneMatch::from(etag.clone()); + // store original headers, to assert that they are repeated on the 304 response. + let original_headers: HashMap = uncached_response + .headers() + .iter() + .filter(|(k, _)| !NON_CACHE_HEADERS.contains(k)) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); { - // cached response - let response = self - .get_with_headers(path, |headers| { + let cached_response = self + .get_with_headers(initial_path, |headers| { + headers.insert(X_RLNG_SOURCE_CDN, HeaderValue::from_static("fastly")); headers.typed_insert(if_none_match); }) .await?; - assert_eq!(response.status(), StatusCode::NOT_MODIFIED); - // etag is repeated - assert_eq!(response.headers().typed_get::().unwrap(), etag); + assert_eq!(cached_response.status(), StatusCode::NOT_MODIFIED); + + // most headers are repeated on the 304 response. + let cached_response_headers: HashMap = cached_response + .headers() + .iter() + .filter_map(|(k, v)| { + if original_headers.contains_key(k) { + Some((k.clone(), v.clone())) + } else { + None + } + }) + .collect(); + + assert_eq!(original_headers, cached_response_headers); } Ok(()) } + async fn assert_success_and_conditional_get(&self, path: &str) -> Result<()> { + self.assert_conditional_get(path, &self.assert_success(path).await?) + .await + } + async fn assert_not_found(&self, path: &str) -> Result<()> { let response = self.get(path).await?; diff --git a/src/utils/cargo_metadata.rs b/src/utils/cargo_metadata.rs index f5327d686..e432e5620 100644 --- a/src/utils/cargo_metadata.rs +++ b/src/utils/cargo_metadata.rs @@ -135,6 +135,22 @@ pub(crate) struct Dependency { pub(crate) optional: bool, } +impl bincode::Encode for Dependency { + fn encode( + &self, + encoder: &mut E, + ) -> Result<(), bincode::error::EncodeError> { + self.name.encode(encoder)?; + // FIXME: VersionReq does not implement Encode, so we serialize it to string + // Could be fixable by wrapping VersionReq in a newtype + self.req.to_string().encode(encoder)?; + self.kind.encode(encoder)?; + self.rename.encode(encoder)?; + self.optional.encode(encoder)?; + Ok(()) + } +} + impl Dependency { #[cfg(test)] pub fn new(name: String, req: VersionReq) -> Dependency { diff --git a/src/web/cache.rs b/src/web/cache.rs index bfbd1e666..1f8cbb52d 100644 --- a/src/web/cache.rs +++ b/src/web/cache.rs @@ -13,7 +13,11 @@ use axum::{ response::Response as AxumResponse, }; use axum_extra::headers::HeaderMapExt as _; -use http::{HeaderMap, HeaderName, HeaderValue, header::CACHE_CONTROL, request::Parts}; +use http::{ + HeaderMap, HeaderName, HeaderValue, StatusCode, + header::{CACHE_CONTROL, ETAG}, + request::Parts, +}; use serde::Deserialize; use std::{convert::Infallible, sync::Arc}; use tracing::error; @@ -235,6 +239,15 @@ pub(crate) async fn cache_middleware( response.headers(), ); + debug_assert!( + response.status() == StatusCode::NOT_MODIFIED + || response.status().is_success() + || !response.headers().contains_key(ETAG), + "only successful or not-modified responses should have etags. \n{:?}\n{:?}", + response.status(), + response.headers(), + ); + // extract cache policy, default to "forbid caching everywhere". // We only use cache policies in our successful responses (with content, or redirect), // so any errors (4xx, 5xx) should always get "NoCaching". diff --git a/src/web/escaped_uri.rs b/src/web/escaped_uri.rs index a64f76808..2e292365a 100644 --- a/src/web/escaped_uri.rs +++ b/src/web/escaped_uri.rs @@ -19,6 +19,23 @@ pub struct EscapedURI { fragment: Option, } +impl bincode::Encode for EscapedURI { + fn encode( + &self, + encoder: &mut E, + ) -> Result<(), bincode::error::EncodeError> { + // encode as separate parts so we don't have to clone + self.uri.scheme_str().encode(encoder)?; + self.uri.authority().map(|a| a.as_str()).encode(encoder)?; + self.uri + .path_and_query() + .map(|pq| pq.as_str()) + .encode(encoder)?; + self.fragment.encode(encoder)?; + Ok(()) + } +} + impl EscapedURI { pub fn from_uri(uri: Uri) -> Self { if uri.path_and_query().is_some() { diff --git a/src/web/extractors/rustdoc.rs b/src/web/extractors/rustdoc.rs index 8ceec8499..e20ffda1e 100644 --- a/src/web/extractors/rustdoc.rs +++ b/src/web/extractors/rustdoc.rs @@ -26,7 +26,7 @@ pub(crate) const ROOT_RUSTDOC_HTML_FILES: &[&str] = &[ "scrape-examples-help.html", ]; -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, bincode::Encode)] pub(crate) enum PageKind { Rustdoc, Source, @@ -42,7 +42,7 @@ pub(crate) enum PageKind { /// All of these have more or less detail depending on how much metadata we have here. /// Maintains some additional fields containing "fixed" things, whos quality /// gets better the more metadata we provide. -#[derive(Clone, PartialEq)] +#[derive(Clone, PartialEq, bincode::Encode)] pub(crate) struct RustdocParams { // optional behaviour marker page_kind: Option, diff --git a/src/web/headers/mod.rs b/src/web/headers/mod.rs index 03525d402..b51b292e2 100644 --- a/src/web/headers/mod.rs +++ b/src/web/headers/mod.rs @@ -3,8 +3,10 @@ mod if_none_match; mod surrogate_key; use axum_extra::headers::ETag; -pub use canonical_url::CanonicalUrl; use http::HeaderName; +use std::io::{self, Write}; + +pub use canonical_url::CanonicalUrl; pub(crate) use if_none_match::IfNoneMatch; pub use surrogate_key::{SURROGATE_KEY, SurrogateKey, SurrogateKeys}; @@ -12,10 +14,45 @@ pub use surrogate_key::{SURROGATE_KEY, SurrogateKey, SurrogateKeys}; /// https://www.fastly.com/documentation/reference/http/http-headers/Surrogate-Control/ pub static SURROGATE_CONTROL: HeaderName = HeaderName::from_static("surrogate-control"); +/// X-Robots-Tag header for search engines. +pub static X_ROBOTS_TAG: HeaderName = HeaderName::from_static("x-robots-tag"); + /// compute our etag header value from some content /// /// Has to match the implementation in our build-script. pub fn compute_etag>(content: T) -> ETag { - let digest = md5::compute(&content); - format!("\"{:x}\"", digest).parse().unwrap() + let mut computer = ETagComputer::new(); + computer.write_all(content.as_ref()).unwrap(); + computer.finalize() +} + +/// Helper type to compute ETag values. +/// +/// Works the same way as the inner `md5::Context`, +/// but produces an `ETag` when finalized. +pub(crate) struct ETagComputer(md5::Context); + +impl ETagComputer { + pub fn new() -> Self { + Self(md5::Context::new()) + } + + pub fn consume>(&mut self, data: T) { + self.0.consume(data.as_ref()); + } + + pub fn finalize(self) -> ETag { + let digest = self.0.finalize(); + format!("\"{:x}\"", digest).parse().unwrap() + } +} + +impl io::Write for ETagComputer { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.0.write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.0.flush() + } } diff --git a/src/web/licenses.rs b/src/web/licenses.rs index 11ac8b8c3..c3bd537ac 100644 --- a/src/web/licenses.rs +++ b/src/web/licenses.rs @@ -8,7 +8,7 @@ static LICENSE_ID_REGEX: LazyLock = LazyLock::new(|| Regex::new("[A-Za-z0-9.-]+").expect("Known regex must compile")); /// A segment of an SPDX license string -#[derive(Clone, PartialEq, Eq, Debug)] +#[derive(Clone, PartialEq, Eq, Debug, bincode::Encode)] pub enum LicenseSegment { /// This is a set of glue tokens like OR, AND, `/`, `(`, `)`, etc. /// diff --git a/src/web/mod.rs b/src/web/mod.rs index 0c0c8d13a..e05017863 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -16,6 +16,7 @@ use crate::{ use anyhow::{Context as _, Result, anyhow, bail}; use askama::Template; use axum_extra::middleware::option_layer; +use serde::Serialize; use serde_json::Value; use tracing::{info, instrument}; @@ -102,6 +103,30 @@ impl ReqVersion { } } +impl bincode::Encode for ReqVersion { + fn encode( + &self, + encoder: &mut E, + ) -> Result<(), bincode::error::EncodeError> { + // manual implementation since VersionReq doesn't implement Encode, + // and I don't want to NewType it right now. + match self { + ReqVersion::Exact(v) => { + 0u8.encode(encoder)?; + v.encode(encoder) + } + ReqVersion::Semver(req) => { + 1u8.encode(encoder)?; + req.to_string().encode(encoder) + } + ReqVersion::Latest => { + 2u8.encode(encoder)?; + Ok(()) + } + } + } +} + impl Display for ReqVersion { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -662,8 +687,7 @@ where } /// MetaData used in header -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(test, derive(serde::Serialize))] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, bincode::Encode)] pub(crate) struct MetaData { pub(crate) name: String, /// The exact version of the release being shown. diff --git a/src/web/rustdoc.rs b/src/web/rustdoc.rs index a646d38df..1a3f564cb 100644 --- a/src/web/rustdoc.rs +++ b/src/web/rustdoc.rs @@ -1,12 +1,13 @@ //! rustdoc handler use crate::{ - AsyncStorage, Config, InstanceMetrics, RUSTDOC_STATIC_STORAGE_PREFIX, + AsyncStorage, BUILD_VERSION, Config, InstanceMetrics, RUSTDOC_STATIC_STORAGE_PREFIX, + registry_api::OwnerKind, storage::{ CompressionAlgorithm, RustdocJsonFormatVersion, StreamingBlob, compression::compression_from_file_extension, rustdoc_archive_path, rustdoc_json_path, }, - utils, + utils::{self, Dependency}, web::{ MetaData, ReqVersion, axum_cached_redirect, cache::CachePolicy, @@ -19,8 +20,8 @@ use crate::{ rustdoc::{PageKind, RustdocParams}, }, file::StreamingFile, - headers::IfNoneMatch, - match_version, + headers::{ETagComputer, IfNoneMatch, X_ROBOTS_TAG}, + licenses, match_version, metrics::WebMetrics, page::{ TemplateData, @@ -36,8 +37,11 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response as AxumResponse}, }; -use axum_extra::{headers::ContentType, typed_header::TypedHeader}; -use http::{Uri, uri::Authority}; +use axum_extra::{ + headers::{ContentType, ETag, Header as _, HeaderMapExt as _}, + typed_header::TypedHeader, +}; +use http::{HeaderMap, Uri, uri::Authority}; use serde::Deserialize; use std::{ collections::HashMap, @@ -373,7 +377,69 @@ pub(crate) async fn rustdoc_redirector_handler( } } -#[derive(Template)] +/// small wrapper around CrateDetails to limit serialized fields we hand +/// to the template. +/// Mostly to know what we have to serialize into the etag. +pub struct LimitedCrateDetails(CrateDetails); + +impl From for LimitedCrateDetails { + fn from(value: CrateDetails) -> Self { + Self(value) + } +} + +impl LimitedCrateDetails { + pub fn parsed_license(&self) -> Option<&[licenses::LicenseSegment]> { + self.0.parsed_license.as_deref() + } + + pub fn homepage_url(&self) -> Option<&str> { + self.0.homepage_url.as_deref() + } + + pub fn documentation_url(&self) -> Option<&str> { + self.0.documentation_url.as_deref() + } + + pub fn repository_url(&self) -> Option<&str> { + self.0.repository_url.as_deref() + } + + pub fn owners(&self) -> &[(String, String, OwnerKind)] { + self.0.owners.as_ref() + } + + pub fn dependencies(&self) -> &[Dependency] { + self.0.dependencies.as_ref() + } + + pub fn total_items(&self) -> Option { + self.0.total_items + } + + pub fn documented_items(&self) -> Option { + self.0.documented_items + } +} + +impl bincode::Encode for LimitedCrateDetails { + fn encode( + &self, + encoder: &mut E, + ) -> Result<(), bincode::error::EncodeError> { + self.parsed_license().encode(encoder)?; + self.homepage_url().encode(encoder)?; + self.documentation_url().encode(encoder)?; + self.repository_url().encode(encoder)?; + self.owners().encode(encoder)?; + self.dependencies().encode(encoder)?; + self.total_items().encode(encoder)?; + self.documented_items().encode(encoder)?; + Ok(()) + } +} + +#[derive(Template, bincode::Encode)] #[template(path = "rustdoc/topbar.html")] pub struct RustdocPage { pub latest_path: EscapedURI, @@ -384,13 +450,56 @@ pub struct RustdocPage { // true if the URL specifies a version using the string "latest." pub is_latest_url: bool, pub is_prerelease: bool, - pub krate: CrateDetails, + pub krate: LimitedCrateDetails, pub metadata: MetaData, pub current_target: String, params: RustdocParams, } impl RustdocPage { + /// generate an ETag for this rustdoc page, currently based on + /// * the ETag of the original rustdoc HTML file + /// * the BUILD_VERION + /// * the serialized RustdocPage struct + /// + /// we might not use all of the details in html rewriting, so we might + /// change the etag more often than we could, but this is for now the + /// safe and easy way. + /// + /// Can be optimized by removing data from the struct or its children + /// that we don't need in the HTML rewriting. + #[instrument(skip_all)] + fn generate_etag(&self, original_rustdoc_html_etag: &ETag) -> ETag { + let mut etag = ETagComputer::new(); + + // a new release might change the HTML we generate + etag.consume(BUILD_VERSION); + + { + // add the etag of the original rustdoc file from storage. + // + // This is a little annoying, there is no other way to get the inner + // entity-tag value out of an `headers::ETag`. + let mut map = HeaderMap::with_capacity(1); + map.typed_insert(original_rustdoc_html_etag.clone()); + etag.consume(map.get(ETag::name()).expect("we just inserted this header")); + } + + // we assume that all the info we put into the `RustdocPage` struct might change the + // page content. So we have to pipe all of it into the ETag. + // I chose to add the additional bincode dependency because I was worried about the + // added processing time when handling these responses, since this is our + // most accessed handler on the origin. + let config = bincode::config::standard() + .with_big_endian() + .with_variable_int_encoding(); + bincode::encode_into_std_write(self, &mut etag, config) + .expect("bincode::Encode impl in RustdocPage can't fail"); + + etag.finalize() + } + + #[instrument(skip_all)] async fn into_response( self: &Arc, template_data: Arc, @@ -398,28 +507,49 @@ impl RustdocPage { otel_metrics: Arc, rustdoc_html: StreamingBlob, max_parse_memory: usize, - ) -> AxumResult { - let is_latest_url = self.is_latest_url; + if_none_match: Option<&IfNoneMatch>, + ) -> AxumResponse { + let cache_policy = if self.is_latest_url { + CachePolicy::ForeverInCdn + } else { + CachePolicy::ForeverInCdnAndStaleInBrowser + }; + let robots_tag = (!self.is_latest_url).then_some([(&X_ROBOTS_TAG, "noindex")]); - Ok(( - StatusCode::OK, - (!is_latest_url).then_some([("X-Robots-Tag", "noindex")]), - Extension(if is_latest_url { - CachePolicy::ForeverInCdn - } else { - CachePolicy::ForeverInCdnAndStaleInBrowser - }), - TypedHeader(ContentType::from(mime::TEXT_HTML_UTF_8)), - Body::from_stream(utils::rewrite_rustdoc_html_stream( - template_data, - rustdoc_html.content, - max_parse_memory, - self.clone(), - metrics, - otel_metrics, - )), - ) - .into_response()) + let etag = rustdoc_html + .etag + .as_ref() + .map(|etag| self.generate_etag(etag)); + + if let Some(if_none_match) = if_none_match + && let Some(ref etag) = etag + && !if_none_match.precondition_passes(etag) + { + ( + StatusCode::NOT_MODIFIED, + robots_tag, + TypedHeader(etag.clone()), + Extension(cache_policy), + ) + .into_response() + } else { + ( + StatusCode::OK, + robots_tag, + etag.map(TypedHeader), + Extension(cache_policy), + TypedHeader(ContentType::from(mime::TEXT_HTML_UTF_8)), + Body::from_stream(utils::rewrite_rustdoc_html_stream( + template_data, + rustdoc_html.content, + max_parse_memory, + self.clone(), + metrics, + otel_metrics, + )), + ) + .into_response() + } } pub(crate) fn use_direct_platform_links(&self) -> bool { @@ -650,17 +780,19 @@ pub(crate) async fn rustdoc_html_server_handler( is_prerelease, metadata: krate.metadata.clone(), current_target: current_target.to_owned(), - krate, + krate: krate.into(), params, }); - page.into_response( - templates, - metrics, - otel_metrics, - blob, - config.max_parse_memory, - ) - .await + Ok(page + .into_response( + templates, + metrics, + otel_metrics, + blob, + config.max_parse_memory, + if_none_match.as_deref(), + ) + .await) } #[instrument(skip_all)] @@ -1008,6 +1140,9 @@ mod test { env.config(), ) .await?; + + web.assert_success_and_conditional_get("/krate/0.1.0/help.html") + .await?; Ok(()) }); } @@ -1125,7 +1260,8 @@ mod test { ) .await?; - web.assert_success("/dummy/latest/dummy/").await?; + web.assert_success_and_conditional_get("/dummy/latest/dummy/") + .await?; // set an explicit target that requires cross-compile let target = "x86_64-pc-windows-msvc"; @@ -1139,7 +1275,7 @@ mod test { .create() .await?; let base = "/dummy/0.2.0/dummy/"; - web.assert_success(base).await?; + web.assert_success_and_conditional_get(base).await?; web.assert_redirect("/dummy/0.2.0/x86_64-pc-windows-msvc/dummy/", base) .await?; @@ -1226,11 +1362,15 @@ mod test { { let resp = web.get("/dummy/latest/dummy/").await?; resp.assert_cache_control(CachePolicy::ForeverInCdn, env.config()); + web.assert_conditional_get("/dummy/latest/dummy/", &resp) + .await?; } { let resp = web.get("/dummy/0.1.0/dummy/").await?; resp.assert_cache_control(CachePolicy::ForeverInCdnAndStaleInBrowser, env.config()); + web.assert_conditional_get("/dummy/0.1.0/dummy/", &resp) + .await?; } Ok(()) } @@ -2999,7 +3139,15 @@ mod test { let web = env.web_app().await; - web.assert_success_and_conditional_get(&format!("/dummy/0.1.0/{name}"), "content") + assert_eq!( + web.assert_success(&format!("/dummy/0.1.0/{name}")) + .await? + .text() + .await?, + "content" + ); + + web.assert_success_and_conditional_get(&format!("/dummy/0.1.0/{name}")) .await?; Ok(()) @@ -3022,11 +3170,16 @@ mod test { let web = env.web_app().await; - web.assert_success_and_conditional_get( - &format!("/-/rustdoc.static/{path}"), - "static content", - ) - .await?; + assert_eq!( + web.assert_success(&format!("/-/rustdoc.static/{path}"),) + .await? + .text() + .await?, + "static content" + ); + + web.assert_success_and_conditional_get(&format!("/-/rustdoc.static/{path}")) + .await?; Ok(()) } @@ -3068,10 +3221,14 @@ mod test { response.headers().get("Location"), ); - web.assert_success_and_conditional_get(&format!("/{ROOT_ASSET}"), "content") - .await?; - web.assert_success_and_conditional_get(&format!("/dummy/0.1.0/{path}"), "more_content") - .await?; + for (path, expected_content) in [ + (format!("/{ROOT_ASSET}"), "content"), + (format!("/dummy/0.1.0/{path}"), "more_content"), + ] { + let resp = web.assert_success(&path).await?; + web.assert_conditional_get(&path, &resp).await?; + assert_eq!(resp.text().await?, expected_content); + } Ok(()) }) diff --git a/templates/rustdoc/topbar.html b/templates/rustdoc/topbar.html index c0bc8929a..45bd5527d 100644 --- a/templates/rustdoc/topbar.html +++ b/templates/rustdoc/topbar.html @@ -13,9 +13,9 @@ {%- if krate is defined -%}
  • - + {{ crate::icons::IconCube.render_solid(false, false, "") }} - {{ krate.name }}-{{ krate.version }} + {{ metadata.name }}-{{ metadata.version }} {#- Crate details -#} @@ -23,7 +23,7 @@ {# Crate name, description and license #}