Skip to content

Commit 268e46e

Browse files
authored
Merge pull request #12328 from Turbo87/fastly-crate
Extract `crates_io_fastly` workspace crate
2 parents b3958d6 + ca2d083 commit 268e46e

File tree

11 files changed

+182
-116
lines changed

11 files changed

+182
-116
lines changed

Cargo.lock

Lines changed: 11 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 & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ crates_io_database_dump = { path = "crates/crates_io_database_dump" }
7373
crates_io_diesel_helpers = { path = "crates/crates_io_diesel_helpers" }
7474
crates_io_docs_rs = { path = "crates/crates_io_docs_rs" }
7575
crates_io_env_vars = { path = "crates/crates_io_env_vars" }
76+
crates_io_fastly = { path = "crates/crates_io_fastly" }
7677
crates_io_github = { path = "crates/crates_io_github" }
7778
crates_io_heroku = { path = "crates/crates_io_heroku" }
7879
crates_io_index = { path = "crates/crates_io_index" }

crates/crates_io_fastly/Cargo.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[package]
2+
name = "crates_io_fastly"
3+
version = "0.0.0"
4+
license = "MIT OR Apache-2.0"
5+
edition = "2024"
6+
7+
[lints]
8+
workspace = true
9+
10+
[dependencies]
11+
reqwest = { version = "=0.12.24", features = ["json"] }
12+
secrecy = "=0.10.3"
13+
thiserror = "=2.0.17"
14+
tracing = "=0.1.41"

crates/crates_io_fastly/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# crates_io_fastly
2+
3+
This package implements functionality for interacting with the Fastly API.
4+
5+
The `Fastly` struct provides methods for purging cached content on Fastly's CDN.
6+
It uses the `reqwest` crate to perform HTTP requests to the Fastly API and
7+
authenticates using an API token.
8+
9+
The main operations supported are:
10+
- `purge()` - Purge a specific path on a single domain
11+
- `purge_both_domains()` - Purge a path on both the primary and prefixed domains
12+
13+
Note that wildcard invalidations are not supported by the Fastly API.

