use std::collections::BTreeMap;
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::cassettes::discovery::Discovery;
use crate::cassettes::spec::{self, ReducerConfig, Surface};
use crate::transport::{SpecFetch, SpecTransport};
#[derive(Debug, Clone, Copy)]
pub struct CacheConfig<'a> {
pub app_dir_name: &'a str,
pub env_override_var: &'a str,
pub revalidate_after: Duration,
pub key: &'a str,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedSpec {
#[serde(default)]
pub etag: Option<String>,
pub document: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Cached {
pub base: String,
pub revalidated_at: u64,
pub discovery: Discovery,
pub specs: BTreeMap<String, CachedSpec>,
}
impl Cached {
#[must_use]
pub fn surface(&self, reducer: &ReducerConfig<'_>) -> Surface {
let cassettes = self
.discovery
.cassettes
.iter()
.filter_map(|entry| {
let cached = self.specs.get(&entry.name)?;
Some(spec::reduce(
&entry.name,
entry.description.clone(),
&cached.document,
reducer,
))
})
.collect();
Surface { cassettes }
}
#[must_use]
pub fn is_fresh(&self, now: u64, revalidate_after: Duration) -> bool {
now >= self.revalidated_at && now - self.revalidated_at < revalidate_after.as_secs()
}
}
fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
fn cache_dir(config: &CacheConfig<'_>) -> Option<PathBuf> {
if let Ok(raw) = std::env::var(config.env_override_var) {
if !raw.trim().is_empty() {
return Some(PathBuf::from(raw));
}
}
Some(dirs::cache_dir()?.join(config.app_dir_name))
}
fn cache_path(config: &CacheConfig<'_>) -> Option<PathBuf> {
let readable: String = config
.key
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
let trimmed: String = readable.chars().take(48).collect();
Some(cache_dir(config)?.join(format!("{trimmed}-{:016x}.json", fnv1a(config.key))))
}
fn fnv1a(input: &str) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in input.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
#[must_use]
pub fn read(config: &CacheConfig<'_>) -> Option<Cached> {
let path = cache_path(config)?;
let raw = std::fs::read(&path).ok()?;
let cached: Cached = serde_json::from_slice(&raw).ok()?;
(cached.base == config.key).then_some(cached)
}
pub fn write(config: &CacheConfig<'_>, cached: &Cached) {
let Some(path) = cache_path(config) else {
return;
};
let Some(parent) = path.parent() else {
return;
};
if let Err(error) = std::fs::create_dir_all(parent) {
tracing::debug!(%error, "could not create the cassette cache directory");
return;
}
let Ok(encoded) = serde_json::to_vec(cached) else {
return;
};
let temporary = path.with_extension(format!("{}.tmp", std::process::id()));
if let Err(error) = std::fs::write(&temporary, &encoded) {
tracing::debug!(%error, "could not write the cassette cache");
return;
}
if let Err(error) = std::fs::rename(&temporary, &path) {
tracing::debug!(%error, "could not install the cassette cache");
let _ = std::fs::remove_file(&temporary);
}
}
pub async fn load<T: SpecTransport>(
transport: &T,
config: &CacheConfig<'_>,
reducer: &ReducerConfig<'_>,
) -> Surface {
let existing = read(config);
if let Some(cached) = &existing {
if cached.is_fresh(now(), config.revalidate_after) {
return cached.surface(reducer);
}
}
match revalidate(transport, config, existing.as_ref()).await {
Some(fresh) => {
write(config, &fresh);
fresh.surface(reducer)
}
None => {
existing
.map(|cached| cached.surface(reducer))
.unwrap_or_default()
}
}
}
async fn revalidate<T: SpecTransport>(
transport: &T,
config: &CacheConfig<'_>,
existing: Option<&Cached>,
) -> Option<Cached> {
let document = match transport.fetch_discovery().await {
Ok(document) => document,
Err(error) => {
tracing::debug!(%error, "could not reach cassette discovery");
return None;
}
};
let discovery: Discovery = match serde_json::from_value(document) {
Ok(discovery) => discovery,
Err(error) => {
tracing::debug!(%error, "could not read the cassette discovery document");
return None;
}
};
for problem in &discovery.problems {
tracing::debug!(
subject = %problem.subject,
reason = %problem.reason,
"the server refused a configured cassette",
);
}
let mut specs: BTreeMap<String, CachedSpec> = BTreeMap::new();
for entry in &discovery.cassettes {
if !entry.has_spec() {
continue;
}
let previous = existing.and_then(|cached| cached.specs.get(&entry.name));
let etag = previous.and_then(|spec| spec.etag.as_deref());
match transport.fetch_spec(&entry.openapi_path, etag).await {
Ok(SpecFetch::Unchanged) => {
if let Some(previous) = previous {
specs.insert(entry.name.clone(), previous.clone());
}
}
Ok(SpecFetch::Fetched { document, etag }) => {
specs.insert(entry.name.clone(), CachedSpec { etag, document });
}
Err(error) => {
tracing::debug!(
cassette = %entry.name,
%error,
"could not fetch a cassette's OpenAPI document",
);
if let Some(previous) = previous {
specs.insert(entry.name.clone(), previous.clone());
}
}
}
}
Some(Cached {
base: config.key.to_owned(),
revalidated_at: now(),
discovery,
specs,
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use crate::cassettes::discovery::DiscoveryEntry;
use serde_json::json;
const REVALIDATE_AFTER: Duration = Duration::from_secs(600);
const RESERVED: ReducerConfig<'static> = ReducerConfig {
reserved_flags: &["tapes-url", "body", "help", "verbose"],
};
fn config(key: &str) -> CacheConfig<'_> {
CacheConfig {
app_dir_name: "tapesctl/cassettes",
env_override_var: "TAPESCTL_CACHE_DIR",
revalidate_after: REVALIDATE_AFTER,
key,
}
}
fn entry(name: &str) -> DiscoveryEntry {
DiscoveryEntry {
name: name.to_owned(),
route_prefix: format!("/v1/cassettes/{name}"),
openapi_path: format!("/v1/cassettes/{name}/openapi.json"),
openapi_status: "fresh".to_owned(),
..Default::default()
}
}
fn hello_document(name: &str) -> Value {
json!({"paths": {format!("/v1/cassettes/{name}/hello"): {
"get": {"operationId": "getHello"}
}}})
}
fn cached(base: &str, name: &str, at: u64) -> Cached {
Cached {
base: base.to_owned(),
revalidated_at: at,
discovery: Discovery {
contract_version: "v1".to_owned(),
cassettes: vec![entry(name)],
problems: Vec::new(),
},
specs: BTreeMap::from([(
name.to_owned(),
CachedSpec {
etag: Some("\"sha256:abc\"".to_owned()),
document: hello_document(name),
},
)]),
}
}
#[test]
fn a_cached_entry_reduces_to_the_generated_surface() {
let surface = cached("http://a", "hello-world", 0).surface(&RESERVED);
assert_eq!(surface.cassettes.len(), 1);
assert_eq!(surface.cassettes[0].methods[0].name, "get-hello");
}
#[test]
fn a_cassette_with_no_cached_document_generates_no_noun() {
let mut entry = cached("http://a", "hello-world", 0);
entry.specs.clear();
assert!(entry.surface(&RESERVED).is_empty());
}
#[test]
fn freshness_expires_after_the_revalidation_window() {
let entry = cached("http://a", "hello-world", 1_000);
assert!(entry.is_fresh(1_000, REVALIDATE_AFTER));
assert!(entry.is_fresh(1_000 + REVALIDATE_AFTER.as_secs() - 1, REVALIDATE_AFTER));
assert!(!entry.is_fresh(1_000 + REVALIDATE_AFTER.as_secs(), REVALIDATE_AFTER));
}
#[test]
fn a_clock_that_moved_backwards_expires_rather_than_pinning_the_surface() {
let entry = cached("http://a", "hello-world", 5_000);
assert!(!entry.is_fresh(1_000, REVALIDATE_AFTER));
}
#[test]
fn two_base_urls_get_two_cache_files() {
let a = cache_path(&config("http://one.example")).unwrap();
let b = cache_path(&config("http://two.example")).unwrap();
assert_ne!(a, b);
}
#[test]
fn urls_that_sanitize_alike_still_get_different_files() {
let a = cache_path(&config("http://a-b.example")).unwrap();
let b = cache_path(&config("http://a.b-example")).unwrap();
assert_ne!(a, b);
}
#[test]
fn the_file_name_hash_is_stable_across_builds() {
assert_eq!(fnv1a(""), 0xcbf2_9ce4_8422_2325);
assert_eq!(
fnv1a("http://127.0.0.1:8081/"),
fnv1a("http://127.0.0.1:8081/")
);
assert_ne!(fnv1a("a"), fnv1a("b"));
}
#[test]
fn the_file_name_is_byte_identical_to_the_pre_extraction_layout() {
let path = cache_path(&CacheConfig {
env_override_var: "CASSETTE_CLIENT_TEST_UNSET_VAR",
..config("http://127.0.0.1:8081/")
})
.unwrap();
assert_eq!(
path.file_name().unwrap().to_str().unwrap(),
"http___127_0_0_1_8081_-709aba2490ce417e.json",
);
assert!(path.parent().unwrap().ends_with("tapesctl/cassettes"));
}
#[test]
fn the_cached_serde_shape_is_byte_compatible_with_the_pre_extraction_format() {
let cached = cached("http://a", "hello-world", 42);
let encoded = serde_json::to_value(&cached).unwrap();
assert_eq!(
encoded,
json!({
"base": "http://a",
"revalidated_at": 42,
"discovery": {
"contract_version": "v1",
"cassettes": [{
"name": "hello-world",
"version": null,
"display_name": null,
"description": null,
"route_prefix": "/v1/cassettes/hello-world",
"openapi_path": "/v1/cassettes/hello-world/openapi.json",
"openapi_status": "fresh",
"manifest_digest": ""
}],
"problems": []
},
"specs": {
"hello-world": {
"etag": "\"sha256:abc\"",
"document": hello_document("hello-world")
}
}
}),
);
let decoded: Cached = serde_json::from_value(encoded).unwrap();
assert_eq!(decoded.base, cached.base);
}
}