Skip to main content

reddb_client/
connect.rs

1//! Connection-string parser. Thin shim that delegates to
2//! [`reddb_wire::conn_string`] (the canonical, workspace-shared
3//! parser) and projects its richer [`ConnectionTarget`] vocabulary
4//! onto the legacy [`Target`] enum exposed by this crate.
5//!
6//! Why the shim: the previously-published `reddb-client` driver
7//! had its own copy of the parser that mapped `red://host:port` to
8//! a gRPC endpoint. The shared parser exposes a separate
9//! [`reddb_wire::ConnectionTarget::RedWire`] variant; keeping the
10//! shim preserves the existing public API surface so downstream
11//! `reddb_client::connect::Target` users keep compiling without
12//! changes. Direct callers can opt into the richer vocabulary by
13//! depending on `reddb-wire` and using `reddb_wire::parse` instead.
14
15use std::path::PathBuf;
16
17use reddb_wire::{parse as wire_parse, ConnectionTarget, ParseErrorKind};
18
19use crate::error::{ClientError, ErrorCode, Result};
20
21/// What kind of backend the user asked for.
22///
23/// Note: `red://` and `reds://` URIs are folded onto
24/// [`Target::Grpc`] / [`Target::GrpcCluster`] for backwards
25/// compatibility with the previous driver release. New code that
26/// wants the RedWire variant explicitly should depend on
27/// `reddb-wire` directly.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum Target {
30    /// `memory://` — ephemeral, in-memory backend.
31    Memory,
32    /// `file:///abs/path` — embedded engine on disk.
33    File { path: PathBuf },
34    /// `grpc://host:port` — single-host remote tonic client.
35    /// Also produced by `red://host:port` and `reds://host:port`
36    /// for back-compat with the previous driver behaviour.
37    Grpc { endpoint: String },
38    /// `grpc://primary:port,replica1:port,replica2:port` — primary +
39    /// read-replica fleet. Writes always go to `primary`; reads
40    /// round-robin across `replicas` (or to `primary` when the
41    /// replica set is empty / a `?route=primary` query param is set).
42    GrpcCluster {
43        primary: String,
44        replicas: Vec<String>,
45        force_primary: bool,
46    },
47    /// `http://host:port` / `https://host:port` — REST client.
48    Http { base_url: String },
49}
50
51/// Parse a connection URI. Pure function, no side effects.
52pub fn parse(uri: &str) -> Result<Target> {
53    let target = wire_parse(uri).map_err(|e| match e.kind {
54        ParseErrorKind::Empty => ClientError::new(ErrorCode::InvalidUri, e.message),
55        ParseErrorKind::InvalidUri => ClientError::new(ErrorCode::InvalidUri, e.message),
56        ParseErrorKind::UnsupportedScheme => {
57            // `e.message` is `"unsupported scheme: <scheme>"`; fall
58            // back to the helper for the canonical wording.
59            let scheme = e
60                .message
61                .strip_prefix("unsupported scheme: ")
62                .unwrap_or(&e.message);
63            ClientError::unsupported_scheme(scheme)
64        }
65        ParseErrorKind::LimitExceeded => {
66            // DoS guardrails added in #90 (max URI bytes, max query
67            // params, max cluster hosts). Surface as InvalidUri with
68            // the structured message intact.
69            ClientError::new(ErrorCode::InvalidUri, e.message)
70        }
71    })?;
72    Ok(map_target(target))
73}
74
75fn map_target(t: ConnectionTarget) -> Target {
76    match t {
77        ConnectionTarget::Memory => Target::Memory,
78        ConnectionTarget::File { path } => Target::File { path },
79        ConnectionTarget::Grpc { endpoint } => Target::Grpc { endpoint },
80        ConnectionTarget::GrpcCluster {
81            primary,
82            replicas,
83            force_primary,
84        } => Target::GrpcCluster {
85            primary,
86            replicas,
87            force_primary,
88        },
89        ConnectionTarget::Http { base_url } => Target::Http { base_url },
90        // Back-compat: previous driver routed `red://` / `reds://`
91        // through the gRPC endpoint, not a separate RedWire path.
92        // Preserve that mapping until downstream code migrates.
93        ConnectionTarget::RedWire { host, port, tls } => {
94            let _ = tls;
95            Target::Grpc {
96                endpoint: format!("http://{host}:{port}"),
97            }
98        }
99        // `red+wss://` / `red+ws://` is a browser-native WS transport
100        // (ADR 0047). The legacy client has no WS transport, so fold onto
101        // the HTTP base URL — callers that need the WS path should use
102        // `reddb_wire::parse` and the `WsNative` variant directly.
103        ConnectionTarget::WsNative { host, port, tls } => {
104            let scheme = if tls { "https" } else { "http" };
105            Target::Http {
106                base_url: format!("{scheme}://{host}:{port}"),
107            }
108        }
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn parses_memory() {
118        assert_eq!(parse("memory://").unwrap(), Target::Memory);
119        assert_eq!(parse("memory:").unwrap(), Target::Memory);
120    }
121
122    #[test]
123    fn parses_file_with_absolute_path() {
124        let target = parse("file:///var/lib/reddb/data.rdb").unwrap();
125        match target {
126            Target::File { path } => assert_eq!(path, PathBuf::from("/var/lib/reddb/data.rdb")),
127            _ => panic!("expected File"),
128        }
129    }
130
131    #[test]
132    fn parses_grpc_with_default_port() {
133        let target = parse("grpc://primary.svc.cluster.local").unwrap();
134        match target {
135            Target::Grpc { endpoint } => {
136                assert_eq!(endpoint, "http://primary.svc.cluster.local:55055")
137            }
138            _ => panic!("expected Grpc"),
139        }
140    }
141
142    #[test]
143    fn parses_grpcs_with_default_tls_port() {
144        let target = parse("grpcs://primary.svc.cluster.local").unwrap();
145        match target {
146            Target::Grpc { endpoint } => {
147                assert_eq!(endpoint, "http://primary.svc.cluster.local:55555")
148            }
149            _ => panic!("expected Grpc"),
150        }
151    }
152
153    #[test]
154    fn parses_red_with_default_port() {
155        let target = parse("red://primary.svc.cluster.local").unwrap();
156        match target {
157            Target::Grpc { endpoint } => {
158                assert_eq!(endpoint, "http://primary.svc.cluster.local:5050")
159            }
160            _ => panic!("expected Grpc (back-compat for red://)"),
161        }
162    }
163
164    #[test]
165    fn parses_grpc_with_explicit_port() {
166        let target = parse("grpc://primary:6000").unwrap();
167        match target {
168            Target::Grpc { endpoint } => assert_eq!(endpoint, "http://primary:6000"),
169            _ => panic!("expected Grpc"),
170        }
171    }
172
173    #[test]
174    fn rejects_unknown_scheme() {
175        let err = parse("mongodb://localhost").unwrap_err();
176        assert_eq!(err.code, ErrorCode::UnsupportedScheme);
177    }
178
179    #[test]
180    fn rejects_empty() {
181        assert_eq!(parse("").unwrap_err().code, ErrorCode::InvalidUri);
182    }
183
184    #[test]
185    fn rejects_file_without_path() {
186        assert_eq!(parse("file://").unwrap_err().code, ErrorCode::InvalidUri);
187    }
188
189    #[test]
190    fn parses_grpc_cluster_with_explicit_ports() {
191        let target = parse("grpc://primary:55055,replica1:55055,replica2:55055").unwrap();
192        match target {
193            Target::GrpcCluster {
194                primary,
195                replicas,
196                force_primary,
197            } => {
198                assert_eq!(primary, "http://primary:55055");
199                assert_eq!(
200                    replicas,
201                    vec!["http://replica1:55055", "http://replica2:55055"]
202                );
203                assert!(!force_primary);
204            }
205            other => panic!("expected GrpcCluster, got {other:?}"),
206        }
207    }
208
209    #[test]
210    fn cluster_inherits_default_port_per_scheme() {
211        match parse("grpc://a,b").unwrap() {
212            Target::GrpcCluster {
213                primary, replicas, ..
214            } => {
215                assert_eq!(primary, "http://a:55055");
216                assert_eq!(replicas, vec!["http://b:55055"]);
217            }
218            other => panic!("expected GrpcCluster, got {other:?}"),
219        }
220        match parse("red://a,b").unwrap() {
221            Target::GrpcCluster {
222                primary, replicas, ..
223            } => {
224                assert_eq!(primary, "http://a:5050");
225                assert_eq!(replicas, vec!["http://b:5050"]);
226            }
227            other => panic!("expected GrpcCluster, got {other:?}"),
228        }
229    }
230
231    #[test]
232    fn cluster_per_host_port_overrides_default() {
233        match parse("grpc://a:7000,b:7001,c").unwrap() {
234            Target::GrpcCluster {
235                primary, replicas, ..
236            } => {
237                assert_eq!(primary, "http://a:7000");
238                assert_eq!(replicas, vec!["http://b:7001", "http://c:55055"]);
239            }
240            other => panic!("expected GrpcCluster, got {other:?}"),
241        }
242    }
243
244    #[test]
245    fn cluster_route_primary_query_param_forces_primary() {
246        match parse("grpc://primary,replica?route=primary").unwrap() {
247            Target::GrpcCluster {
248                primary,
249                replicas,
250                force_primary,
251            } => {
252                assert_eq!(primary, "http://primary:55055");
253                assert_eq!(replicas, vec!["http://replica:55055"]);
254                assert!(force_primary, "?route=primary must set force_primary");
255            }
256            other => panic!("expected GrpcCluster, got {other:?}"),
257        }
258    }
259
260    #[test]
261    fn cluster_rejects_empty_host_entry() {
262        assert_eq!(
263            parse("grpc://primary,,replica").unwrap_err().code,
264            ErrorCode::InvalidUri
265        );
266        assert_eq!(parse("grpc://,b").unwrap_err().code, ErrorCode::InvalidUri);
267    }
268
269    #[test]
270    fn cluster_rejects_invalid_port() {
271        assert_eq!(
272            parse("grpc://a:nope,b:55055").unwrap_err().code,
273            ErrorCode::InvalidUri
274        );
275    }
276
277    #[test]
278    fn single_host_grpc_still_routes_to_grpc_target_not_cluster() {
279        match parse("grpc://primary:55055").unwrap() {
280            Target::Grpc { endpoint } => assert_eq!(endpoint, "http://primary:55055"),
281            other => panic!("expected Grpc (single host), got {other:?}"),
282        }
283    }
284}