Skip to content

Add rustdoc JSON download endpoints & about-page #2832

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 25, 2025
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
9 changes: 4 additions & 5 deletions src/docbuilder/rustwide_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1494,12 +1494,11 @@ mod tests {
.map(|f| f.strip_prefix(&json_prefix).unwrap().to_owned())
.collect();
json_files.sort();
assert!(json_files[0].starts_with(&format!("empty-library_1.0.0_{target}_")));
assert!(json_files[0].ends_with(".json.zst"));
assert_eq!(
json_files,
vec![
format!("empty-library_1.0.0_{target}_45.json.zst"),
format!("empty-library_1.0.0_{target}_latest.json.zst"),
]
json_files[1],
format!("empty-library_1.0.0_{target}_latest.json.zst")
);

if target == &default_target {
Expand Down
36 changes: 34 additions & 2 deletions src/storage/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,16 @@ use fn_error_context::context;
use futures_util::stream::BoxStream;
use mime::Mime;
use path_slash::PathExt;
use std::iter;
use serde_with::{DeserializeFromStr, SerializeDisplay};
use std::{
fmt, fs,
io::{self, BufReader},
num::ParseIntError,
ops::RangeInclusive,
path::{Path, PathBuf},
sync::Arc,
};
use std::{iter, str::FromStr};
use tokio::{io::AsyncWriteExt, runtime::Runtime};
use tracing::{error, info_span, instrument, trace};
use walkdir::WalkDir;
Expand Down Expand Up @@ -815,14 +817,25 @@ pub(crate) fn rustdoc_archive_path(name: &str, version: &str) -> String {
format!("rustdoc/{name}/{version}.zip")
}

#[derive(strum::Display, Debug, PartialEq, Eq)]
#[derive(strum::Display, Debug, PartialEq, Eq, Clone, SerializeDisplay, DeserializeFromStr)]
#[strum(serialize_all = "snake_case")]
pub(crate) enum RustdocJsonFormatVersion {
#[strum(serialize = "{0}")]
Version(u16),
Latest,
}

impl FromStr for RustdocJsonFormatVersion {
type Err = ParseIntError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s == "latest" {
Ok(RustdocJsonFormatVersion::Latest)
} else {
s.parse::<u16>().map(RustdocJsonFormatVersion::Version)
}
}
}

pub(crate) fn rustdoc_json_path(
name: &str,
version: &str,
Expand All @@ -842,6 +855,25 @@ pub(crate) fn source_archive_path(name: &str, version: &str) -> String {
mod test {
use super::*;
use std::env;
use test_case::test_case;

#[test_case("latest", RustdocJsonFormatVersion::Latest)]
#[test_case("42", RustdocJsonFormatVersion::Version(42))]
fn test_json_format_version(input: &str, expected: RustdocJsonFormatVersion) {
// test Display
assert_eq!(expected.to_string(), input);
// test FromStr
assert_eq!(expected, input.parse().unwrap());

let json_input = format!("\"{input}\"");
// test Serialize
assert_eq!(serde_json::to_string(&expected).unwrap(), json_input);
// test Deserialize
assert_eq!(
serde_json::from_str::<RustdocJsonFormatVersion>(&json_input).unwrap(),
expected
);
}

#[test]
fn test_get_file_list() -> Result<()> {
Expand Down
10 changes: 10 additions & 0 deletions src/web/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ pub enum AxumNope {
OwnerNotFound,
#[error("Requested crate does not have specified version")]
VersionNotFound,
#[error("Requested release doesn't have docs for the given target")]
TargetNotFound,
#[error("Search yielded no results")]
NoResults,
#[error("Unauthorized: {0}")]
Expand Down Expand Up @@ -77,6 +79,14 @@ impl AxumNope {
message: "no such build".into(),
status: StatusCode::NOT_FOUND,
},
AxumNope::TargetNotFound => {
// user tried to navigate to a target that doesn't exist
ErrorInfo {
title: "The requested target does not exist",
message: "no such target".into(),
status: StatusCode::NOT_FOUND,
}
}
AxumNope::CrateNotFound => {
// user tried to navigate to a crate that doesn't exist
// TODO: Display the attempted crate and a link to a search for said crate
Expand Down
16 changes: 16 additions & 0 deletions src/web/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -301,10 +301,26 @@ pub(super) fn build_axum_routes() -> AxumRouter {
"/crate/{name}/{version}/download",
get_internal(super::rustdoc::download_handler),
)
.route_with_tsr(
"/crate/{name}/{version}/json",
get_internal(super::rustdoc::json_download_handler),
)
.route_with_tsr(
"/crate/{name}/{version}/json/{format_version}",
get_internal(super::rustdoc::json_download_handler),
)
.route(
"/crate/{name}/{version}/target-redirect/{*path}",
get_internal(super::rustdoc::target_redirect_handler),
)
.route_with_tsr(
"/crate/{name}/{version}/{target}/json",
get_internal(super::rustdoc::json_download_handler),
)
.route_with_tsr(
"/crate/{name}/{version}/{target}/json/{format_version}",
get_internal(super::rustdoc::json_download_handler),
)
.route(
"/{name}/badge.svg",
get_internal(super::rustdoc::badge_handler),
Expand Down
197 changes: 196 additions & 1 deletion src/web/rustdoc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
use crate::{
AsyncStorage, Config, InstanceMetrics, RUSTDOC_STATIC_STORAGE_PREFIX,
db::Pool,
storage::rustdoc_archive_path,
storage::{RustdocJsonFormatVersion, rustdoc_archive_path, rustdoc_json_path},
utils,
web::{
MetaData, ReqVersion, axum_cached_redirect, axum_parse_uri_with_params,
Expand Down Expand Up @@ -817,6 +817,72 @@ pub(crate) async fn badge_handler(
))
}

#[derive(Clone, Deserialize, Debug)]
pub(crate) struct JsonDownloadParams {
pub(crate) name: String,
pub(crate) version: ReqVersion,
pub(crate) target: Option<String>,
pub(crate) format_version: Option<RustdocJsonFormatVersion>,
}

#[instrument(skip_all)]
pub(crate) async fn json_download_handler(
Path(params): Path<JsonDownloadParams>,
mut conn: DbConnection,
Extension(config): Extension<Arc<Config>>,
) -> AxumResult<impl IntoResponse> {
let matched_release = match_version(&mut conn, &params.name, &params.version)
.await?
.assume_exact_name()?;

if !matched_release.rustdoc_status() {
// without docs we'll never have JSON docs too
return Err(AxumNope::ResourceNotFound);
}

let krate = CrateDetails::from_matched_release(&mut conn, matched_release).await?;

let target = if let Some(wanted_target) = params.target {
if krate
.metadata
.doc_targets
.as_ref()
.expect("we are checking rustdoc_status() above, so we always have metadata")
.iter()
.any(|s| s == &wanted_target)
{
wanted_target
} else {
return Err(AxumNope::TargetNotFound);
}
} else {
krate
.metadata
.default_target
.as_ref()
.expect("we are checking rustdoc_status() above, so we always have metadata")
.to_string()
};

let format_version = params
.format_version
.unwrap_or(RustdocJsonFormatVersion::Latest);

let storage_path = rustdoc_json_path(
&krate.name,
&krate.version.to_string(),
&target,
format_version,
);

// since we didn't build rustdoc json for all releases yet,
// this redirect might redirect to a location that doesn't exist.
Ok(super::axum_cached_redirect(
format!("{}/{}", config.s3_static_root_path, storage_path),
CachePolicy::ForeverInCdn,
)?)
}

#[instrument(skip_all)]
pub(crate) async fn download_handler(
Path((name, req_version)): Path<(String, ReqVersion)>,
Expand Down Expand Up @@ -874,6 +940,7 @@ pub(crate) async fn static_asset_handler(

#[cfg(test)]
mod test {
use super::*;
use crate::{
Config,
registry_api::{CrateOwner, OwnerKind},
Expand Down Expand Up @@ -3009,4 +3076,132 @@ mod test {
Ok(())
});
}

#[test_case(
"latest/json",
"0.2.0",
"x86_64-unknown-linux-gnu",
RustdocJsonFormatVersion::Latest
)]
#[test_case(
"0.1/json",
"0.1.0",
"x86_64-unknown-linux-gnu",
RustdocJsonFormatVersion::Latest;
"semver"
)]
#[test_case(
"0.1.0/json",
"0.1.0",
"x86_64-unknown-linux-gnu",
RustdocJsonFormatVersion::Latest
)]
#[test_case(
"latest/json/latest",
"0.2.0",
"x86_64-unknown-linux-gnu",
RustdocJsonFormatVersion::Latest
)]
#[test_case(
"latest/json/42",
"0.2.0",
"x86_64-unknown-linux-gnu",
RustdocJsonFormatVersion::Version(42)
)]
#[test_case(
"latest/i686-pc-windows-msvc/json",
"0.2.0",
"i686-pc-windows-msvc",
RustdocJsonFormatVersion::Latest
)]
#[test_case(
"latest/i686-pc-windows-msvc/json/42",
"0.2.0",
"i686-pc-windows-msvc",
RustdocJsonFormatVersion::Version(42)
)]
fn json_download(
request_path_suffix: &str,
redirect_version: &str,
redirect_target: &str,
redirect_format_version: RustdocJsonFormatVersion,
) {
async_wrapper(|env| async move {
env.override_config(|config| {
config.s3_static_root_path = "https://static.docs.rs".into();
});
env.fake_release()
.await
.name("dummy")
.version("0.1.0")
.archive_storage(true)
.default_target("x86_64-unknown-linux-gnu")
.add_target("i686-pc-windows-msvc")
.create()
.await?;

env.fake_release()
.await
.name("dummy")
.version("0.2.0")
.archive_storage(true)
.default_target("x86_64-unknown-linux-gnu")
.add_target("i686-pc-windows-msvc")
.create()
.await?;

let web = env.web_app().await;

web.assert_redirect_cached_unchecked(
&format!("/crate/dummy/{request_path_suffix}"),
&format!("https://static.docs.rs/rustdoc-json/dummy/{redirect_version}/{redirect_target}/\
dummy_{redirect_version}_{redirect_target}_{redirect_format_version}.json.zst"),
CachePolicy::ForeverInCdn,
&env.config(),
)
.await?;
Ok(())
});
}

