Manually invoked currency refresh (#181)

Adds `currency.fetch_on_startup` to the config, which when set to false
behaves as if the `cache_duration` was infinite. This means rink will
fetch the file once, and then keep reusing that file indefinitely.

Adds a `--fetch-currency` CLI argument, which will make rink download
the latest version of the currency data and then exit. This can be put
into a cron job and used together with the config option so that rink
will never block on a web request at startup, without giving up currency
units. Requires manual setup though.
This commit is contained in:
Tiffany Bennett 2024-06-01 19:07:35 -07:00 committed by GitHub
parent 6e674f07f8
commit 900b07528a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 261 additions and 13 deletions

2
.gitignore vendored
View file

@ -8,3 +8,5 @@
**/.rpt2_cache
**/build
/web/data
# created by unit tests
/cli/currency.json

32
Cargo.lock generated
View file

@ -101,6 +101,12 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
[[package]]
name = "ascii"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]]
name = "assert-json-diff"
version = "2.0.2"
@ -479,6 +485,12 @@ dependencies = [
"parse-zoneinfo",
]
[[package]]
name = "chunked_transfer"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
[[package]]
name = "clap"
version = "4.5.4"
@ -1085,6 +1097,12 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "humantime"
version = "2.1.0"
@ -1743,6 +1761,7 @@ dependencies = [
"eyre",
"humantime-serde",
"nu-ansi-term",
"once_cell",
"rink-core",
"rink-sandbox",
"rustyline",
@ -1751,6 +1770,7 @@ dependencies = [
"serde_json",
"similar-asserts",
"tempfile",
"tiny_http",
"toml 0.5.11",
"ubyte",
]
@ -2149,6 +2169,18 @@ dependencies = [
"once_cell",
]
[[package]]
name = "tiny_http"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
dependencies = [
"ascii",
"chunked_transfer",
"httpdate",
"log",
]
[[package]]
name = "tokio"
version = "1.37.0"

View file

@ -42,6 +42,8 @@ path = "../sandbox"
[dev-dependencies]
similar-asserts = "1.1.0"
tiny_http = "0.12"
once_cell = "1"
[package.metadata.wasm-pack.profile.profiling]
wasm-opt = ['-g', '-O']

View file

@ -62,8 +62,10 @@ pub struct Rink {
#[derive(Serialize, Deserialize, Clone)]
#[serde(default, deny_unknown_fields)]
pub struct Currency {
/// Set to false to disable currency fetching entirely.
/// Set to false to disable currency loading entirely.
pub enabled: bool,
/// Set to false to only reuse the existing cached currency data.
pub fetch_on_startup: bool,
/// Which web endpoint should be used to download currency data?
pub endpoint: String,
/// How long to cache for.
@ -176,6 +178,7 @@ impl Default for Currency {
fn default() -> Self {
Currency {
enabled: true,
fetch_on_startup: true,
endpoint: "https://rinkcalc.app/data/currency.json".to_owned(),
cache_duration: Duration::from_secs(60 * 60), // 1 hour
timeout: Duration::from_secs(2),
@ -257,13 +260,32 @@ fn read_from_search_path(
}
}
pub(crate) fn force_refresh_currency(config: &Currency) -> Result<String> {
println!("Fetching...");
let start = std::time::Instant::now();
let mut path = dirs::cache_dir().ok_or_else(|| eyre!("Could not find cache directory"))?;
path.push("rink");
path.push("currency.json");
let file = download_to_file(&path, &config.endpoint, config.timeout)
.wrap_err("Fetching currency data failed")?;
let delta = std::time::Instant::now() - start;
let metadata = file
.metadata()
.wrap_err("Fetched currency file, but failed to read file metadata")?;
let len = metadata.len();
Ok(format!(
"Fetched {len} byte currency file after {}ms",
delta.as_millis()
))
}
fn load_live_currency(config: &Currency) -> Result<ast::Defs> {
let file = cached(
"currency.json",
&config.endpoint,
config.cache_duration,
config.timeout,
)?;
let duration = if config.fetch_on_startup {
Some(config.cache_duration)
} else {
None
};
let file = cached("currency.json", &config.endpoint, duration, config.timeout)?;
let contents = file_to_string(file)?;
serde_json::from_str(&contents).wrap_err("Invalid JSON")
}
@ -341,18 +363,19 @@ pub fn load(config: &Config) -> Result<Context> {
Ok(ctx)
}
fn read_if_current(file: File, expiration: Duration) -> Result<File> {
fn read_if_current(file: File, expiration: Option<Duration>) -> Result<File> {
use std::time::SystemTime;
let stats = file.metadata()?;
let mtime = stats.modified()?;
let now = SystemTime::now();
let elapsed = now.duration_since(mtime)?;
if elapsed > expiration {
Err(eyre!("File is out of date"))
} else {
Ok(file)
if let Some(expiration) = expiration {
if elapsed > expiration {
return Err(eyre!("File is out of date"));
}
}
Ok(file)
}
fn download_to_file(path: &Path, url: &str, timeout: Duration) -> Result<File> {
@ -409,7 +432,12 @@ fn download_to_file(path: &Path, url: &str, timeout: Duration) -> Result<File> {
.wrap_err("Failed to write to cache dir")
}
fn cached(filename: &str, url: &str, expiration: Duration, timeout: Duration) -> Result<File> {
fn cached(
filename: &str,
url: &str,
expiration: Option<Duration>,
timeout: Duration,
) -> Result<File> {
let mut path = dirs::cache_dir().ok_or_else(|| eyre!("Could not find cache directory"))?;
path.push("rink");
path.push(filename);
@ -443,3 +471,154 @@ fn cached(filename: &str, url: &str, expiration: Duration, timeout: Duration) ->
Err(err).wrap_err_with(|| format!("Failed to fetch {}", url))
}
}
#[cfg(test)]
mod tests {
use std::{
io::Read,
path::PathBuf,
sync::{Arc, Mutex},
time::Duration,
};
use once_cell::sync::Lazy;
use tiny_http::{Response, Server, StatusCode};
static SERVER: Lazy<Mutex<Arc<Server>>> = Lazy::new(|| {
Mutex::new(Arc::new(
Server::http("127.0.0.1:3090").expect("port 3090 is needed to do http tests"),
))
});
#[test]
fn test_download_timeout() {
let server = SERVER.lock().unwrap();
let server2 = server.clone();
let thread_handle = std::thread::spawn(move || {
let request = server2.recv().expect("the request should not fail");
assert_eq!(request.url(), "/data/currency.json");
std::thread::sleep(Duration::from_millis(100));
});
let result = super::download_to_file(
&PathBuf::from("currency.json"),
"http://127.0.0.1:3090/data/currency.json",
Duration::from_millis(5),
);
let result = result.expect_err("this should always fail");
assert_eq!(result.to_string(), "[28] Timeout was reached (Operation timed out after 5 milliseconds with 0 bytes received)");
thread_handle.join().unwrap();
drop(server);
}
#[test]
fn test_download_404() {
let server = SERVER.lock().unwrap();
let server2 = server.clone();
let thread_handle = std::thread::spawn(move || {
let request = server2.recv().expect("the request should not fail");
assert_eq!(request.url(), "/data/currency.json");
let mut data = b"404 not found".to_owned();
let cursor = std::io::Cursor::new(&mut data);
request
.respond(Response::new(StatusCode(404), vec![], cursor, None, None))
.expect("the response should go through");
});
let result = super::download_to_file(
&PathBuf::from("currency.json"),
"http://127.0.0.1:3090/data/currency.json",
Duration::from_millis(2000),
);
let result = result.expect_err("this should always fail");
assert_eq!(
result.to_string(),
"Received status 404 while downloading http://127.0.0.1:3090/data/currency.json"
);
thread_handle.join().unwrap();
drop(server);
}
#[test]
fn test_download_success() {
let server = SERVER.lock().unwrap();
let server2 = server.clone();
let thread_handle = std::thread::spawn(move || {
let request = server2.recv().expect("the request should not fail");
assert_eq!(request.url(), "/data/currency.json");
let mut data = b"{}".to_owned();
let cursor = std::io::Cursor::new(&mut data);
request
.respond(Response::new(StatusCode(200), vec![], cursor, None, None))
.expect("the response should go through");
});
let result = super::download_to_file(
&PathBuf::from("currency.json"),
"http://127.0.0.1:3090/data/currency.json",
Duration::from_millis(2000),
);
let mut result = result.expect("this should succeed");
let mut string = String::new();
result
.read_to_string(&mut string)
.expect("the file should exist");
assert_eq!(string, "{}");
thread_handle.join().unwrap();
drop(server);
}
#[test]
fn test_force_refresh_success() {
let config = super::Currency {
enabled: true,
fetch_on_startup: false,
endpoint: "http://127.0.0.1:3090/data/currency.json".to_owned(),
cache_duration: Duration::ZERO,
timeout: Duration::from_millis(2000),
};
let server = SERVER.lock().unwrap();
let server2 = server.clone();
let thread_handle = std::thread::spawn(move || {
let request = server2.recv().expect("the request should not fail");
assert_eq!(request.url(), "/data/currency.json");
let mut data = b"{}".to_owned();
let cursor = std::io::Cursor::new(&mut data);
request
.respond(Response::new(StatusCode(200), vec![], cursor, None, None))
.expect("the response should go through");
});
let result = super::force_refresh_currency(&config);
let result = result.expect("this should succeed");
assert!(result.starts_with("Fetched 2 byte currency file after "));
thread_handle.join().unwrap();
drop(server);
}
#[test]
fn test_force_refresh_timeout() {
let config = super::Currency {
enabled: true,
fetch_on_startup: false,
endpoint: "http://127.0.0.1:3090/data/currency.json".to_owned(),
cache_duration: Duration::ZERO,
timeout: Duration::from_millis(5),
};
let server = SERVER.lock().unwrap();
let server2 = server.clone();
let thread_handle = std::thread::spawn(move || {
let request = server2.recv().expect("the request should not fail");
assert_eq!(request.url(), "/data/currency.json");
std::thread::sleep(Duration::from_millis(100));
});
let result = super::force_refresh_currency(&config);
let result = result.expect_err("this should timeout");
assert_eq!(result.to_string(), "Fetching currency data failed");
thread_handle.join().unwrap();
drop(server);
}
}

View file

@ -44,6 +44,12 @@ async fn main() -> Result<()> {
.help("Prints a path to the config file, then exits")
.action(ArgAction::SetTrue)
)
.arg(
Arg::new("fetch-currency")
.long("fetch-currency")
.help("Fetches latest version of currency data, then exits")
.action(ArgAction::SetTrue)
)
.arg(
Arg::new("dump")
.long("dump")
@ -78,6 +84,17 @@ async fn main() -> Result<()> {
return Ok(());
}
if matches.get_flag("fetch-currency") {
let result = config::force_refresh_currency(&config.currency);
match result {
Ok(msg) => {
println!("{msg}");
return Ok(());
}
Err(err) => return Err(err),
}
}
if matches.get_flag("config-path") {
println!("{}", config::config_path("config.toml").unwrap().display());
Ok(())

View file

@ -25,6 +25,11 @@ Options
**--config-path**::
Prints a path to the config file, then exits.
**--fetch-currency**:
Fetches the latest version of the currency data, then exits. Can be
used as part of a cron job, possibly together with setting
`currency.fetch_on_startup` to false.
**-f**::
**--file** <__file__>::
Reads expressions from a file, one per line, printing them to stdout
@ -58,6 +63,9 @@ Rink looks for a configuration file in
Rink relies on some data files, which are found using a search path.
See rink-defs(5) and rink-dates(5).
Downloaded currency data is saved in
`$XDG_CACHE_DIR/rink/currency.json`.
Bugs
----

View file

@ -63,6 +63,14 @@ The `[currency]` section.
Currency fetching can be disabled for those that don't want it.
Default: `true`
*fetch_on_startup* = <__bool__>::
Setting this to `false` causes rink to behave as if
`cache_duration` was infinite. If the currency file doesn't
exist, it will fetch it, otherwise it will use it indefinitely.
Rink can be invoked with `--fetch-currency` to download the
latest version.
Default: `true`
*endpoint* = <__url__>::
Allows pointing to alternate Rink-Web instances, or to any other API
that offers a compatible format.