Skip to content

Commit 16885a3

Browse files
committed
use typed header to generate canonical-link-header, use for binary source files
1 parent 66acb49 commit 16885a3

File tree

6 files changed

+137
-9
lines changed

6 files changed

+137
-9
lines changed

Cargo.lock

Lines changed: 26 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ iron = "0.6"
9191
router = "0.6"
9292

9393
# axum dependencies
94-
axum = "0.6.1"
94+
axum = { version = "0.6.1", features = ["headers"]}
9595
axum-extra = "0.4.2"
9696
hyper = { version = "0.14.15", default-features = false }
9797
tower = "0.4.11"

src/web/headers.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
use axum::{
2+
headers::{Header, HeaderName, HeaderValue},
3+
http::uri::Uri,
4+
};
5+
6+
/// simplified typed header for a `Link rel=canonical` header in the response.
7+
pub struct CanonicalUrl(pub Uri);
8+
9+
impl Header for CanonicalUrl {
10+
fn name() -> &'static HeaderName {
11+
&http::header::LINK
12+
}
13+
14+
fn decode<'i, I>(_values: &mut I) -> Result<Self, axum::headers::Error>
15+
where
16+
I: Iterator<Item = &'i HeaderValue>,
17+
{
18+
unimplemented!();
19+
}
20+
21+
fn encode<E>(&self, values: &mut E)
22+
where
23+
E: Extend<HeaderValue>,
24+
{
25+
let value: HeaderValue = format!(r#"<{}>; rel="canonical""#, self.0).parse().unwrap();
26+
27+
values.extend(std::iter::once(value));
28+
}
29+
}
30+
31+
#[cfg(test)]
32+
mod tests {
33+
use super::*;
34+
35+
use axum::headers::HeaderMapExt;
36+
use axum::http::HeaderMap;
37+
38+
#[test]
39+
fn test_encode_canonical() {
40+
let mut map = HeaderMap::new();
41+
map.typed_insert(CanonicalUrl("http://something/".parse().unwrap()));
42+
assert_eq!(map["link"], "<http://something/>; rel=\"canonical\"");
43+
}
44+
}

src/web/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ pub(crate) mod error;
8181
mod extensions;
8282
mod features;
8383
mod file;
84+
mod headers;
8485
mod highlight;
8586
mod markdown;
8687
pub(crate) mod metrics;

src/web/page/web_page.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,12 @@ macro_rules! impl_axum_webpage {
133133
(canonical_url)(&self)
134134
};
135135
if let Some(canonical_url) = canonical_url {
136-
response.headers_mut().insert(
137-
axum::http::header::LINK,
138-
format!(r#"<{}>; rel="canonical"#, canonical_url).parse().unwrap(),
136+
use axum::headers::HeaderMapExt;
137+
138+
response.headers_mut().typed_insert(
139+
$crate::web::headers::CanonicalUrl(
140+
canonical_url.parse().expect("invalid URL for canonical link")
141+
),
139142
);
140143
}
141144
)?

src/web/source.rs

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@ use crate::{
33
db::Pool,
44
impl_axum_webpage,
55
utils::{get_correct_docsrs_style_file, spawn_blocking},
6-
web::{cache::CachePolicy, error::AxumNope, file::File as DbFile, MatchSemver, MetaData},
6+
web::{
7+
cache::CachePolicy, error::AxumNope, file::File as DbFile, headers::CanonicalUrl,
8+
MatchSemver, MetaData,
9+
},
710
Storage,
811
};
9-
use anyhow::Result;
10-
use axum::{extract::Path, response::IntoResponse, Extension};
12+
use anyhow::{Context as _, Result};
13+
use axum::{extract::Path, headers::HeaderMapExt, response::IntoResponse, Extension};
14+
1115
use postgres::Client;
1216
use serde::{Deserialize, Serialize};
1317
use serde_json::Value;
@@ -249,11 +253,20 @@ pub(crate) async fn source_browser_handler(
249253
})
250254
.await?;
251255

256+
let canonical_url = format!("https://docs.rs/crate/{}/latest/source/{}", name, path);
257+
252258
let (file, file_content) = if let Some(blob) = blob {
253259
let is_text = blob.mime.starts_with("text") || blob.mime == "application/json";
254260
// serve the file with DatabaseFileHandler if file isn't text and not empty
255261
if !is_text && !blob.is_empty() {
256-
return Ok(DbFile(blob).into_response());
262+
let mut response = DbFile(blob).into_response();
263+
response.headers_mut().typed_insert(CanonicalUrl(
264+
canonical_url.parse().context("invalid canonical url")?,
265+
));
266+
response
267+
.extensions_mut()
268+
.insert(CachePolicy::ForeverInCdnAndStaleInBrowser);
269+
return Ok(response);
257270
} else if is_text && !blob.is_empty() {
258271
let path = blob
259272
.path
@@ -299,7 +312,7 @@ pub(crate) async fn source_browser_handler(
299312
show_parent_link: !current_folder.is_empty(),
300313
file,
301314
file_content,
302-
canonical_url: format!("https://docs.rs/crate/{}/latest/source/{}", name, path),
315+
canonical_url,
303316
is_latest_url,
304317
}
305318
.into_response())
@@ -346,6 +359,47 @@ mod tests {
346359
.get("/crate/fake/0.1.0/source/some_filename.rs")
347360
.send()?;
348361
assert!(response.status().is_success());
362+
assert_eq!(
363+
response.headers().get("link").unwrap(),
364+
"<https://docs.rs/crate/fake/latest/source/some_filename.rs>; rel=\"canonical\""
365+
);
366+
assert_cache_control(
367+
&response,
368+
CachePolicy::ForeverInCdnAndStaleInBrowser,
369+
&env.config(),
370+
);
371+
assert!(response.text()?.contains("some_random_content"));
372+
Ok(())
373+
});
374+
}
375+
376+
#[test_case(true)]
377+
#[test_case(false)]
378+
fn fetch_binary(archive_storage: bool) {
379+
wrapper(|env| {
380+
env.fake_release()
381+
.archive_storage(archive_storage)
382+
.name("fake")
383+
.version("0.1.0")
384+
.source_file("some_file.pdf", b"some_random_content")
385+
.create()?;
386+
let web = env.frontend();
387+
let response = web.get("/crate/fake/0.1.0/source/some_file.pdf").send()?;
388+
assert!(response.status().is_success());
389+
assert_eq!(
390+
response.headers().get("link").unwrap(),
391+
"<https://docs.rs/crate/fake/latest/source/some_file.pdf>; rel=\"canonical\""
392+
);
393+
assert_eq!(
394+
response
395+
.headers()
396+
.get("content-type")
397+
.unwrap()
398+
.to_str()
399+
.unwrap(),
400+
"application/pdf"
401+
);
402+
349403
assert_cache_control(
350404
&response,
351405
CachePolicy::ForeverInCdnAndStaleInBrowser,

0 commit comments

Comments
 (0)