Skip to content

Commit 242c0db

Browse files
authored
fastly: Add surrogate-key purging (#14228)
1 parent 6c74294 commit 242c0db

2 files changed

Lines changed: 113 additions & 6 deletions

File tree

crates/crates_io_fastly/examples/purge.rs

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,35 @@
11
use std::str::FromStr;
22

3-
use clap::Parser;
3+
use clap::{ArgGroup, Parser};
44
use crates_io_fastly::Fastly;
55
use secrecy::SecretString;
66

77
/// Arguments accepted by the purge example.
88
#[derive(Debug, Parser)]
9-
#[command(about = "Purge cached content using the crates.io Fastly client")]
9+
#[command(
10+
about = "Purge cached content using the crates.io Fastly client",
11+
group(
12+
ArgGroup::new("operation")
13+
.required(true)
14+
.args(["url", "service_id"])
15+
)
16+
)]
1017
struct Options {
1118
/// Fastly API token used to authenticate the purge request.
1219
#[arg(long, env = "FASTLY_API_TOKEN", hide_env_values = true)]
1320
api_token: SecretString,
1421

1522
/// URL to purge, without the scheme.
1623
#[arg(long, value_name = "DOMAIN/PATH")]
17-
url: PurgeUrl,
24+
url: Option<PurgeUrl>,
25+
26+
/// Fastly service containing the objects associated with the key.
27+
#[arg(long, value_name = "ID", requires = "key")]
28+
service_id: Option<String>,
29+
30+
/// Surrogate key to purge.
31+
#[arg(value_name = "KEY", requires = "service_id")]
32+
key: Option<String>,
1833
}
1934

2035
/// Domain and path extracted from a URL argument.
@@ -53,12 +68,17 @@ async fn main() -> Result<(), crates_io_fastly::Error> {
5368
let options = Options::parse();
5469
let fastly = Fastly::new(options.api_token);
5570

56-
fastly.purge(&options.url.domain, &options.url.path).await
71+
match (options.url, options.service_id, options.key) {
72+
(Some(url), None, None) => fastly.purge(&url.domain, &url.path).await,
73+
(None, Some(service_id), Some(key)) => fastly.purge_surrogate_key(&service_id, &key).await,
74+
_ => unreachable!("clap validates exactly one complete purge operation"),
75+
}
5776
}
5877

5978
#[cfg(test)]
6079
mod tests {
6180
use super::*;
81+
use clap::error::ErrorKind;
6282

6383
#[test]
6484
fn rejects_url_with_http_scheme() {
@@ -86,10 +106,60 @@ mod tests {
86106

87107
assert_eq!(
88108
options.url,
89-
PurgeUrl {
109+
Some(PurgeUrl {
90110
domain: "static.crates.io".into(),
91111
path: "crates/serde/serde-1.0.0.crate".into(),
92-
}
112+
})
93113
);
114+
assert_eq!(options.service_id, None);
115+
assert_eq!(options.key, None);
116+
}
117+
118+
#[test]
119+
fn parses_surrogate_key_purge() {
120+
let options = Options::try_parse_from([
121+
"purge",
122+
"--api-token",
123+
"test-token",
124+
"--service-id",
125+
"static-service-id",
126+
"release:serde@1.0.0+metadata",
127+
])
128+
.unwrap();
129+
130+
assert_eq!(options.url, None);
131+
assert_eq!(options.service_id.as_deref(), Some("static-service-id"));
132+
assert_eq!(options.key.as_deref(), Some("release:serde@1.0.0+metadata"));
133+
}
134+
135+
#[test]
136+
fn rejects_service_id_without_key() {
137+
let error = Options::try_parse_from([
138+
"purge",
139+
"--api-token",
140+
"test-token",
141+
"--service-id",
142+
"static-service-id",
143+
])
144+
.unwrap_err();
145+
146+
assert_eq!(error.kind(), ErrorKind::MissingRequiredArgument);
147+
}
148+
149+
#[test]
150+
fn rejects_multiple_operations() {
151+
let error = Options::try_parse_from([
152+
"purge",
153+
"--api-token",
154+
"test-token",
155+
"--url",
156+
"static.crates.io/crates/serde/serde-1.0.0.crate",
157+
"--service-id",
158+
"static-service-id",
159+
"crate:serde",
160+
])
161+
.unwrap_err();
162+
163+
assert_eq!(error.kind(), ErrorKind::ArgumentConflict);
94164
}
95165
}

crates/crates_io_fastly/src/lib.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,18 @@ impl Fastly {
7373
Ok(())
7474
}
7575

76+
/// Invalidates all objects associated with a surrogate key on a Fastly service.
77+
///
78+
/// The key uses Fastly's native representation without CloudFront's leading `#` marker.
79+
///
80+
/// More information on Fastly's APIs for cache invalidations can be found here:
81+
/// <https://developer.fastly.com/reference/api/purging/>
82+
#[instrument(skip(self))]
83+
pub async fn purge_surrogate_key(&self, service_id: &str, key: &str) -> Result<(), Error> {
84+
let url = format!("{}/service/{service_id}/purge/{key}", self.api_base_url);
85+
self.send_purge_request(url).await
86+
}
87+
7688
/// Invalidates a path on Fastly
7789
///
7890
/// This method takes a domain and path and invalidates the cached content
@@ -89,7 +101,11 @@ impl Fastly {
89101

90102
let path = path.trim_start_matches('/');
91103
let url = format!("{}/purge/{domain}/{path}", self.api_base_url);
104+
self.send_purge_request(url).await
105+
}
92106

107+
/// Sends an authenticated purge request to Fastly.
108+
async fn send_purge_request(&self, url: String) -> Result<(), Error> {
93109
trace!(?url);
94110

95111
debug!("sending invalidation request to Fastly");
@@ -205,6 +221,27 @@ mod tests {
205221
.unwrap();
206222
}
207223

224+
#[tokio::test]
225+
async fn purges_surrogate_key() {
226+
let mut server = mock_server().await;
227+
let _mock = server
228+
.mock(
229+
"POST",
230+
"/service/static-service-id/purge/release:serde@1.0.0+metadata",
231+
)
232+
.match_header("fastly-key", TEST_TOKEN)
233+
.with_status(200)
234+
.expect(1)
235+
.create_async()
236+
.await;
237+
238+
let client = client_with_server(&server);
239+
client
240+
.purge_surrogate_key("static-service-id", "release:serde@1.0.0+metadata")
241+
.await
242+
.unwrap();
243+
}
244+
208245
#[tokio::test]
209246
async fn rejects_wildcards() {
210247
let server = mock_server().await;

0 commit comments

Comments
 (0)