Skip to main content

ullun/
redis.rs

1//! Minimal Redis client and Redis-backed stores (feature `redis`)
2//!
3//! Sessions, rate limits and idempotency keys live in process memory by
4//! default, so a second instance sees none of them. With this feature the same
5//! middleware keeps its state in Redis, shared by every instance:
6//!
7//! ```no_run
8//! use ullun::prelude::*;
9//! use ullun::redis::{Redis, RedisSessionStore};
10//! use ullun::session::SessionLayer;
11//!
12//! # async fn run() -> ullun::error::Result<()> {
13//! let redis = Redis::from_url("redis://127.0.0.1:6379/0")?;
14//! App::new()
15//!     .middleware(SessionLayer::with_store(RedisSessionStore::new(redis)))
16//!     .run("127.0.0.1:3000")
17//!     .await
18//! # }
19//! ```
20//!
21//! The client speaks RESP2 over plain TCP with a small connection pool and
22//! brings no dependencies. It supports `redis://[user:password@]host[:port][/db]`
23//! and is meant for the stores here; for anything else use a full client and
24//! implement the store traits ([`SessionStore`], [`IdempotencyStore`],
25//! [`RateLimitStore`]) yourself. TLS (`rediss://`) is not supported: terminate it
26//! with a local proxy or keep Redis on a private network.
27//!
28//! Failure behavior is chosen per store: a session store that cannot reach
29//! Redis behaves as if the session does not exist, the rate limiter fails open
30//! (requests are let through), idempotency fails closed (`503`) because
31//! running a payment twice is worse than refusing it.
32
33use crate::error::{Error, Result};
34use crate::idempotency::{Begin, IdempotencyStore, StoredResponse};
35use crate::rate_limit::{Decision, RateLimitStore};
36use crate::session::{SessionRecord, SessionStore};
37use async_trait::async_trait;
38use bytes::Bytes;
39use std::collections::VecDeque;
40use std::sync::Mutex;
41use std::time::{Duration, SystemTime, UNIX_EPOCH};
42use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
43use tokio::net::TcpStream;
44
45const MAX_BULK: usize = 64 * 1024 * 1024;
46
47/// A reply from Redis
48#[derive(Debug, Clone, PartialEq)]
49pub enum Value {
50    Nil,
51    Int(i64),
52    Status(String),
53    Bulk(Vec<u8>),
54    Array(Vec<Value>),
55    /// An error reply (`-ERR ...`); [`Redis::run`] turns it into an `Err`
56    Error(String),
57}
58
59impl Value {
60    /// The bytes of a bulk string, `None` for nil
61    pub fn into_bytes(self) -> Option<Vec<u8>> {
62        match self {
63            Value::Bulk(b) => Some(b),
64            _ => None,
65        }
66    }
67
68    pub fn as_int(&self) -> Option<i64> {
69        match self {
70            Value::Int(n) => Some(*n),
71            _ => None,
72        }
73    }
74
75    pub fn is_ok(&self) -> bool {
76        matches!(self, Value::Status(s) if s == "OK")
77    }
78}
79
80/// A command under construction
81#[derive(Debug, Clone)]
82pub struct Cmd {
83    args: Vec<Vec<u8>>,
84}
85
86impl Cmd {
87    pub fn new(name: &str) -> Self {
88        Self {
89            args: vec![name.as_bytes().to_vec()],
90        }
91    }
92
93    pub fn arg(mut self, arg: impl AsRef<[u8]>) -> Self {
94        self.args.push(arg.as_ref().to_vec());
95        self
96    }
97
98    pub fn int(self, n: i64) -> Self {
99        self.arg(n.to_string())
100    }
101
102    /// RESP encoding as sent on the wire
103    pub fn encode(&self) -> Vec<u8> {
104        let mut out = format!("*{}\r\n", self.args.len()).into_bytes();
105        for a in &self.args {
106            out.extend_from_slice(format!("${}\r\n", a.len()).as_bytes());
107            out.extend_from_slice(a);
108            out.extend_from_slice(b"\r\n");
109        }
110        out
111    }
112}
113
114struct Conn {
115    io: BufReader<TcpStream>,
116}
117
118impl Conn {
119    async fn read_line(&mut self) -> Result<Vec<u8>> {
120        let mut line = Vec::new();
121        let n = self.io.read_until(b'\n', &mut line).await.map_err(io_err)?;
122        if n == 0 || !line.ends_with(b"\r\n") {
123            return Err(Error::internal("Redis closed the connection"));
124        }
125        line.truncate(line.len() - 2);
126        Ok(line)
127    }
128
129    fn read_value<'a>(
130        &'a mut self,
131    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Value>> + Send + 'a>> {
132        Box::pin(async move {
133            let line = self.read_line().await?;
134            let (kind, rest) = line
135                .split_first()
136                .ok_or_else(|| Error::internal("Empty Redis reply"))?;
137            let text = || String::from_utf8_lossy(rest).into_owned();
138            let number = || -> Result<i64> {
139                text()
140                    .parse()
141                    .map_err(|_| Error::internal("Malformed Redis reply"))
142            };
143            match kind {
144                b'+' => Ok(Value::Status(text())),
145                b'-' => Ok(Value::Error(text())),
146                b':' => Ok(Value::Int(number()?)),
147                b'$' => {
148                    let len = number()?;
149                    if len < 0 {
150                        return Ok(Value::Nil);
151                    }
152                    let len = len as usize;
153                    if len > MAX_BULK {
154                        return Err(Error::internal("Redis reply too large"));
155                    }
156                    let mut buf = vec![0u8; len + 2];
157                    self.io.read_exact(&mut buf).await.map_err(io_err)?;
158                    buf.truncate(len);
159                    Ok(Value::Bulk(buf))
160                }
161                b'*' => {
162                    let len = number()?;
163                    if len < 0 {
164                        return Ok(Value::Nil);
165                    }
166                    let mut items = Vec::with_capacity((len as usize).min(1024));
167                    for _ in 0..len {
168                        items.push(self.read_value().await?);
169                    }
170                    Ok(Value::Array(items))
171                }
172                _ => Err(Error::internal("Unknown Redis reply type")),
173            }
174        })
175    }
176}
177
178fn io_err(e: std::io::Error) -> Error {
179    Error::internal(format!("Redis I/O: {e}"))
180}
181
182/// A Redis server and a pool of connections to it
183pub struct Redis {
184    addr: String,
185    username: Option<String>,
186    password: Option<String>,
187    db: u32,
188    timeout: Duration,
189    max_idle: usize,
190    idle: Mutex<VecDeque<Conn>>,
191}
192
193impl std::fmt::Debug for Redis {
194    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195        f.debug_struct("Redis")
196            .field("addr", &self.addr)
197            .field("db", &self.db)
198            .finish_non_exhaustive()
199    }
200}
201
202impl Redis {
203    /// `redis://[user:password@]host[:port][/db]`; no connection is made yet
204    pub fn from_url(url: &str) -> Result<Self> {
205        let bad =
206            || Error::internal("Redis URL must look like redis://[user:password@]host[:port][/db]");
207        let rest = url.strip_prefix("redis://").ok_or_else(bad)?;
208        let (authority, db) = match rest.split_once('/') {
209            Some((a, d)) if !d.is_empty() => (a, d.parse::<u32>().map_err(|_| bad())?),
210            Some((a, _)) => (a, 0),
211            None => (rest, 0),
212        };
213        let (credentials, host) = match authority.rsplit_once('@') {
214            Some((c, h)) => (Some(c), h),
215            None => (None, authority),
216        };
217        if host.is_empty() {
218            return Err(bad());
219        }
220        let (username, password) = match credentials {
221            None => (None, None),
222            Some(c) => match c.split_once(':') {
223                Some((u, p)) => ((!u.is_empty()).then(|| decode(u)), Some(decode(p))),
224                None => (None, Some(decode(c))),
225            },
226        };
227        let addr = if host.contains(':') && !host.starts_with('[') || host.contains("]:") {
228            host.to_string()
229        } else {
230            format!("{host}:6379")
231        };
232        Ok(Self {
233            addr,
234            username,
235            password,
236            db,
237            timeout: Duration::from_secs(5),
238            max_idle: 16,
239            idle: Mutex::new(VecDeque::new()),
240        })
241    }
242
243    /// Connect, read and write timeout per command (default 5 seconds)
244    pub fn timeout(mut self, timeout: Duration) -> Self {
245        self.timeout = timeout;
246        self
247    }
248
249    /// Idle connections kept for reuse (default 16)
250    pub fn max_idle(mut self, max: usize) -> Self {
251        self.max_idle = max;
252        self
253    }
254
255    async fn connect(&self) -> Result<Conn> {
256        let stream = TcpStream::connect(&self.addr).await.map_err(io_err)?;
257        let _ = stream.set_nodelay(true);
258        let mut conn = Conn {
259            io: BufReader::new(stream),
260        };
261        if let Some(password) = &self.password {
262            let mut auth = Cmd::new("AUTH");
263            if let Some(user) = &self.username {
264                auth = auth.arg(user);
265            }
266            expect_ok(exchange(&mut conn, &[auth.arg(password)]).await?)?;
267        }
268        if self.db != 0 {
269            expect_ok(exchange(&mut conn, &[Cmd::new("SELECT").int(i64::from(self.db))]).await?)?;
270        }
271        Ok(conn)
272    }
273
274    fn take_idle(&self) -> Option<Conn> {
275        self.idle
276            .lock()
277            .unwrap_or_else(|e| e.into_inner())
278            .pop_front()
279    }
280
281    fn put_idle(&self, conn: Conn) {
282        let mut idle = self.idle.lock().unwrap_or_else(|e| e.into_inner());
283        if idle.len() < self.max_idle {
284            idle.push_back(conn);
285        }
286    }
287
288    /// Send commands in one round trip and collect their replies. Error replies
289    /// stay in the result as [`Value::Error`].
290    pub async fn pipeline(&self, cmds: &[Cmd]) -> Result<Vec<Value>> {
291        let attempt = |mut conn: Conn| async move {
292            let replies = exchange(&mut conn, cmds).await?;
293            Ok::<_, Error>((conn, replies))
294        };
295        tokio::time::timeout(self.timeout, async {
296            // a pooled connection may have been closed by the server meanwhile:
297            // fall back to a fresh one once
298            if let Some(conn) = self.take_idle() {
299                if let Ok((conn, replies)) = attempt(conn).await {
300                    self.put_idle(conn);
301                    return Ok(replies);
302                }
303            }
304            let (conn, replies) = attempt(self.connect().await?).await?;
305            self.put_idle(conn);
306            Ok(replies)
307        })
308        .await
309        .map_err(|_| Error::internal("Redis timed out"))?
310    }
311
312    /// Run one command; an error reply becomes `Err`
313    pub async fn run(&self, cmd: Cmd) -> Result<Value> {
314        match self.pipeline(std::slice::from_ref(&cmd)).await?.pop() {
315            Some(Value::Error(e)) => Err(Error::internal(format!("Redis: {e}"))),
316            Some(v) => Ok(v),
317            None => Err(Error::internal("Redis sent no reply")),
318        }
319    }
320}
321
322async fn exchange(conn: &mut Conn, cmds: &[Cmd]) -> Result<Vec<Value>> {
323    let mut out = Vec::new();
324    for c in cmds {
325        out.extend_from_slice(&c.encode());
326    }
327    conn.io.write_all(&out).await.map_err(io_err)?;
328    let mut replies = Vec::with_capacity(cmds.len());
329    for _ in cmds {
330        replies.push(conn.read_value().await?);
331    }
332    Ok(replies)
333}
334
335fn expect_ok(mut replies: Vec<Value>) -> Result<()> {
336    match replies.pop() {
337        Some(v) if v.is_ok() => Ok(()),
338        Some(Value::Error(e)) => Err(Error::internal(format!("Redis: {e}"))),
339        _ => Err(Error::internal("Unexpected Redis reply")),
340    }
341}
342
343fn decode(s: &str) -> String {
344    urlencoding::decode(s)
345        .map(|c| c.into_owned())
346        .unwrap_or_else(|_| s.to_string())
347}
348
349fn millis(d: Duration) -> i64 {
350    i64::try_from(d.as_millis()).unwrap_or(i64::MAX).max(1)
351}
352
353// ---- sessions ---------------------------------------------------------------
354
355/// [`SessionStore`] in Redis: one JSON string per session with an expiry
356pub struct RedisSessionStore {
357    redis: Redis,
358    prefix: String,
359    ttl: Duration,
360}
361
362impl RedisSessionStore {
363    /// Keys `ullun:session:<id>`, expiring 24 hours after the last save
364    pub fn new(redis: Redis) -> Self {
365        Self {
366            redis,
367            prefix: "ullun:session:".into(),
368            ttl: Duration::from_secs(24 * 3600),
369        }
370    }
371
372    /// Key prefix, to share a Redis between applications
373    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
374        self.prefix = prefix.into();
375        self
376    }
377
378    /// Expiry after the last save. Set it to at least the session layer's
379    /// absolute lifetime.
380    pub fn ttl(mut self, ttl: Duration) -> Self {
381        self.ttl = ttl;
382        self
383    }
384
385    fn key(&self, id: &str) -> String {
386        format!("{}{}", self.prefix, id)
387    }
388}
389
390#[async_trait]
391impl SessionStore for RedisSessionStore {
392    async fn load(&self, id: &str) -> Option<SessionRecord> {
393        let raw = self
394            .redis
395            .run(Cmd::new("GET").arg(self.key(id)))
396            .await
397            .ok()?
398            .into_bytes()?;
399        let doc: serde_json::Value = serde_json::from_slice(&raw).ok()?;
400        Some(SessionRecord {
401            data: doc.get("d")?.as_object()?.clone(),
402            created_at: doc.get("c")?.as_u64()?,
403            last_seen: doc.get("l")?.as_u64()?,
404        })
405    }
406
407    async fn save(&self, id: &str, record: &SessionRecord) {
408        let doc = serde_json::json!({
409            "d": record.data,
410            "c": record.created_at,
411            "l": record.last_seen,
412        });
413        let cmd = Cmd::new("SET")
414            .arg(self.key(id))
415            .arg(doc.to_string())
416            .arg("PX")
417            .int(millis(self.ttl));
418        if let Err(e) = self.redis.run(cmd).await {
419            eprintln!("Saving session to Redis failed: {}", e.message());
420        }
421    }
422
423    async fn delete(&self, id: &str) {
424        let _ = self.redis.run(Cmd::new("DEL").arg(self.key(id))).await;
425    }
426}
427
428// ---- rate limits ------------------------------------------------------------
429
430/// [`RateLimitStore`] in Redis. Fails open: when Redis is unreachable requests
431/// are allowed.
432pub struct RedisRateLimitStore {
433    redis: Redis,
434    prefix: String,
435}
436
437impl RedisRateLimitStore {
438    pub fn new(redis: Redis) -> Self {
439        Self {
440            redis,
441            prefix: "ullun:rl:".into(),
442        }
443    }
444
445    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
446        self.prefix = prefix.into();
447        self
448    }
449
450    async fn fixed(&self, key: &str, limit: u32, window: Duration) -> Result<Decision> {
451        let k = format!("{}{}", self.prefix, key);
452        let window_ms = millis(window);
453        // create the counter with its expiry first, so INCR never leaves one without
454        let replies = self
455            .redis
456            .pipeline(&[
457                Cmd::new("SET")
458                    .arg(&k)
459                    .arg("0")
460                    .arg("NX")
461                    .arg("PX")
462                    .int(window_ms),
463                Cmd::new("INCR").arg(&k),
464                Cmd::new("PTTL").arg(&k),
465            ])
466            .await?;
467        let count = replies.get(1).and_then(Value::as_int).unwrap_or(1).max(1) as u64;
468        let mut ttl = replies.get(2).and_then(Value::as_int).unwrap_or(window_ms);
469        if ttl < 0 {
470            // the counter expired between SET and INCR: restart its window
471            let _ = self
472                .redis
473                .run(Cmd::new("PEXPIRE").arg(&k).int(window_ms))
474                .await;
475            ttl = window_ms;
476        }
477        Ok(Decision {
478            allowed: count <= u64::from(limit),
479            limit,
480            remaining: u64::from(limit).saturating_sub(count) as u32,
481            reset_secs: ((ttl as u64).div_ceil(1000)).max(1),
482        })
483    }
484
485    async fn sliding(&self, key: &str, limit: u32, window: Duration) -> Result<Decision> {
486        let window_ms = millis(window) as u64;
487        let now_ms = SystemTime::now()
488            .duration_since(UNIX_EPOCH)
489            .map(|d| d.as_millis() as u64)
490            .unwrap_or(0);
491        let (index, elapsed) = (now_ms / window_ms, now_ms % window_ms);
492        let cur = format!("{}{}:{}", self.prefix, key, index);
493        let prev = format!("{}{}:{}", self.prefix, key, index.wrapping_sub(1));
494        let replies = self
495            .redis
496            .pipeline(&[
497                Cmd::new("INCR").arg(&cur),
498                Cmd::new("PEXPIRE").arg(&cur).int((window_ms * 2) as i64),
499                Cmd::new("GET").arg(&prev),
500            ])
501            .await?;
502        let count = replies.first().and_then(Value::as_int).unwrap_or(1).max(1) as f64;
503        let previous = match replies.get(2) {
504            Some(Value::Bulk(b)) => String::from_utf8_lossy(b).parse::<f64>().unwrap_or(0.0),
505            _ => 0.0,
506        };
507        let weight = 1.0 - elapsed as f64 / window_ms as f64;
508        let estimate = previous * weight + count;
509        let limit_f = f64::from(limit);
510        let reset_secs = ((window_ms - elapsed).div_ceil(1000)).max(1);
511        if estimate <= limit_f {
512            return Ok(Decision {
513                allowed: true,
514                limit,
515                remaining: (limit_f - estimate).floor().max(0.0) as u32,
516                reset_secs,
517            });
518        }
519        // denied requests must not count against the client
520        let _ = self.redis.run(Cmd::new("DECR").arg(&cur)).await;
521        Ok(Decision {
522            allowed: false,
523            limit,
524            remaining: 0,
525            reset_secs,
526        })
527    }
528}
529
530#[async_trait]
531impl RateLimitStore for RedisRateLimitStore {
532    async fn hit(&self, key: &str, limit: u32, window: Duration, sliding: bool) -> Decision {
533        let result = if sliding {
534            self.sliding(key, limit, window).await
535        } else {
536            self.fixed(key, limit, window).await
537        };
538        result.unwrap_or_else(|e| {
539            eprintln!("Rate limit store unavailable, allowing: {}", e.message());
540            Decision {
541                allowed: true,
542                limit,
543                remaining: limit,
544                reset_secs: window.as_secs().max(1),
545            }
546        })
547    }
548}
549
550// ---- idempotency ------------------------------------------------------------
551
552/// [`IdempotencyStore`] in Redis. Pair it with
553/// [`Idempotency::shared_seed`](crate::idempotency::Idempotency::shared_seed) so
554/// all instances hash scopes and payloads alike. Fails closed: when Redis is
555/// unreachable requests get `503`.
556pub struct RedisIdempotencyStore {
557    redis: Redis,
558    prefix: String,
559}
560
561const RUNNING: u8 = 0;
562const DONE: u8 = 1;
563
564fn encode_entry(fingerprint: u64, response: Option<&StoredResponse>) -> Vec<u8> {
565    let mut out = fingerprint.to_be_bytes().to_vec();
566    let Some(res) = response else {
567        out.push(RUNNING);
568        return out;
569    };
570    out.push(DONE);
571    out.extend_from_slice(&res.status.to_be_bytes());
572    out.extend_from_slice(&(res.headers.len() as u32).to_be_bytes());
573    for (name, value) in &res.headers {
574        out.extend_from_slice(&(name.len() as u32).to_be_bytes());
575        out.extend_from_slice(name.as_bytes());
576        out.extend_from_slice(&(value.len() as u32).to_be_bytes());
577        out.extend_from_slice(value);
578    }
579    out.extend_from_slice(&res.body);
580    out
581}
582
583fn decode_entry(raw: &[u8]) -> Option<(u64, Option<StoredResponse>)> {
584    fn take<'a>(buf: &mut &'a [u8], n: usize) -> Option<&'a [u8]> {
585        (buf.len() >= n).then(|| {
586            let (head, rest) = buf.split_at(n);
587            *buf = rest;
588            head
589        })
590    }
591    fn u32_of(buf: &mut &[u8]) -> Option<usize> {
592        Some(u32::from_be_bytes(take(buf, 4)?.try_into().ok()?) as usize)
593    }
594    let mut buf = raw;
595    let fingerprint = u64::from_be_bytes(take(&mut buf, 8)?.try_into().ok()?);
596    match take(&mut buf, 1)?[0] {
597        RUNNING => Some((fingerprint, None)),
598        DONE => {
599            let status = u16::from_be_bytes(take(&mut buf, 2)?.try_into().ok()?);
600            let count = u32_of(&mut buf)?;
601            let mut headers = Vec::new();
602            for _ in 0..count {
603                let n = u32_of(&mut buf)?;
604                let name = String::from_utf8(take(&mut buf, n)?.to_vec()).ok()?;
605                let n = u32_of(&mut buf)?;
606                headers.push((name, take(&mut buf, n)?.to_vec()));
607            }
608            Some((
609                fingerprint,
610                Some(StoredResponse {
611                    status,
612                    headers,
613                    body: Bytes::copy_from_slice(buf),
614                }),
615            ))
616        }
617        _ => None,
618    }
619}
620
621impl RedisIdempotencyStore {
622    pub fn new(redis: Redis) -> Self {
623        Self {
624            redis,
625            prefix: "ullun:idem:".into(),
626        }
627    }
628
629    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
630        self.prefix = prefix.into();
631        self
632    }
633
634    fn key(&self, key: &str) -> String {
635        format!("{}{}", self.prefix, key)
636    }
637
638    async fn try_begin(&self, key: &str, fingerprint: u64, lock_ttl: Duration) -> Result<Begin> {
639        let k = self.key(key);
640        let claim = encode_entry(fingerprint, None);
641        // a key can expire between the failed claim and the read: try again
642        for _ in 0..3 {
643            let set = Cmd::new("SET")
644                .arg(&k)
645                .arg(&claim)
646                .arg("NX")
647                .arg("PX")
648                .int(millis(lock_ttl));
649            if self.redis.run(set).await?.is_ok() {
650                return Ok(Begin::Started);
651            }
652            let Some(raw) = self.redis.run(Cmd::new("GET").arg(&k)).await?.into_bytes() else {
653                continue;
654            };
655            return Ok(match decode_entry(&raw) {
656                Some((fp, _)) if fp != fingerprint => Begin::Mismatch,
657                Some((_, None)) => Begin::InFlight,
658                Some((_, Some(res))) => Begin::Replay(res),
659                // unreadable entry: refuse rather than guess
660                None => Begin::Unavailable,
661            });
662        }
663        Ok(Begin::Unavailable)
664    }
665}
666
667#[async_trait]
668impl IdempotencyStore for RedisIdempotencyStore {
669    async fn begin(&self, key: &str, fingerprint: u64, lock_ttl: Duration) -> Begin {
670        self.try_begin(key, fingerprint, lock_ttl)
671            .await
672            .unwrap_or_else(|e| {
673                eprintln!("Idempotency store unavailable: {}", e.message());
674                Begin::Unavailable
675            })
676    }
677
678    async fn complete(&self, key: &str, response: StoredResponse, ttl: Duration) {
679        let k = self.key(key);
680        // keep the fingerprint of the claim; XX: only a claim still ours is finished
681        let Ok(Some(raw)) = self
682            .redis
683            .run(Cmd::new("GET").arg(&k))
684            .await
685            .map(Value::into_bytes)
686        else {
687            return;
688        };
689        let Some((fingerprint, _)) = decode_entry(&raw) else {
690            return;
691        };
692        let cmd = Cmd::new("SET")
693            .arg(&k)
694            .arg(encode_entry(fingerprint, Some(&response)))
695            .arg("XX")
696            .arg("PX")
697            .int(millis(ttl));
698        if let Err(e) = self.redis.run(cmd).await {
699            eprintln!("Saving idempotent response failed: {}", e.message());
700        }
701    }
702
703    async fn abandon(&self, key: &str) {
704        let k = self.key(key);
705        // only a running claim is released, never a stored response
706        if let Ok(Value::Bulk(raw)) = self.redis.run(Cmd::new("GET").arg(&k)).await {
707            if matches!(decode_entry(&raw), Some((_, None))) {
708                let _ = self.redis.run(Cmd::new("DEL").arg(&k)).await;
709            }
710        }
711    }
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    #[test]
719    fn commands_are_encoded_as_resp_arrays() {
720        let cmd = Cmd::new("SET").arg("k").arg(b"v\r\n").int(5);
721        assert_eq!(
722            cmd.encode(),
723            b"*4\r\n$3\r\nSET\r\n$1\r\nk\r\n$3\r\nv\r\n\r\n$1\r\n5\r\n".to_vec()
724        );
725    }
726
727    #[test]
728    fn urls_are_parsed() {
729        let r = Redis::from_url("redis://localhost").unwrap();
730        assert_eq!((r.addr.as_str(), r.db), ("localhost:6379", 0));
731        let r = Redis::from_url("redis://h:7000/3").unwrap();
732        assert_eq!((r.addr.as_str(), r.db), ("h:7000", 3));
733        let r = Redis::from_url("redis://:p%40ss@h/").unwrap();
734        assert_eq!(r.password.as_deref(), Some("p@ss"));
735        assert_eq!(r.username, None);
736        let r = Redis::from_url("redis://ann:secret@h:1/2").unwrap();
737        assert_eq!(r.username.as_deref(), Some("ann"));
738        assert_eq!(r.password.as_deref(), Some("secret"));
739        for bad in [
740            "",
741            "localhost",
742            "rediss://h",
743            "redis://",
744            "redis://h/x",
745            "http://h",
746        ] {
747            assert!(Redis::from_url(bad).is_err(), "{bad}");
748        }
749    }
750
751    #[test]
752    fn idempotency_entries_roundtrip() {
753        let running = encode_entry(42, None);
754        assert_eq!(
755            decode_entry(&running).map(|(f, r)| (f, r.is_none())),
756            Some((42, true))
757        );
758        let res = StoredResponse {
759            status: 201,
760            headers: vec![
761                ("content-type".into(), b"application/json".to_vec()),
762                ("x".into(), vec![0, 255]),
763            ],
764            body: Bytes::from_static(b"{\"id\":1}\r\n\0"),
765        };
766        let (fp, got) = decode_entry(&encode_entry(7, Some(&res))).unwrap();
767        let got = got.unwrap();
768        assert_eq!(
769            (fp, got.status, &got.headers, &got.body),
770            (7, 201, &res.headers, &res.body)
771        );
772        // truncated or unknown entries are rejected
773        let full = encode_entry(7, Some(&res));
774        for cut in 0..full.len().min(40) {
775            let _ = decode_entry(&full[..cut]);
776        }
777        assert!(decode_entry(&full[..12]).is_none());
778        assert!(decode_entry(b"short").is_none());
779        let mut unknown = encode_entry(1, None);
780        *unknown.last_mut().unwrap() = 9;
781        assert!(decode_entry(&unknown).is_none());
782    }
783}