#![forbid(unsafe_code)]
#![deny(missing_docs)]
#![cfg_attr(docsrs, feature(doc_cfg))]
use std::sync::Mutex;
use std::time::Duration;
use dynamic_config::{Error, Fetched, Format, RemoteSource, Watching};
use redis::Commands;
pub use redis::Client;
const POLL_SLICE: Duration = Duration::from_millis(250);
pub struct Redis {
client: Client,
connection: Mutex<Option<redis::Connection>>,
key: String,
format: Option<Format>,
described: String,
}
impl Redis {
pub fn new(url: &str, key: impl Into<String>) -> Result<Self, Error> {
let client = Client::open(url)
.map_err(|error| Error::remote(format!("redis {}: {error}", redacted(url))))?;
Ok(Self::build(client, key, redacted(url)))
}
#[must_use]
pub fn from_client(client: Client, key: impl Into<String>) -> Self {
Self::build(client, key, "<an existing client>".to_owned())
}
fn build(client: Client, key: impl Into<String>, described: String) -> Self {
let key = key.into();
let format = Format::from_key(&key);
Self {
client,
connection: Mutex::new(None),
key,
format,
described,
}
}
#[must_use]
pub fn with_format(mut self, format: Format) -> Self {
self.format = Some(format);
self
}
pub fn watch<F>(&self, watching: &Watching, mut on_change: F) -> Result<(), Error>
where
F: FnMut(Fetched) -> Result<(), Error>,
{
self.format()?;
self.require_keyspace_notifications()?;
let mut subscriber = self.client.get_connection().map_err(|error| {
Error::remote(format!("{}: cannot subscribe: {error}", self.describe()))
})?;
let database = self.database().ok_or_else(|| {
Error::remote(format!(
"{}: cannot determine the database index the connection lands on, so the keyspace channel cannot be named",
self.describe()
))
})?;
let channel = format!("__keyspace@{database}__:{}", self.key);
let mut pubsub = subscriber.as_pubsub();
pubsub.subscribe(&channel).map_err(|error| {
Error::remote(format!("{}: cannot subscribe: {error}", self.describe()))
})?;
pubsub
.set_read_timeout(Some(POLL_SLICE))
.map_err(|error| Error::remote(format!("{}: {error}", self.describe())))?;
while watching.keep_going() {
let message = match pubsub.get_message() {
Ok(message) => message,
Err(error) if error.is_timeout() => continue,
Err(error) => {
return Err(Error::remote(format!(
"{}: the subscription failed: {error}",
self.describe()
)))
}
};
let event: String = message.get_payload().unwrap_or_default();
if event == "del" || event == "expired" {
continue;
}
match self.fetch() {
Ok(document) => guarded(&mut on_change, document, &self.describe())?,
Err(_) => continue,
}
}
Ok(())
}
fn database(&self) -> Option<i64> {
let mut connection = self.client.get_connection().ok()?;
let info: String = redis::cmd("CLIENT")
.arg("INFO")
.query(&mut connection)
.ok()?;
info.split_whitespace()
.find_map(|field| field.strip_prefix("db="))
.and_then(|value| value.parse().ok())
}
fn format(&self) -> Result<Format, Error> {
self.format.ok_or_else(|| {
Error::remote(format!(
"{}: the key names no format; call `with_format`",
self.describe()
))
})
}
fn read(&self, format: Format) -> Result<Fetched, Error> {
let mut slot = self
.connection
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let connection = match slot.as_mut() {
Some(connection) => connection,
None => {
let opened = self
.client
.get_connection()
.map_err(|error| Error::remote(format!("{}: {error}", self.describe())))?;
slot.insert(opened)
}
};
let text: Option<String> = connection.get(&self.key).map_err(|error| {
Error::remote(format!("{}: {error}", self.describe()))
})?;
let Some(text) = text else {
return Err(Error::remote(format!(
"{}: the key holds no value",
self.describe()
)));
};
Ok(Fetched::new(text, format))
}
fn require_keyspace_notifications(&self) -> Result<(), Error> {
let mut connection = self
.client
.get_connection()
.map_err(|error| Error::remote(format!("{}: {error}", self.describe())))?;
let settings: Vec<String> = redis::cmd("CONFIG")
.arg("GET")
.arg("notify-keyspace-events")
.query(&mut connection)
.map_err(|error| Error::remote(format!("{}: {error}", self.describe())))?;
let value = settings.get(1).map(String::as_str).unwrap_or_default();
if value.contains('K') {
return Ok(());
}
Err(Error::remote(format!(
"{}: keyspace notifications are off, so nothing would ever arrive; \
`CONFIG SET notify-keyspace-events KEA` on the server",
self.describe()
)))
}
}
impl RemoteSource for Redis {
fn fetch(&self) -> Result<Fetched, Error> {
let format = self.format()?;
match self.read(format) {
Ok(document) => Ok(document),
Err(error) => {
*self
.connection
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
Err(error)
}
}
}
fn describe(&self) -> String {
format!("redis {} key {}", self.described, self.key)
}
}
impl std::fmt::Debug for Redis {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Redis")
.field("server", &self.described)
.field("key", &self.key)
.field("format", &self.format)
.finish_non_exhaustive()
}
}
fn redacted(url: &str) -> String {
let Some((scheme, rest)) = url.split_once("://") else {
return url.to_owned();
};
let Some((authority, tail)) = rest.rsplit_once('@') else {
return url.to_owned();
};
let user = authority
.split_once(':')
.map_or(authority, |(user, _)| user);
format!("{scheme}://{user}:***@{tail}")
}
fn guarded<F>(on_change: &mut F, document: Fetched, described: &str) -> Result<(), Error>
where
F: FnMut(Fetched) -> Result<(), Error>,
{
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| on_change(document))).unwrap_or_else(
|_| {
Err(Error::remote(format!(
"{described}: the watch callback panicked; the watch is stopped"
)))
},
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_password_never_reaches_an_error_message() {
assert_eq!(
redacted("redis://app:hunter2@redis.internal:6379"),
"redis://app:***@redis.internal:6379"
);
}
#[test]
fn a_password_containing_at_signs_is_fully_redacted() {
assert_eq!(
redacted("redis://app:p@ss@w@rd@redis.internal:6379"),
"redis://app:***@redis.internal:6379"
);
}
#[test]
fn a_url_with_no_credentials_is_left_alone() {
assert_eq!(
redacted("redis://redis.internal:6379"),
"redis://redis.internal:6379"
);
assert_eq!(redacted("not a url"), "not a url");
}
#[test]
fn the_format_comes_from_the_keys_extension() {
let client = Client::open("redis://127.0.0.1:6379").unwrap();
let source = Redis::from_client(client, "myapp/db.json");
assert_eq!(source.format, Some(Format::Json));
}
}