use std::collections::HashMap;
use fred::types::Resp3Frame;
use crate::error::RedisError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ClaimedEntry {
pub(crate) id: String,
pub(crate) fields: HashMap<String, Vec<u8>>,
pub(crate) idle_ms: u64,
pub(crate) delivery_count: u64,
}
pub(crate) fn decode_reply(frame: &Resp3Frame, key: &str) -> Result<Vec<ClaimedEntry>, RedisError> {
match frame {
Resp3Frame::Null => Ok(Vec::new()),
Resp3Frame::SimpleError { data, .. } => Err(server_error(data)),
Resp3Frame::BlobError { data, .. } => Err(server_error(&String::from_utf8_lossy(data))),
Resp3Frame::Map { data, .. } => {
for (name, entries) in data {
if frame_bytes(name) == Some(key.as_bytes()) {
return decode_entries(entries);
}
}
Ok(Vec::new())
}
Resp3Frame::Array { data, .. } => {
for pair in data {
let Resp3Frame::Array { data: pair, .. } = pair else {
return Err(malformed(
"a stream of the reply is not a [key, entries] pair",
));
};
let [name, entries] = pair.as_slice() else {
return Err(malformed(
"a stream of the reply is not a [key, entries] pair",
));
};
if frame_bytes(name) == Some(key.as_bytes()) {
return decode_entries(entries);
}
}
Ok(Vec::new())
}
_ => Err(malformed(
"the reply is neither a map nor an array of streams",
)),
}
}
fn decode_entries(frame: &Resp3Frame) -> Result<Vec<ClaimedEntry>, RedisError> {
match frame {
Resp3Frame::Null => Ok(Vec::new()),
Resp3Frame::Array { data, .. } => data.iter().map(decode_entry).collect(),
_ => Err(malformed("the entries of a stream are not an array")),
}
}
fn decode_entry(frame: &Resp3Frame) -> Result<ClaimedEntry, RedisError> {
let Resp3Frame::Array { data, .. } = frame else {
return Err(malformed("an entry is not an array"));
};
let [id, fields, idle, count] = data.as_slice() else {
return Err(malformed(&format!(
"an entry of a CLAIM read has four elements [id, fields, idle-ms, delivery-count]; \
this one has {}",
data.len()
)));
};
let id = frame_bytes(id)
.and_then(|raw| std::str::from_utf8(raw).ok())
.ok_or_else(|| malformed("an entry id is not a string"))?;
Ok(ClaimedEntry {
id: id.to_owned(),
fields: decode_fields(fields)?,
idle_ms: frame_u64(idle).ok_or_else(|| malformed("an entry idle time is not a number"))?,
delivery_count: frame_u64(count)
.ok_or_else(|| malformed("an entry delivery count is not a number"))?,
})
}
fn decode_fields(frame: &Resp3Frame) -> Result<HashMap<String, Vec<u8>>, RedisError> {
match frame {
Resp3Frame::Array { data, .. } => {
if data.len() % 2 != 0 {
return Err(malformed(
"the fields of an entry are an odd-length array, so one name has no value",
));
}
data.as_chunks::<2>()
.0
.iter()
.map(|[name, value]| field_pair(name, value))
.collect()
}
Resp3Frame::Map { data, .. } => data
.iter()
.map(|(name, value)| field_pair(name, value))
.collect(),
_ => Err(malformed(
"the fields of an entry are neither an array nor a map",
)),
}
}
fn field_pair(name: &Resp3Frame, value: &Resp3Frame) -> Result<(String, Vec<u8>), RedisError> {
let name = frame_bytes(name)
.and_then(|raw| std::str::from_utf8(raw).ok())
.ok_or_else(|| malformed("a field name is not a string"))?;
let value = frame_bytes(value).ok_or_else(|| malformed("a field value is not a string"))?;
Ok((name.to_owned(), value.to_vec()))
}
fn frame_bytes(frame: &Resp3Frame) -> Option<&[u8]> {
match frame {
Resp3Frame::BlobString { data, .. }
| Resp3Frame::VerbatimString { data, .. }
| Resp3Frame::SimpleString { data, .. }
| Resp3Frame::ChunkedString(data) => Some(data),
_ => None,
}
}
fn frame_u64(frame: &Resp3Frame) -> Option<u64> {
match frame {
Resp3Frame::Number { data, .. } => u64::try_from(*data).ok(),
other => frame_bytes(other)
.and_then(|raw| std::str::from_utf8(raw).ok())
.and_then(|text| text.parse().ok()),
}
}
fn server_error(details: &str) -> RedisError {
RedisError::Stream(format!("XREADGROUP with CLAIM was refused: {details}").into())
}
fn malformed(what: &str) -> RedisError {
RedisError::Stream(format!("unexpected XREADGROUP CLAIM reply: {what}").into())
}
#[cfg(test)]
mod tests {
use bytes::Bytes;
use super::*;
fn blob(text: &str) -> Resp3Frame {
Resp3Frame::BlobString {
data: Bytes::copy_from_slice(text.as_bytes()),
attributes: None,
}
}
fn number(value: i64) -> Resp3Frame {
Resp3Frame::Number {
data: value,
attributes: None,
}
}
fn array(data: Vec<Resp3Frame>) -> Resp3Frame {
Resp3Frame::Array {
data,
attributes: None,
}
}
fn entry(id: &str, idle: i64, count: i64) -> Resp3Frame {
array(vec![
blob(id),
array(vec![blob("_payload"), blob("body")]),
number(idle),
number(count),
])
}
fn resp2_reply(key: &str, entries: Vec<Resp3Frame>) -> Resp3Frame {
array(vec![array(vec![blob(key), array(entries)])])
}
fn resp3_reply(key: &str, entries: Vec<Resp3Frame>) -> Resp3Frame {
let mut map = HashMap::new();
map.insert(blob(key), array(entries));
Resp3Frame::Map {
data: map,
attributes: None,
}
}
#[test]
fn both_protocol_shapes_decode_to_the_same_entries() {
let entries = vec![entry("1-0", 800, 3), entry("2-0", 0, 0)];
let expected = vec![
ClaimedEntry {
id: "1-0".to_owned(),
fields: [("_payload".to_owned(), b"body".to_vec())].into(),
idle_ms: 800,
delivery_count: 3,
},
ClaimedEntry {
id: "2-0".to_owned(),
fields: [("_payload".to_owned(), b"body".to_vec())].into(),
idle_ms: 0,
delivery_count: 0,
},
];
assert_eq!(
decode_reply(&resp2_reply("orders", entries.clone()), "orders").expect("resp2 decodes"),
expected
);
assert_eq!(
decode_reply(&resp3_reply("orders", entries), "orders").expect("resp3 decodes"),
expected
);
}
#[test]
fn a_timed_out_read_decodes_to_no_entries() {
assert_eq!(
decode_reply(&Resp3Frame::Null, "orders").expect("a null is a legal reply"),
Vec::<ClaimedEntry>::new()
);
}
#[test]
fn entries_of_another_key_are_not_returned() {
let reply = resp2_reply("other", vec![entry("1-0", 0, 0)]);
assert_eq!(
decode_reply(&reply, "orders").expect("a reply naming another key is well formed"),
Vec::<ClaimedEntry>::new()
);
}
#[test]
fn an_entry_without_the_two_counters_is_refused() {
let plain = array(vec![blob("1-0"), array(vec![blob("_payload"), blob("b")])]);
let err = decode_reply(&resp2_reply("orders", vec![plain]), "orders")
.expect_err("an entry with no counters must be refused");
assert!(
format!("{err}").contains("four elements"),
"the error has to name the shape it wanted: {err}"
);
}
#[test]
fn a_server_error_is_reported_as_itself() {
let frame = Resp3Frame::SimpleError {
data: "ERR syntax error".into(),
attributes: None,
};
let err = decode_reply(&frame, "orders").expect_err("an error frame is an error");
assert!(format!("{err}").contains("ERR syntax error"), "got {err}");
}
#[test]
fn a_counter_sent_as_a_string_is_read_as_a_number() {
let entry = array(vec![
blob("1-0"),
array(vec![blob("_payload"), blob("body")]),
blob("800"),
blob("3"),
]);
let decoded = decode_reply(&resp2_reply("orders", vec![entry]), "orders").expect("decodes");
assert_eq!(decoded[0].idle_ms, 800);
assert_eq!(decoded[0].delivery_count, 3);
}
#[test]
fn every_field_of_an_entry_survives() {
let entry = array(vec![
blob("1-0"),
array(vec![
blob("_payload"),
blob("body"),
blob("h:content-type"),
blob("application/json"),
]),
number(0),
number(0),
]);
let decoded = decode_reply(&resp2_reply("orders", vec![entry]), "orders").expect("decodes");
assert_eq!(decoded[0].fields.len(), 2);
assert_eq!(
decoded[0].fields.get("h:content-type").map(Vec::as_slice),
Some(b"application/json".as_slice())
);
}
}