#[test_case("0.1.0/json"; "rustdoc status false")]
#[test_case("0.2.0/unknown-target/json"; "unknown target")]
#[test_case("0.42.0/json"; "unknown version")]
fn json_download_not_found(request_path_suffix: &str) {
async_wrapper(|env| async move {
env.override_config(|config| {
config.s3_static_root_path = "https://static.docs.rs".into();
});

env.fake_release()
.await
.name("dummy")
.version("0.1.0")
.archive_storage(true)
.default_target("x86_64-unknown-linux-gnu")
.add_target("i686-pc-windows-msvc")
.binary(true) // binary => rustdoc_status = false
.create()
.await?;

env.fake_release()
.await
.name("dummy")
.version("0.2.0")
.archive_storage(true)
.default_target("x86_64-unknown-linux-gnu")
.add_target("i686-pc-windows-msvc")
.create()
.await?;

let web = env.web_app().await;

let response = web
.get(&format!("/crate/dummy/{request_path_suffix}"))
.await?;

assert_eq!(response.status(), StatusCode::NOT_FOUND);
Ok(())
});
}
}
5 changes: 5 additions & 0 deletions src/web/sitemap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ about_page!(AboutPageBadges, "core/about/badges.html");
about_page!(AboutPageMetadata, "core/about/metadata.html");
about_page!(AboutPageRedirection, "core/about/redirections.html");
about_page!(AboutPageDownload, "core/about/download.html");
about_page!(AboutPageRustdocJson, "core/about/rustdoc-json.html");

