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:5055")
137            }
138            _ => panic!("expected Grpc"),
139        }
140    }
141
142    #[test]
143    fn parses_red_with_default_port() {
144        let target = parse("red://primary.svc.cluster.local").unwrap();
145        match target {
146            Target::Grpc { endpoint } => {
147                assert_eq!(endpoint, "http://primary.svc.cluster.local:5050")
148            }
149            _ => panic!("expected Grpc (back-compat for red://)"),
150        }
151    }
152
153    #[test]
154    fn parses_grpc_with_explicit_port() {
155        let target = parse("grpc://primary:6000").unwrap();
156        match target {
157            Target::Grpc { endpoint } => assert_eq!(endpoint, "http://primary:6000"),
158            _ => panic!("expected Grpc"),
159        }
160    }
161
162    #[test]
163    fn rejects_unknown_scheme() {
164        let err = parse("mongodb://localhost").unwrap_err();
165        assert_eq!(err.code, ErrorCode::UnsupportedScheme);
166    }
167
168    #[test]
169    fn rejects_empty() {
170        assert_eq!(parse("").unwrap_err().code, ErrorCode::InvalidUri);
171    }
172
173    #[test]
174    fn rejects_file_without_path() {
175        assert_eq!(parse("file://").unwrap_err().code, ErrorCode::InvalidUri);
176    }
177
178    #[test]
179    fn parses_grpc_cluster_with_explicit_ports() {
180        let target = parse("grpc://primary:5055,replica1:5055,replica2:5055").unwrap();
181        match target {
182            Target::GrpcCluster {
183                primary,
184                replicas,
185                force_primary,
186            } => {
187                assert_eq!(primary, "http://primary:5055");
188                assert_eq!(
189                    replicas,
190                    vec!["http://replica1:5055", "http://replica2:5055"]
191                );
192                assert!(!force_primary);
193            }
194            other => panic!("expected GrpcCluster, got {other:?}"),
195        }
196    }
197
198    #[test]
199    fn cluster_inherits_default_port_per_scheme() {
200        match parse("grpc://a,b").unwrap() {
201            Target::GrpcCluster {
202                primary, replicas, ..
203            } => {
204                assert_eq!(primary, "http://a:5055");
205                assert_eq!(replicas, vec!["http://b:5055"]);
206            }
207            other => panic!("expected GrpcCluster, got {other:?}"),
208        }
209        match parse("red://a,b").unwrap() {
210            Target::GrpcCluster {
211                primary, replicas, ..
212            } => {
213                assert_eq!(primary, "http://a:5050");
214                assert_eq!(replicas, vec!["http://b:5050"]);
215            }
216            other => panic!("expected GrpcCluster, got {other:?}"),
217        }
218    }
219
220    #[test]
221    fn cluster_per_host_port_overrides_default() {
222        match parse("grpc://a:7000,b:7001,c").unwrap() {
223            Target::GrpcCluster {
224                primary, replicas, ..
225            } => {
226                assert_eq!(primary, "http://a:7000");
227                assert_eq!(replicas, vec!["http://b:7001", "http://c:5055"]);
228            }
229            other => panic!("expected GrpcCluster, got {other:?}"),
230        }
231    }
232
233    #[test]
234    fn cluster_route_primary_query_param_forces_primary() {
235        match parse("grpc://primary,replica?route=primary").unwrap() {
236            Target::GrpcCluster {
237                primary,
238                replicas,
239                force_primary,
240            } => {
241                assert_eq!(primary, "http://primary:5055");
242                assert_eq!(replicas, vec!["http://replica:5055"]);
243                assert!(force_primary, "?route=primary must set force_primary");
244            }
245            other => panic!("expected GrpcCluster, got {other:?}"),
246        }
247    }
248
249    #[test]
250    fn cluster_rejects_empty_host_entry() {
251        assert_eq!(
252            parse("grpc://primary,,replica").unwrap_err().code,
253            ErrorCode::InvalidUri
254        );
255        assert_eq!(parse("grpc://,b").unwrap_err().code, ErrorCode::InvalidUri);
256    }
257
258    #[test]
259    fn cluster_rejects_invalid_port() {
260        assert_eq!(
261            parse("grpc://a:nope,b:5055").unwrap_err().code,
262            ErrorCode::InvalidUri
263        );
264    }
265
266    #[test]
267    fn single_host_grpc_still_routes_to_grpc_target_not_cluster() {
268        match parse("grpc://primary:5055").unwrap() {
269            Target::Grpc { endpoint } => assert_eq!(endpoint, "http://primary:5055"),
270            other => panic!("expected Grpc (single host), got {other:?}"),
271        }
272    }
273}