crates/crates_io_fastly/src/lib.rs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
#![doc = include_str!("../README.md")]
2+
3+
use reqwest::Client;
4+
use reqwest::header::{HeaderValue, InvalidHeaderValue};
5+
use secrecy::{ExposeSecret, SecretString};
6+
use thiserror::Error;
7+
use tracing::{debug, instrument, trace};
8+
9+
#[derive(Debug, Error)]
10+
pub enum Error {
11+
#[error("Wildcard invalidations are not supported for Fastly")]
12+
WildcardNotSupported,
13+
14+
#[error("Invalid API token format")]
15+
InvalidApiToken(#[from] InvalidHeaderValue),
16+
17+
#[error("Failed to `POST {url}`{}: {source}", status.map(|s| format!(" (status: {})", s)).unwrap_or_default())]
18+
PurgeFailed {
19+
url: String,
20+
status: Option<reqwest::StatusCode>,
21+
#[source]
22+
source: reqwest::Error,
23+
},
24+
}
25+
26+
#[derive(Debug)]
27+
pub struct Fastly {
28+
client: Client,
29+
api_token: SecretString,
30+
}
31+
32+
impl Fastly {
33+
pub fn new(api_token: SecretString) -> Self {
34+
let client = Client::new();
35+
Self { client, api_token }
36+
}
37+
38+
/// Invalidate a path on Fastly
39+
///
40+
/// This method takes a path and invalidates the cached content on Fastly. The path must not
41+
/// contain a wildcard, since the Fastly API does not support wildcard invalidations. Paths are
42+
/// invalidated for both domains that are associated with the Fastly service.
43+
///
44+
/// Requests are authenticated using a token that is sent in a header. The token is passed to
45+
/// the application as an environment variable.
46+
///
47+
/// More information on Fastly's APIs for cache invalidations can be found here:
48+
/// <https://developer.fastly.com/reference/api/purging/>
49+
#[instrument(skip(self))]
50+
pub async fn purge_both_domains(&self, base_domain: &str, path: &str) -> Result<(), Error> {
51+
self.purge(base_domain, path).await?;
52+
53+
let prefixed_domain = format!("fastly-{base_domain}");
54+
self.purge(&prefixed_domain, path).await?;
55+
56+
Ok(())
57+
}
58+
59+
/// Invalidate a path on Fastly
60+
///
61+
/// This method takes a domain and path and invalidates the cached content
62+
/// on Fastly. The path must not contain a wildcard, since the Fastly API
63+
/// does not support wildcard invalidations.
64+
///
65+
/// More information on Fastly's APIs for cache invalidations can be found here:
66+
/// <https://developer.fastly.com/reference/api/purging/>
67+
#[instrument(skip(self))]
68+
pub async fn purge(&self, domain: &str, path: &str) -> Result<(), Error> {
69+
if path.contains('*') {
70+
return Err(Error::WildcardNotSupported);
71+
}
72+
73+
let path = path.trim_start_matches('/');
74+
let url = format!("https://api.fastly.com/purge/{domain}/{path}");
75+
76+
trace!(?url);
77+
78+
debug!("sending invalidation request to Fastly");
79+
let response = self
80+
.client
81+
.post(&url)
82+
.header("Fastly-Key", self.token_header_value()?)
83+
.send()
84+
.await
85+
.map_err(|source| Error::PurgeFailed {
86+
url: url.clone(),
87+
status: None,
88+
source,
89+
})?;
90+
91+
let status = response.status();
92+
93+
match response.error_for_status_ref() {
94+
Ok(_) => {
95+
debug!(?status, "invalidation request accepted by Fastly");
96+
Ok(())
97+
}
98+
Err(error) => {
99+
let headers = response.headers().clone();
100+
let body = response.text().await;
101+
debug!(
102+
?status,
103+
?headers,
104+
?body,
105+
"invalidation request to Fastly failed"
106+
);
107+
108+
Err(Error::PurgeFailed {
109+
url,
110+
status: Some(status),
111+
source: error,
112+
})
113+
}
114+
}
115+
}
116+
117+
fn token_header_value(&self) -> Result<HeaderValue, InvalidHeaderValue> {
118+
let api_token = self.api_token.expose_secret();
119+
120+
let mut header_value = HeaderValue::try_from(api_token)?;
121+
header_value.set_sensitive(true);
122+
Ok(header_value)
123+
}
124+
}

src/bin/background-worker.rs

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,18 @@ extern crate tracing;
1616
use anyhow::Context;
1717
use crates_io::app::create_database_pool;
1818
use crates_io::cloudfront::CloudFront;
19-
use crates_io::fastly::Fastly;
2019
use crates_io::ssh;
2120
use crates_io::storage::Storage;
2221
use crates_io::worker::{Environment, RunnerExt};
2322
use crates_io::{Emails, config};
2423
use crates_io_docs_rs::RealDocsRsClient;
2524
use crates_io_env_vars::var;
25+
use crates_io_fastly::Fastly;
2626
use crates_io_index::RepositoryConfig;
2727
use crates_io_og_image::OgImageGenerator;
2828
use crates_io_team_repo::TeamRepoImpl;
2929
use crates_io_worker::Runner;
3030
use object_store::prefix::PrefixStore;
31-
use reqwest::Client;
3231
use std::sync::Arc;
3332
use std::thread::sleep;
3433
use std::time::Duration;
@@ -79,13 +78,11 @@ fn main() -> anyhow::Result<()> {
7978
let downloads_archive_store = PrefixStore::new(storage.as_inner(), "archive/version-downloads");
8079
let downloads_archive_store = Box::new(downloads_archive_store);
8180

82-
let client = Client::builder()
83-
.timeout(Duration::from_secs(45))
84-
.build()
85-
.expect("Couldn't build client");
86-
8781
let emails = Emails::from_environment(&config);
88-
let fastly = Fastly::from_environment(client.clone());
82+
83+
let fastly_api_token = var("FASTLY_API_TOKEN")?.map(Into::into);
84+
let fastly = fastly_api_token.map(Fastly::new);
85+
8986
let team_repo = TeamRepoImpl::default();
9087

9188
let docs_rs = RealDocsRsClient::from_environment().map(|cl| Box::new(cl) as _);

src/fastly.rs

Lines changed: 0 additions & 101 deletions
This file was deleted.

src/lib.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ pub mod config;
2727
pub mod controllers;
2828
pub mod db;
2929
pub mod email;
30-
pub mod fastly;
3130
pub mod headers;
3231
pub mod index;
3332
mod licenses;

src/worker/environment.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
use crate::Emails;
22
use crate::cloudfront::CloudFront;
3-
use crate::fastly::Fastly;
43
use crate::storage::Storage;
54
use crate::typosquat;
65
use crate::worker::jobs::ProcessCloudfrontInvalidationQueue;
76
use anyhow::Context;
87
use bon::Builder;
98
use crates_io_database::models::CloudFrontInvalidationQueueItem;
109
use crates_io_docs_rs::DocsRsClient;
10+
use crates_io_fastly::Fastly;
1111
use crates_io_index::{Repository, RepositoryConfig};
1212
use crates_io_og_image::OgImageGenerator;
1313
use crates_io_team_repo::TeamRepo;
@@ -91,8 +91,13 @@ impl Environment {
9191
result.context("Failed to enqueue CloudFront invalidation processing job")?;
9292
}
9393

94-
if let Some(fastly) = self.fastly() {
95-
fastly.invalidate(path).await.context("Fastly")?;
94+
if let Some(fastly) = self.fastly()
95+
&& let Some(cdn_domain) = &self.config.storage.cdn_prefix
96+
{
97+
fastly
98+
.purge_both_domains(cdn_domain, path)
99+
.await
100+
.context("Fastly")?;
96101
}
97102

98103
Ok(())

src/worker/jobs/generate_og_image.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,8 @@ impl BackgroundJob for GenerateOgImage {
122122

123123
// Invalidate Fastly CDN
124124
if let Some(fastly) = ctx.fastly()
125-
&& let Err(error) = fastly.invalidate(&og_image_path).await
125+
&& let Some(cdn_domain) = &ctx.config.storage.cdn_prefix
126+
&& let Err(error) = fastly.purge_both_domains(cdn_domain, &og_image_path).await
126127
{
127128
warn!("Failed to invalidate Fastly CDN for {crate_name}: {error}");
128129
}

0 commit comments

Comments
 (0)