pub(crate) async fn about_handler(subpage: Option<Path<String>>) -> AxumResult<impl IntoResponse> {
let subpage = match subpage {
Expand Down Expand Up @@ -170,6 +171,10 @@ pub(crate) async fn about_handler(subpage: Option<Path<String>>) -> AxumResult<i
active_tab: "download",
}
.into_response(),
"rustdoc-json" => AboutPageRustdocJson {
active_tab: "rustdoc-json",
}
.into_response(),
_ => {
let msg = "This /about page does not exist. \
Perhaps you are interested in <a href=\"https://github.com/rust-lang/docs.rs/tree/master/templates/core/about\">creating</a> it?";
Expand Down
4 changes: 4 additions & 0 deletions templates/about-base.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ <h1 id="crate-title" class="no-description">Docs.rs documentation</h1>
{% set text = crate::icons::IconDownload.render_solid(false, false, "") %}
{% set text = "{} <span class='title'>Download</span>"|format(text) %}
{% call macros::active_link(expected="download", href="/about/download", text=text) %}

{% set text = crate::icons::IconFileCode.render_solid(false, false, "") %}
{% set text = "{} <span class='title'>Rustdoc JSON</span>"|format(text) %}
{% call macros::active_link(expected="rustdoc-json", href="/about/rustdoc-json", text=text) %}
</ul>
</div>
</div>
Expand Down
Loading
Loading