Skip to main content

mockforge_core/multi_tenant/
registry.rs

1//! Multi-tenant workspace support for MockForge
2//!
3//! This module provides infrastructure for hosting multiple isolated workspaces
4//! in a single MockForge instance, enabling namespace separation and tenant isolation.
5
6use crate::request_logger::CentralizedRequestLogger;
7use crate::routing::RouteRegistry;
8use crate::workspace::{EntityId, Workspace};
9use crate::{Error, Result};
10use chrono::{DateTime, Utc};
11pub use mockforge_foundation::multi_tenant_types::{MultiTenantConfig, RoutingStrategy};
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::sync::{Arc, RwLock};
15
16/// Tenant workspace wrapper with isolated resources
17#[derive(Debug, Clone)]
18pub struct TenantWorkspace {
19    /// Workspace metadata and configuration
20    pub workspace: Workspace,
21    /// Workspace-specific route registry
22    pub route_registry: Arc<RwLock<RouteRegistry>>,
23    /// Last access timestamp
24    pub last_accessed: DateTime<Utc>,
25    /// Whether this workspace is enabled
26    pub enabled: bool,
27    /// Workspace-specific statistics
28    pub stats: WorkspaceStats,
29}
30
31/// Statistics for a tenant workspace
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct WorkspaceStats {
34    /// Total number of requests handled
35    pub total_requests: u64,
36    /// Total number of active routes
37    pub active_routes: usize,
38    /// Last request timestamp
39    pub last_request_at: Option<DateTime<Utc>>,
40    /// Created at timestamp
41    pub created_at: DateTime<Utc>,
42    /// Average response time in milliseconds
43    pub avg_response_time_ms: f64,
44}
45
46impl Default for WorkspaceStats {
47    fn default() -> Self {
48        Self {
49            total_requests: 0,
50            active_routes: 0,
51            last_request_at: None,
52            created_at: Utc::now(),
53            avg_response_time_ms: 0.0,
54        }
55    }
56}
57
58/// Multi-tenant workspace registry for managing multiple isolated workspaces
59#[derive(Debug, Clone)]
60pub struct MultiTenantWorkspaceRegistry {
61    /// Tenant workspaces indexed by ID
62    workspaces: Arc<RwLock<HashMap<EntityId, TenantWorkspace>>>,
63    /// Default workspace ID
64    default_workspace_id: EntityId,
65    /// Configuration
66    config: MultiTenantConfig,
67    /// Global request logger (for aggregated logging)
68    global_logger: Arc<CentralizedRequestLogger>,
69}
70
71impl MultiTenantWorkspaceRegistry {
72    /// Create a new multi-tenant workspace registry
73    pub fn new(config: MultiTenantConfig) -> Self {
74        let default_workspace_id = config.default_workspace.clone();
75
76        Self {
77            workspaces: Arc::new(RwLock::new(HashMap::new())),
78            default_workspace_id,
79            config,
80            global_logger: Arc::new(CentralizedRequestLogger::new(10000)), // Keep last 10000 requests
81        }
82    }
83
84    /// Create with default configuration
85    pub fn with_default_workspace(workspace_name: String) -> Self {
86        let config = MultiTenantConfig {
87            default_workspace: "default".to_string(),
88            ..Default::default()
89        };
90
91        let mut registry = Self::new(config);
92
93        // Create and register default workspace
94        let default_workspace = Workspace::new(workspace_name);
95        let _ = registry.register_workspace("default".to_string(), default_workspace);
96
97        registry
98    }
99
100    /// Register a new workspace
101    pub fn register_workspace(&mut self, workspace_id: String, workspace: Workspace) -> Result<()> {
102        // Check max workspaces limit
103        if let Some(max) = self.config.max_workspaces {
104            let current_count = self
105                .workspaces
106                .read()
107                .map_err(|e| Error::internal(format!("Failed to read workspaces: {}", e)))?
108                .len();
109
110            if current_count >= max {
111                return Err(Error::validation(format!(
112                    "Maximum number of workspaces ({}) exceeded",
113                    max
114                )));
115            }
116        }
117
118        let tenant_workspace = TenantWorkspace {
119            workspace,
120            route_registry: Arc::new(RwLock::new(RouteRegistry::new())),
121            last_accessed: Utc::now(),
122            enabled: true,
123            stats: WorkspaceStats::default(),
124        };
125
126        self.workspaces
127            .write()
128            .map_err(|e| Error::internal(format!("Failed to write workspaces: {}", e)))?
129            .insert(workspace_id, tenant_workspace);
130
131        Ok(())
132    }
133
134    /// Get a workspace by ID
135    pub fn get_workspace(&self, workspace_id: &str) -> Result<TenantWorkspace> {
136        let workspaces = self
137            .workspaces
138            .read()
139            .map_err(|e| Error::internal(format!("Failed to read workspaces: {}", e)))?;
140
141        workspaces
142            .get(workspace_id)
143            .cloned()
144            .ok_or_else(|| Error::not_found("Workspace", workspace_id))
145    }
146
147    /// Get the default workspace
148    pub fn get_default_workspace(&self) -> Result<TenantWorkspace> {
149        self.get_workspace(&self.default_workspace_id)
150    }
151
152    /// Update workspace
153    pub fn update_workspace(&mut self, workspace_id: &str, workspace: Workspace) -> Result<()> {
154        let mut workspaces = self
155            .workspaces
156            .write()
157            .map_err(|e| Error::internal(format!("Failed to write workspaces: {}", e)))?;
158
159        if let Some(tenant_workspace) = workspaces.get_mut(workspace_id) {
160            tenant_workspace.workspace = workspace;
161            Ok(())
162        } else {
163            Err(Error::not_found("Workspace", workspace_id))
164        }
165    }
166
167    /// Remove a workspace
168    pub fn remove_workspace(&mut self, workspace_id: &str) -> Result<()> {
169        // Prevent removing default workspace
170        if workspace_id == self.default_workspace_id {
171            return Err(Error::validation("Cannot remove default workspace"));
172        }
173
174        self.workspaces
175            .write()
176            .map_err(|e| Error::internal(format!("Failed to write workspaces: {}", e)))?
177            .remove(workspace_id)
178            .ok_or_else(|| Error::not_found("Workspace", workspace_id))?;
179
180        Ok(())
181    }
182
183    /// List all workspaces
184    pub fn list_workspaces(&self) -> Result<Vec<(String, TenantWorkspace)>> {
185        let workspaces = self
186            .workspaces
187            .read()
188            .map_err(|e| Error::internal(format!("Failed to read workspaces: {}", e)))?;
189
190        Ok(workspaces.iter().map(|(id, ws)| (id.clone(), ws.clone())).collect())
191    }
192
193    /// Get workspace by ID or default
194    pub fn resolve_workspace(&self, workspace_id: Option<&str>) -> Result<TenantWorkspace> {
195        if let Some(id) = workspace_id {
196            self.get_workspace(id)
197        } else {
198            self.get_default_workspace()
199        }
200    }
201
202    /// Update workspace last accessed time
203    pub fn touch_workspace(&mut self, workspace_id: &str) -> Result<()> {
204        let mut workspaces = self
205            .workspaces
206            .write()
207            .map_err(|e| Error::internal(format!("Failed to write workspaces: {}", e)))?;
208
209        if let Some(tenant_workspace) = workspaces.get_mut(workspace_id) {
210            tenant_workspace.last_accessed = Utc::now();
211            Ok(())
212        } else {
213            Err(Error::not_found("Workspace", workspace_id))
214        }
215    }
216
217    /// Update workspace statistics
218    pub fn update_workspace_stats(
219        &mut self,
220        workspace_id: &str,
221        response_time_ms: f64,
222    ) -> Result<()> {
223        let mut workspaces = self
224            .workspaces
225            .write()
226            .map_err(|e| Error::internal(format!("Failed to write workspaces: {}", e)))?;
227
228        if let Some(tenant_workspace) = workspaces.get_mut(workspace_id) {
229            tenant_workspace.stats.total_requests += 1;
230            tenant_workspace.stats.last_request_at = Some(Utc::now());
231
232            // Update average response time using running average
233            let n = tenant_workspace.stats.total_requests as f64;
234            tenant_workspace.stats.avg_response_time_ms =
235                ((tenant_workspace.stats.avg_response_time_ms * (n - 1.0)) + response_time_ms) / n;
236
237            Ok(())
238        } else {
239            Err(Error::not_found("Workspace", workspace_id))
240        }
241    }
242
243    /// Get workspace count
244    pub fn workspace_count(&self) -> Result<usize> {
245        let workspaces = self
246            .workspaces
247            .read()
248            .map_err(|e| Error::internal(format!("Failed to read workspaces: {}", e)))?;
249
250        Ok(workspaces.len())
251    }
252
253    /// Check if workspace exists
254    pub fn workspace_exists(&self, workspace_id: &str) -> bool {
255        self.workspaces.read().map(|ws| ws.contains_key(workspace_id)).unwrap_or(false)
256    }
257
258    /// Enable/disable a workspace
259    pub fn set_workspace_enabled(&mut self, workspace_id: &str, enabled: bool) -> Result<()> {
260        let mut workspaces = self
261            .workspaces
262            .write()
263            .map_err(|e| Error::internal(format!("Failed to write workspaces: {}", e)))?;
264
265        if let Some(tenant_workspace) = workspaces.get_mut(workspace_id) {
266            tenant_workspace.enabled = enabled;
267            Ok(())
268        } else {
269            Err(Error::not_found("Workspace", workspace_id))
270        }
271    }
272
273    /// Get the global request logger
274    pub fn global_logger(&self) -> &Arc<CentralizedRequestLogger> {
275        &self.global_logger
276    }
277
278    /// Get configuration
279    pub fn config(&self) -> &MultiTenantConfig {
280        &self.config
281    }
282
283    /// Extract workspace ID from request path
284    pub fn extract_workspace_id_from_path(&self, path: &str) -> Option<String> {
285        if !self.config.enabled {
286            return None;
287        }
288
289        let prefix = &self.config.workspace_prefix;
290
291        // Check if path starts with workspace prefix
292        if !path.starts_with(prefix) {
293            return None;
294        }
295
296        // Extract workspace ID from path: /workspace/{id}/...
297        let remaining = &path[prefix.len()..];
298
299        // Skip leading slash
300        let remaining = remaining.strip_prefix('/').unwrap_or(remaining);
301
302        // Get first path segment (workspace ID)
303        remaining.split('/').next().filter(|id| !id.is_empty()).map(|id| id.to_string())
304    }
305
306    /// Strip workspace prefix from path
307    pub fn strip_workspace_prefix(&self, path: &str, workspace_id: &str) -> String {
308        if !self.config.enabled {
309            return path.to_string();
310        }
311
312        let prefix = format!("{}/{}", self.config.workspace_prefix, workspace_id);
313
314        if path.starts_with(&prefix) {
315            let remaining = &path[prefix.len()..];
316            if remaining.is_empty() {
317                "/".to_string()
318            } else {
319                remaining.to_string()
320            }
321        } else {
322            path.to_string()
323        }
324    }
325}
326
327impl TenantWorkspace {
328    /// Create a new tenant workspace
329    pub fn new(workspace: Workspace) -> Self {
330        Self {
331            workspace,
332            route_registry: Arc::new(RwLock::new(RouteRegistry::new())),
333            last_accessed: Utc::now(),
334            enabled: true,
335            stats: WorkspaceStats::default(),
336        }
337    }
338
339    /// Get workspace ID
340    pub fn id(&self) -> &str {
341        &self.workspace.id
342    }
343
344    /// Get workspace name
345    pub fn name(&self) -> &str {
346        &self.workspace.name
347    }
348
349    /// Get route registry
350    pub fn route_registry(&self) -> &Arc<RwLock<RouteRegistry>> {
351        &self.route_registry
352    }
353
354    /// Get workspace statistics
355    pub fn stats(&self) -> &WorkspaceStats {
356        &self.stats
357    }
358
359    /// Rebuild route registry from workspace routes
360    pub fn rebuild_routes(&mut self) -> Result<()> {
361        let routes = self.workspace.get_routes();
362
363        let mut registry = self
364            .route_registry
365            .write()
366            .map_err(|e| Error::internal(format!("Failed to write route registry: {}", e)))?;
367
368        // Clear existing routes
369        *registry = RouteRegistry::new();
370
371        // Add all routes from workspace
372        for route in routes {
373            registry.add_http_route(route)?;
374        }
375
376        // Update stats - count total number of routes
377        self.stats.active_routes = self.workspace.requests.len()
378            + self.workspace.folders.iter().map(Self::count_folder_requests).sum::<usize>();
379
380        Ok(())
381    }
382
383    /// Count requests in a folder recursively
384    fn count_folder_requests(folder: &crate::workspace::Folder) -> usize {
385        folder.requests.len()
386            + folder.folders.iter().map(Self::count_folder_requests).sum::<usize>()
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    #[test]
395    fn test_multi_tenant_config_default() {
396        let config = MultiTenantConfig::default();
397        assert!(!config.enabled);
398        assert_eq!(config.routing_strategy, RoutingStrategy::Path);
399        assert_eq!(config.workspace_prefix, "/workspace");
400        assert_eq!(config.default_workspace, "default");
401    }
402
403    #[test]
404    fn test_multi_tenant_registry_creation() {
405        let config = MultiTenantConfig::default();
406        let registry = MultiTenantWorkspaceRegistry::new(config);
407        assert_eq!(registry.workspace_count().unwrap(), 0);
408    }
409
410    #[test]
411    fn test_register_workspace() {
412        let config = MultiTenantConfig::default();
413        let mut registry = MultiTenantWorkspaceRegistry::new(config);
414
415        let workspace = Workspace::new("Test Workspace".to_string());
416        registry.register_workspace("test".to_string(), workspace).unwrap();
417
418        assert_eq!(registry.workspace_count().unwrap(), 1);
419        assert!(registry.workspace_exists("test"));
420    }
421
422    #[test]
423    fn test_max_workspaces_limit() {
424        let config = MultiTenantConfig {
425            max_workspaces: Some(2),
426            ..Default::default()
427        };
428
429        let mut registry = MultiTenantWorkspaceRegistry::new(config);
430
431        // Register first workspace
432        registry
433            .register_workspace("ws1".to_string(), Workspace::new("WS1".to_string()))
434            .unwrap();
435
436        // Register second workspace
437        registry
438            .register_workspace("ws2".to_string(), Workspace::new("WS2".to_string()))
439            .unwrap();
440
441        // Third should fail
442        let result =
443            registry.register_workspace("ws3".to_string(), Workspace::new("WS3".to_string()));
444        assert!(result.is_err());
445    }
446
447    #[test]
448    fn test_extract_workspace_id_from_path() {
449        let config = MultiTenantConfig {
450            enabled: true,
451            ..Default::default()
452        };
453
454        let registry = MultiTenantWorkspaceRegistry::new(config);
455
456        // Test valid path
457        let workspace_id =
458            registry.extract_workspace_id_from_path("/workspace/project-a/api/users");
459        assert_eq!(workspace_id, Some("project-a".to_string()));
460
461        // Test path without workspace
462        let workspace_id = registry.extract_workspace_id_from_path("/api/users");
463        assert_eq!(workspace_id, None);
464
465        // Test root workspace path
466        let workspace_id = registry.extract_workspace_id_from_path("/workspace/test");
467        assert_eq!(workspace_id, Some("test".to_string()));
468    }
469
470    #[test]
471    fn test_strip_workspace_prefix() {
472        let config = MultiTenantConfig {
473            enabled: true,
474            ..Default::default()
475        };
476
477        let registry = MultiTenantWorkspaceRegistry::new(config);
478
479        // Test stripping prefix
480        let stripped =
481            registry.strip_workspace_prefix("/workspace/project-a/api/users", "project-a");
482        assert_eq!(stripped, "/api/users");
483
484        // Test path without prefix
485        let stripped = registry.strip_workspace_prefix("/api/users", "project-a");
486        assert_eq!(stripped, "/api/users");
487
488        // Test root path
489        let stripped = registry.strip_workspace_prefix("/workspace/project-a", "project-a");
490        assert_eq!(stripped, "/");
491    }
492
493    #[test]
494    fn test_workspace_stats_update() {
495        let config = MultiTenantConfig::default();
496        let mut registry = MultiTenantWorkspaceRegistry::new(config);
497
498        let workspace = Workspace::new("Test Workspace".to_string());
499        registry.register_workspace("test".to_string(), workspace).unwrap();
500
501        // Update stats with response time
502        registry.update_workspace_stats("test", 100.0).unwrap();
503
504        let tenant_ws = registry.get_workspace("test").unwrap();
505        assert_eq!(tenant_ws.stats.total_requests, 1);
506        assert_eq!(tenant_ws.stats.avg_response_time_ms, 100.0);
507
508        // Update again with different response time
509        registry.update_workspace_stats("test", 200.0).unwrap();
510
511        let tenant_ws = registry.get_workspace("test").unwrap();
512        assert_eq!(tenant_ws.stats.total_requests, 2);
513        assert_eq!(tenant_ws.stats.avg_response_time_ms, 150.0);
514    }
515
516    #[test]
517    fn test_cannot_remove_default_workspace() {
518        let config = MultiTenantConfig {
519            default_workspace: "default".to_string(),
520            ..Default::default()
521        };
522
523        let mut registry = MultiTenantWorkspaceRegistry::new(config);
524
525        registry
526            .register_workspace("default".to_string(), Workspace::new("Default".to_string()))
527            .unwrap();
528
529        // Try to remove default workspace
530        let result = registry.remove_workspace("default");
531        assert!(result.is_err());
532    }
533}