Skip to main content

shaperail_core/
workspace.rs

1use indexmap::IndexMap;
2use serde::{Deserialize, Serialize};
3
4use crate::{AuthConfig, CacheConfig};
5
6/// Workspace configuration parsed from `shaperail.workspace.yaml`.
7///
8/// Declares multiple services that form a distributed system.
9///
10/// ```yaml
11/// workspace: my-platform
12/// services:
13///   users-api:
14///     path: services/users-api
15///     port: 3001
16///   orders-api:
17///     path: services/orders-api
18///     port: 3002
19///     depends_on: [users-api]
20/// shared:
21///   cache:
22///     type: redis
23///     url: redis://localhost:6379
24///   auth:
25///     provider: jwt
26///     secret_env: JWT_SECRET
27///     expiry: 24h
28/// ```
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct WorkspaceConfig {
32    /// Workspace name.
33    pub workspace: String,
34
35    /// Named services in the workspace.
36    pub services: IndexMap<String, ServiceDefinition>,
37
38    /// Shared configuration inherited by all services.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub shared: Option<SharedConfig>,
41}
42
43/// A single service within a workspace.
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct ServiceDefinition {
47    /// Relative path to the service directory (from workspace root).
48    pub path: String,
49
50    /// HTTP port for this service.
51    #[serde(default = "default_service_port")]
52    pub port: u16,
53
54    /// Services this service depends on (must start first).
55    #[serde(default, skip_serializing_if = "Vec::is_empty")]
56    pub depends_on: Vec<String>,
57}
58
59fn default_service_port() -> u16 {
60    3000
61}
62
63/// Shared configuration inherited by all services in a workspace.
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct SharedConfig {
67    /// Shared Redis cache configuration.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub cache: Option<CacheConfig>,
70
71    /// Shared authentication configuration.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub auth: Option<AuthConfig>,
74}
75
76/// Entry stored in Redis service registry. Services register on startup and
77/// update their heartbeat periodically.
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct ServiceRegistryEntry {
80    /// Service name (matches key in workspace config).
81    pub name: String,
82
83    /// Base URL for this service (e.g. "http://localhost:3001").
84    pub url: String,
85
86    /// HTTP port.
87    pub port: u16,
88
89    /// Resource names this service exposes.
90    pub resources: Vec<String>,
91
92    /// Enabled protocols (rest, graphql, grpc).
93    pub protocols: Vec<String>,
94
95    /// Current service status.
96    pub status: ServiceStatus,
97
98    /// ISO 8601 timestamp of initial registration.
99    pub registered_at: String,
100
101    /// ISO 8601 timestamp of last heartbeat.
102    pub last_heartbeat: String,
103}
104
105/// Service health status in the registry.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "lowercase")]
108pub enum ServiceStatus {
109    /// Service is starting up.
110    Starting,
111    /// Service is healthy and accepting requests.
112    Healthy,
113    /// Service has missed heartbeats.
114    Unhealthy,
115    /// Service has been stopped.
116    Stopped,
117}
118
119impl std::fmt::Display for ServiceStatus {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        match self {
122            Self::Starting => write!(f, "starting"),
123            Self::Healthy => write!(f, "healthy"),
124            Self::Unhealthy => write!(f, "unhealthy"),
125            Self::Stopped => write!(f, "stopped"),
126        }
127    }
128}
129
130/// Configuration for an auto-generated inter-service client.
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132#[serde(deny_unknown_fields)]
133pub struct InterServiceClientConfig {
134    /// Target service name.
135    pub service: String,
136
137    /// Base URL (resolved from registry at runtime).
138    pub base_url: String,
139
140    /// Request timeout in seconds.
141    #[serde(default = "default_client_timeout")]
142    pub timeout_secs: u64,
143
144    /// Number of retries on transient failures.
145    #[serde(default = "default_client_retries")]
146    pub retry_count: u32,
147}
148
149fn default_client_timeout() -> u64 {
150    10
151}
152
153fn default_client_retries() -> u32 {
154    3
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn workspace_config_minimal() {
163        let json = r#"{
164            "workspace": "my-platform",
165            "services": {
166                "api": {"path": "services/api"}
167            }
168        }"#;
169        let cfg: WorkspaceConfig = serde_json::from_str(json).unwrap();
170        assert_eq!(cfg.workspace, "my-platform");
171        assert_eq!(cfg.services.len(), 1);
172        let api = cfg.services.get("api").unwrap();
173        assert_eq!(api.path, "services/api");
174        assert_eq!(api.port, 3000);
175        assert!(api.depends_on.is_empty());
176        assert!(cfg.shared.is_none());
177    }
178
179    #[test]
180    fn workspace_config_full() {
181        let json = r#"{
182            "workspace": "my-platform",
183            "services": {
184                "users-api": {
185                    "path": "services/users-api",
186                    "port": 3001
187                },
188                "orders-api": {
189                    "path": "services/orders-api",
190                    "port": 3002,
191                    "depends_on": ["users-api"]
192                }
193            },
194            "shared": {
195                "cache": {
196                    "type": "redis",
197                    "url": "redis://localhost:6379"
198                },
199                "auth": {
200                    "provider": "jwt",
201                    "secret_env": "JWT_SECRET",
202                    "expiry": "24h"
203                }
204            }
205        }"#;
206        let cfg: WorkspaceConfig = serde_json::from_str(json).unwrap();
207        assert_eq!(cfg.workspace, "my-platform");
208        assert_eq!(cfg.services.len(), 2);
209
210        let orders = cfg.services.get("orders-api").unwrap();
211        assert_eq!(orders.port, 3002);
212        assert_eq!(orders.depends_on, vec!["users-api"]);
213
214        let shared = cfg.shared.unwrap();
215        assert!(shared.cache.is_some());
216        assert!(shared.auth.is_some());
217    }
218
219    #[test]
220    fn workspace_config_unknown_field_fails() {
221        let json = r#"{
222            "workspace": "test",
223            "services": {},
224            "unknown_field": true
225        }"#;
226        let err = serde_json::from_str::<WorkspaceConfig>(json);
227        assert!(err.is_err());
228    }
229
230    #[test]
231    fn service_registry_entry_serde_roundtrip() {
232        let entry = ServiceRegistryEntry {
233            name: "users-api".to_string(),
234            url: "http://localhost:3001".to_string(),
235            port: 3001,
236            resources: vec!["users".to_string(), "profiles".to_string()],
237            protocols: vec!["rest".to_string()],
238            status: ServiceStatus::Healthy,
239            registered_at: "2026-01-01T00:00:00Z".to_string(),
240            last_heartbeat: "2026-01-01T00:01:00Z".to_string(),
241        };
242        let json = serde_json::to_string(&entry).unwrap();
243        let back: ServiceRegistryEntry = serde_json::from_str(&json).unwrap();
244        assert_eq!(entry, back);
245    }
246
247    #[test]
248    fn service_status_display() {
249        assert_eq!(ServiceStatus::Starting.to_string(), "starting");
250        assert_eq!(ServiceStatus::Healthy.to_string(), "healthy");
251        assert_eq!(ServiceStatus::Unhealthy.to_string(), "unhealthy");
252        assert_eq!(ServiceStatus::Stopped.to_string(), "stopped");
253    }
254
255    #[test]
256    fn inter_service_client_config_defaults() {
257        let json = r#"{"service": "users-api", "base_url": "http://localhost:3001"}"#;
258        let cfg: InterServiceClientConfig = serde_json::from_str(json).unwrap();
259        assert_eq!(cfg.service, "users-api");
260        assert_eq!(cfg.timeout_secs, 10);
261        assert_eq!(cfg.retry_count, 3);
262    }
263
264    #[test]
265    fn service_definition_defaults() {
266        let json = r#"{"path": "services/api"}"#;
267        let svc: ServiceDefinition = serde_json::from_str(json).unwrap();
268        assert_eq!(svc.port, 3000);
269        assert!(svc.depends_on.is_empty());
270    }
271
272    #[test]
273    fn workspace_config_serde_roundtrip() {
274        let cfg = WorkspaceConfig {
275            workspace: "test-workspace".to_string(),
276            services: {
277                let mut m = IndexMap::new();
278                m.insert(
279                    "svc-a".to_string(),
280                    ServiceDefinition {
281                        path: "services/svc-a".to_string(),
282                        port: 3001,
283                        depends_on: vec![],
284                    },
285                );
286                m.insert(
287                    "svc-b".to_string(),
288                    ServiceDefinition {
289                        path: "services/svc-b".to_string(),
290                        port: 3002,
291                        depends_on: vec!["svc-a".to_string()],
292                    },
293                );
294                m
295            },
296            shared: None,
297        };
298        let json = serde_json::to_string(&cfg).unwrap();
299        let back: WorkspaceConfig = serde_json::from_str(&json).unwrap();
300        assert_eq!(cfg, back);
301    }
302}