Skip to main content

reinhardt_core/
ws.rs

1//! WebSocket routing primitives shared across reinhardt crates.
2//!
3//! This module provides foundational WebSocket types used by both
4//! `reinhardt-websockets` (connection handling) and `reinhardt-urls`
5//! (`UnifiedRouter::websocket()` builder). Placing them here avoids a
6//! circular dependency between those two crates.
7
8use std::collections::HashMap;
9use std::sync::Arc;
10use tokio::sync::RwLock;
11
12// ── Endpoint metadata ─────────────────────────────────────────────────────
13
14/// Compile-time WebSocket endpoint metadata.
15///
16/// Implemented on the consumer struct generated by `#[websocket]`.
17/// Parallel to `EndpointInfo` for HTTP views.
18pub trait WebSocketEndpointInfo {
19	/// Returns the URL path pattern for this WebSocket endpoint.
20	fn path() -> &'static str;
21	/// Returns the optional route name for this WebSocket endpoint.
22	fn name() -> Option<&'static str>;
23}
24
25/// Inventory metadata submitted by `#[websocket]` at compile time.
26///
27/// Used by `UrlReverser` to resolve WebSocket route names.
28pub struct WebSocketEndpointMetadata {
29	/// URL path pattern (e.g. `"/ws/chat/{room_id}/"`).
30	pub path: &'static str,
31	/// Route name used for URL reversal.
32	pub name: &'static str,
33	/// Handler function name for diagnostics.
34	pub fn_name: &'static str,
35	/// Rust module path of the handler for diagnostics.
36	pub module_path: &'static str,
37}
38
39inventory::collect!(WebSocketEndpointMetadata);
40
41/// Substitute path parameters in a WebSocket URL pattern.
42///
43/// `"/ws/chat/{room_id}/"` + `[("room_id", "42")]` → `"/ws/chat/42/"`
44pub fn substitute_ws_params(path: &str, params: &[(&str, &str)]) -> String {
45	let mut result = path.to_string();
46	for (name, value) in params {
47		result = result.replace(&format!("{{{}}}", name), value);
48	}
49	result
50}
51
52// ── Routing types ─────────────────────────────────────────────────────────
53
54/// Routing result type
55pub type RouteResult = Result<(), RouteError>;
56
57/// Routing errors for WebSocket routes
58#[derive(Debug, thiserror::Error)]
59pub enum RouteError {
60	/// No route registered for the given path.
61	#[error("Route not found: {0}")]
62	NotFound(String),
63	/// A route with the given path is already registered.
64	#[error("Route already exists: {0}")]
65	AlreadyExists(String),
66	/// The provided route pattern is syntactically invalid.
67	#[error("Invalid route pattern: {0}")]
68	InvalidPattern(String),
69}
70
71/// A registered WebSocket route (path + optional name + metadata).
72#[derive(Debug, Clone)]
73pub struct WebSocketRoute {
74	path: String,
75	name: Option<String>,
76	metadata: HashMap<String, String>,
77}
78
79impl WebSocketRoute {
80	/// Creates a new route with the given path and optional name.
81	pub fn new(path: String, name: Option<String>) -> Self {
82		Self {
83			path,
84			name,
85			metadata: HashMap::new(),
86		}
87	}
88
89	/// Returns the URL path pattern for this route.
90	pub fn path(&self) -> &str {
91		&self.path
92	}
93
94	/// Returns the optional name for this route.
95	pub fn name(&self) -> Option<&str> {
96		self.name.as_deref()
97	}
98
99	/// Attaches a key-value metadata entry to this route.
100	pub fn with_metadata(mut self, key: String, value: String) -> Self {
101		self.metadata.insert(key, value);
102		self
103	}
104
105	/// Returns the metadata value for the given key, if present.
106	pub fn get_metadata(&self, key: &str) -> Option<&String> {
107		self.metadata.get(key)
108	}
109}
110
111// ── WebSocketRouter ───────────────────────────────────────────────────────
112
113/// WebSocket router: build-time registration + runtime lookup.
114///
115/// The build-time API (`consumer()`, `reverse()`, `find_pending()`) is used
116/// by `UnifiedRouter::websocket()` and WebSocket route declarations.
117/// The async API (`register_route()`, `find_route()`, etc.) is used at
118/// connection-handling time in `reinhardt-websockets`.
119#[derive(Clone)]
120pub struct WebSocketRouter {
121	routes: Arc<RwLock<HashMap<String, WebSocketRoute>>>,
122	names: Arc<RwLock<HashMap<String, String>>>,
123	/// Build-time consumer registrations (added by `consumer()` builder).
124	pending_consumers: Vec<WebSocketRoute>,
125	/// Optional namespace (app label) for this router.
126	namespace: Option<String>,
127}
128
129impl WebSocketRouter {
130	/// Creates a new empty router.
131	pub fn new() -> Self {
132		Self {
133			routes: Arc::new(RwLock::new(HashMap::new())),
134			names: Arc::new(RwLock::new(HashMap::new())),
135			pending_consumers: Vec::new(),
136			namespace: None,
137		}
138	}
139
140	/// Set the namespace for this router.
141	///
142	/// Parallel to `ServerRouter::with_namespace`.
143	/// WebSocket route paths are absolute today and are not rewritten
144	/// with this namespace; the value is stored for parity with other
145	/// routers and future use. See reinhardt-web#3829.
146	pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
147		self.namespace = Some(namespace.into());
148		self
149	}
150
151	/// Returns the namespace set via [`with_namespace`], if any.
152	///
153	/// [`with_namespace`]: Self::with_namespace
154	pub fn namespace(&self) -> Option<&str> {
155		self.namespace.as_deref()
156	}
157
158	/// Register a WebSocket consumer by its factory function.
159	///
160	/// Parallel to `ServerRouter::endpoint()`. Path and name are derived
161	/// from `C`'s `WebSocketEndpointInfo` impl at compile time.
162	pub fn consumer<C, F>(mut self, _f: F) -> Self
163	where
164		F: Fn() -> C,
165		C: WebSocketEndpointInfo + 'static,
166	{
167		self.pending_consumers.push(WebSocketRoute::new(
168			C::path().to_string(),
169			C::name().map(|s| s.to_string()),
170		));
171		self
172	}
173
174	/// Find a pending consumer route by name.
175	pub fn find_pending(&self, name: &str) -> Option<&WebSocketRoute> {
176		self.pending_consumers
177			.iter()
178			.find(|r| r.name() == Some(name))
179	}
180
181	/// Resolve a WebSocket URL by route name, substituting path parameters.
182	pub fn reverse(&self, name: &str, params: &[(&str, &str)]) -> Option<String> {
183		self.pending_consumers
184			.iter()
185			.find(|r| r.name() == Some(name))
186			.map(|r| substitute_ws_params(r.path(), params))
187	}
188
189	/// Register a route at runtime (async, used by the connection handler).
190	pub async fn register_route(&mut self, route: WebSocketRoute) -> RouteResult {
191		let mut routes = self.routes.write().await;
192		if routes.contains_key(&route.path) {
193			return Err(RouteError::AlreadyExists(route.path.clone()));
194		}
195		if let Some(name) = &route.name {
196			let mut names = self.names.write().await;
197			names.insert(name.clone(), route.path.clone());
198		}
199		routes.insert(route.path.clone(), route);
200		Ok(())
201	}
202
203	/// Looks up a registered route by its exact path.
204	pub async fn find_route(&self, path: &str) -> Option<WebSocketRoute> {
205		let routes = self.routes.read().await;
206		routes.get(path).cloned()
207	}
208
209	/// Looks up a registered route by its name.
210	pub async fn find_route_by_name(&self, name: &str) -> Option<WebSocketRoute> {
211		let names = self.names.read().await;
212		if let Some(path) = names.get(name) {
213			let routes = self.routes.read().await;
214			routes.get(path).cloned()
215		} else {
216			None
217		}
218	}
219
220	/// Removes the registered route for the given path.
221	pub async fn remove_route(&mut self, path: &str) -> RouteResult {
222		let mut routes = self.routes.write().await;
223		let route = routes
224			.remove(path)
225			.ok_or_else(|| RouteError::NotFound(path.to_string()))?;
226		if let Some(name) = &route.name {
227			let mut names = self.names.write().await;
228			names.remove(name);
229		}
230		Ok(())
231	}
232
233	/// Returns all currently registered routes.
234	pub async fn all_routes(&self) -> Vec<WebSocketRoute> {
235		let routes = self.routes.read().await;
236		routes.values().cloned().collect()
237	}
238
239	/// Returns `true` if a route is registered for the given path.
240	pub async fn has_route(&self, path: &str) -> bool {
241		self.routes.read().await.contains_key(path)
242	}
243
244	/// Returns the number of currently registered routes.
245	pub async fn route_count(&self) -> usize {
246		self.routes.read().await.len()
247	}
248
249	/// Removes all registered routes and name mappings.
250	pub async fn clear(&mut self) {
251		self.routes.write().await.clear();
252		self.names.write().await.clear();
253	}
254}
255
256impl Default for WebSocketRouter {
257	fn default() -> Self {
258		Self::new()
259	}
260}
261
262// ── Global registry ───────────────────────────────────────────────────────
263
264static GLOBAL_ROUTER: once_cell::sync::Lazy<Arc<RwLock<Option<WebSocketRouter>>>> =
265	once_cell::sync::Lazy::new(|| Arc::new(RwLock::new(None)));
266
267/// Installs `router` as the process-wide WebSocket router.
268pub async fn register_websocket_router(router: WebSocketRouter) {
269	*GLOBAL_ROUTER.write().await = Some(router);
270}
271
272/// Returns a clone of the current process-wide WebSocket router, if set.
273pub async fn get_websocket_router() -> Option<WebSocketRouter> {
274	GLOBAL_ROUTER.read().await.clone()
275}
276
277/// Clears the process-wide WebSocket router (primarily for tests).
278pub async fn clear_websocket_router() {
279	*GLOBAL_ROUTER.write().await = None;
280}
281
282/// Resolves a registered or pending WebSocket URL by route name.
283pub async fn reverse_websocket_url(router: &WebSocketRouter, name: &str) -> Option<String> {
284	let names = router.names.read().await;
285	if let Some(path) = names.get(name) {
286		let routes = router.routes.read().await;
287		routes.get(path).map(|r| r.path().to_string())
288	} else {
289		router.find_pending(name).map(|r| r.path().to_string())
290	}
291}
292
293#[cfg(test)]
294mod tests {
295	use super::*;
296	use rstest::rstest;
297
298	struct TestConsumer;
299	impl WebSocketEndpointInfo for TestConsumer {
300		fn path() -> &'static str {
301			"/ws/chat/{room_id}/"
302		}
303		fn name() -> Option<&'static str> {
304			Some("chat_ws")
305		}
306	}
307
308	#[rstest]
309	fn test_substitute_no_params() {
310		assert_eq!(substitute_ws_params("/ws/notif/", &[]), "/ws/notif/");
311	}
312
313	#[rstest]
314	fn test_substitute_one_param() {
315		assert_eq!(
316			substitute_ws_params("/ws/chat/{room_id}/", &[("room_id", "42")]),
317			"/ws/chat/42/"
318		);
319	}
320
321	#[rstest]
322	fn test_consumer_builder() {
323		let router = WebSocketRouter::new().consumer(|| TestConsumer);
324		let route = router.find_pending("chat_ws");
325		assert!(route.is_some());
326		assert_eq!(route.unwrap().path(), "/ws/chat/{room_id}/");
327	}
328
329	#[rstest]
330	fn test_with_namespace_stores_value_without_rewriting_paths() {
331		let router = WebSocketRouter::new()
332			.with_namespace("auth")
333			.consumer(|| TestConsumer);
334		assert_eq!(router.namespace(), Some("auth"));
335		assert_eq!(
336			router.find_pending("chat_ws").unwrap().path(),
337			"/ws/chat/{room_id}/"
338		);
339	}
340
341	#[rstest]
342	fn test_reverse() {
343		let router = WebSocketRouter::new().consumer(|| TestConsumer);
344		assert_eq!(
345			router.reverse("chat_ws", &[("room_id", "99")]),
346			Some("/ws/chat/99/".to_string())
347		);
348		assert_eq!(router.reverse("unknown", &[]), None);
349	}
350}