Skip to main content

shaperail_core/
config.rs

1use serde::{Deserialize, Serialize};
2
3/// Project-level configuration, parsed from `shaperail.config.yaml`.
4///
5/// ```yaml
6/// project: my-api
7/// port: 3000
8/// workers: auto
9/// database:
10///   type: postgresql
11///   host: localhost
12///   port: 5432
13///   name: my_api_db
14///   pool_size: 20
15/// ```
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17#[serde(deny_unknown_fields)]
18pub struct ProjectConfig {
19    /// Project name.
20    pub project: String,
21
22    /// HTTP server port.
23    #[serde(default = "default_port")]
24    pub port: u16,
25
26    /// Number of worker threads ("auto" or a number).
27    #[serde(default = "default_workers")]
28    pub workers: WorkerCount,
29
30    /// Database configuration.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub database: Option<DatabaseConfig>,
33
34    /// Cache (Redis) configuration.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub cache: Option<CacheConfig>,
37
38    /// Authentication configuration.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub auth: Option<AuthConfig>,
41
42    /// Object storage configuration.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub storage: Option<StorageConfig>,
45
46    /// Logging and observability configuration.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub logging: Option<LoggingConfig>,
49
50    /// Events and webhooks configuration.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub events: Option<EventsConfig>,
53}
54
55fn default_port() -> u16 {
56    3000
57}
58
59fn default_workers() -> WorkerCount {
60    WorkerCount::Auto
61}
62
63/// Worker thread count: either automatic or a fixed number.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum WorkerCount {
66    /// Automatically detect based on CPU cores.
67    Auto,
68    /// Fixed number of workers.
69    Fixed(usize),
70}
71
72impl Serialize for WorkerCount {
73    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
74        match self {
75            Self::Auto => serializer.serialize_str("auto"),
76            Self::Fixed(n) => serializer.serialize_u64(*n as u64),
77        }
78    }
79}
80
81impl<'de> Deserialize<'de> for WorkerCount {
82    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
83        let value = serde_json::Value::deserialize(deserializer)?;
84        match &value {
85            serde_json::Value::String(s) if s == "auto" => Ok(Self::Auto),
86            serde_json::Value::Number(n) => n
87                .as_u64()
88                .map(|v| Self::Fixed(v as usize))
89                .ok_or_else(|| serde::de::Error::custom("workers must be a positive integer")),
90            _ => Err(serde::de::Error::custom(
91                "workers must be \"auto\" or a positive integer",
92            )),
93        }
94    }
95}
96
97/// Database connection configuration.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(deny_unknown_fields)]
100pub struct DatabaseConfig {
101    /// Database type (e.g., "postgresql").
102    #[serde(rename = "type")]
103    pub db_type: String,
104
105    /// Database host.
106    #[serde(default = "default_host")]
107    pub host: String,
108
109    /// Database port.
110    #[serde(default = "default_db_port")]
111    pub port: u16,
112
113    /// Database name.
114    pub name: String,
115
116    /// Connection pool size.
117    #[serde(default = "default_pool_size")]
118    pub pool_size: u32,
119}
120
121fn default_host() -> String {
122    "localhost".to_string()
123}
124
125fn default_db_port() -> u16 {
126    5432
127}
128
129fn default_pool_size() -> u32 {
130    20
131}
132
133/// Redis cache configuration.
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(deny_unknown_fields)]
136pub struct CacheConfig {
137    /// Cache type (e.g., "redis").
138    #[serde(rename = "type")]
139    pub cache_type: String,
140
141    /// Redis connection URL.
142    pub url: String,
143}
144
145/// Authentication configuration.
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(deny_unknown_fields)]
148pub struct AuthConfig {
149    /// Auth provider (e.g., "jwt").
150    pub provider: String,
151
152    /// Environment variable name holding the JWT secret.
153    pub secret_env: String,
154
155    /// Token expiry duration (e.g., "24h").
156    pub expiry: String,
157
158    /// Refresh token expiry duration (e.g., "30d").
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub refresh_expiry: Option<String>,
161}
162
163/// Object storage configuration.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(deny_unknown_fields)]
166pub struct StorageConfig {
167    /// Storage provider (e.g., "s3", "gcs", "local").
168    pub provider: String,
169
170    /// Storage bucket name.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub bucket: Option<String>,
173
174    /// Cloud region.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub region: Option<String>,
177}
178
179/// Logging and observability configuration.
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(deny_unknown_fields)]
182pub struct LoggingConfig {
183    /// Log level (e.g., "info", "debug", "warn").
184    #[serde(default = "default_log_level")]
185    pub level: String,
186
187    /// Log format (e.g., "json", "pretty").
188    #[serde(default = "default_log_format")]
189    pub format: String,
190
191    /// OpenTelemetry OTLP endpoint.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub otlp_endpoint: Option<String>,
194}
195
196fn default_log_level() -> String {
197    "info".to_string()
198}
199
200fn default_log_format() -> String {
201    "json".to_string()
202}
203
204/// Events and webhooks configuration.
205///
206/// ```yaml
207/// events:
208///   subscribers:
209///     - event: "user.created"
210///       targets:
211///         - type: webhook
212///           url: "https://example.com/hooks/user-created"
213///         - type: job
214///           name: send_welcome_email
215///         - type: channel
216///           name: notifications
217///           room: "org:{org_id}"
218///   webhooks:
219///     secret_env: WEBHOOK_SECRET
220///     timeout_secs: 30
221///     max_retries: 3
222///   inbound:
223///     - path: /webhooks/stripe
224///       secret_env: STRIPE_WEBHOOK_SECRET
225///       events: ["payment.completed", "subscription.updated"]
226/// ```
227#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
228#[serde(deny_unknown_fields)]
229pub struct EventsConfig {
230    /// Event subscriber definitions.
231    #[serde(default, skip_serializing_if = "Vec::is_empty")]
232    pub subscribers: Vec<EventSubscriber>,
233
234    /// Outbound webhook global settings.
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub webhooks: Option<WebhookConfig>,
237
238    /// Inbound webhook endpoint definitions.
239    #[serde(default, skip_serializing_if = "Vec::is_empty")]
240    pub inbound: Vec<InboundWebhookConfig>,
241}
242
243/// An event subscriber routes events to targets.
244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
245#[serde(deny_unknown_fields)]
246pub struct EventSubscriber {
247    /// Event name pattern (e.g., "user.created", "*.deleted").
248    pub event: String,
249
250    /// Targets to dispatch the event to.
251    pub targets: Vec<EventTarget>,
252}
253
254/// A target for event dispatch.
255#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
256#[serde(tag = "type", rename_all = "lowercase")]
257pub enum EventTarget {
258    /// Enqueue a background job.
259    Job { name: String },
260    /// POST to an external webhook URL.
261    Webhook { url: String },
262    /// Broadcast to a WebSocket channel/room.
263    Channel {
264        name: String,
265        #[serde(default, skip_serializing_if = "Option::is_none")]
266        room: Option<String>,
267    },
268    /// Execute a hook function.
269    Hook { name: String },
270}
271
272/// Global outbound webhook settings.
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
274#[serde(deny_unknown_fields)]
275pub struct WebhookConfig {
276    /// Environment variable holding the HMAC signing secret.
277    #[serde(default = "default_webhook_secret_env")]
278    pub secret_env: String,
279
280    /// HTTP timeout for webhook delivery in seconds.
281    #[serde(default = "default_webhook_timeout")]
282    pub timeout_secs: u64,
283
284    /// Maximum retry attempts for failed deliveries.
285    #[serde(default = "default_webhook_max_retries")]
286    pub max_retries: u32,
287}
288
289fn default_webhook_secret_env() -> String {
290    "WEBHOOK_SECRET".to_string()
291}
292
293fn default_webhook_timeout() -> u64 {
294    30
295}
296
297fn default_webhook_max_retries() -> u32 {
298    3
299}
300
301/// Inbound webhook endpoint configuration.
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
303#[serde(deny_unknown_fields)]
304pub struct InboundWebhookConfig {
305    /// URL path for the inbound webhook (e.g., "/webhooks/stripe").
306    pub path: String,
307
308    /// Environment variable holding the verification secret.
309    pub secret_env: String,
310
311    /// Event names this endpoint accepts.
312    #[serde(default, skip_serializing_if = "Vec::is_empty")]
313    pub events: Vec<String>,
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn project_config_minimal() {
322        let json = r#"{"project": "my-app"}"#;
323        let cfg: ProjectConfig = serde_json::from_str(json).unwrap();
324        assert_eq!(cfg.project, "my-app");
325        assert_eq!(cfg.port, 3000);
326        assert_eq!(cfg.workers, WorkerCount::Auto);
327        assert!(cfg.database.is_none());
328    }
329
330    #[test]
331    fn project_config_full() {
332        let json = r#"{
333            "project": "my-api",
334            "port": 8080,
335            "workers": 4,
336            "database": {
337                "type": "postgresql",
338                "host": "db.example.com",
339                "port": 5433,
340                "name": "my_db",
341                "pool_size": 10
342            },
343            "cache": {
344                "type": "redis",
345                "url": "redis://localhost:6379"
346            },
347            "auth": {
348                "provider": "jwt",
349                "secret_env": "JWT_SECRET",
350                "expiry": "24h",
351                "refresh_expiry": "30d"
352            },
353            "storage": {
354                "provider": "s3",
355                "bucket": "my-bucket",
356                "region": "us-east-1"
357            },
358            "logging": {
359                "level": "debug",
360                "format": "json",
361                "otlp_endpoint": "http://localhost:4317"
362            }
363        }"#;
364        let cfg: ProjectConfig = serde_json::from_str(json).unwrap();
365        assert_eq!(cfg.port, 8080);
366        assert_eq!(cfg.workers, WorkerCount::Fixed(4));
367        let db = cfg.database.unwrap();
368        assert_eq!(db.db_type, "postgresql");
369        assert_eq!(db.host, "db.example.com");
370        assert_eq!(db.port, 5433);
371        assert_eq!(db.pool_size, 10);
372        let cache = cfg.cache.unwrap();
373        assert_eq!(cache.cache_type, "redis");
374        let auth = cfg.auth.unwrap();
375        assert_eq!(auth.provider, "jwt");
376        assert_eq!(auth.refresh_expiry.as_deref(), Some("30d"));
377        let storage = cfg.storage.unwrap();
378        assert_eq!(storage.provider, "s3");
379        let logging = cfg.logging.unwrap();
380        assert_eq!(logging.level, "debug");
381        assert_eq!(
382            logging.otlp_endpoint.as_deref(),
383            Some("http://localhost:4317")
384        );
385    }
386
387    #[test]
388    fn worker_count_auto() {
389        let wc: WorkerCount = serde_json::from_str("\"auto\"").unwrap();
390        assert_eq!(wc, WorkerCount::Auto);
391    }
392
393    #[test]
394    fn worker_count_fixed() {
395        let wc: WorkerCount = serde_json::from_str("8").unwrap();
396        assert_eq!(wc, WorkerCount::Fixed(8));
397    }
398
399    #[test]
400    fn database_config_defaults() {
401        let json = r#"{"type": "postgresql", "name": "test_db"}"#;
402        let db: DatabaseConfig = serde_json::from_str(json).unwrap();
403        assert_eq!(db.host, "localhost");
404        assert_eq!(db.port, 5432);
405        assert_eq!(db.pool_size, 20);
406    }
407
408    #[test]
409    fn logging_config_defaults() {
410        let json = r#"{}"#;
411        let log: LoggingConfig = serde_json::from_str(json).unwrap();
412        assert_eq!(log.level, "info");
413        assert_eq!(log.format, "json");
414        assert!(log.otlp_endpoint.is_none());
415    }
416
417    #[test]
418    fn project_config_serde_roundtrip() {
419        let cfg = ProjectConfig {
420            project: "roundtrip-test".to_string(),
421            port: 3000,
422            workers: WorkerCount::Auto,
423            database: Some(DatabaseConfig {
424                db_type: "postgresql".to_string(),
425                host: "localhost".to_string(),
426                port: 5432,
427                name: "test".to_string(),
428                pool_size: 20,
429            }),
430            cache: None,
431            auth: None,
432            storage: None,
433            logging: None,
434            events: None,
435        };
436        let json = serde_json::to_string(&cfg).unwrap();
437        let back: ProjectConfig = serde_json::from_str(&json).unwrap();
438        assert_eq!(cfg, back);
439    }
440
441    #[test]
442    fn events_config_serde() {
443        let json = r#"{
444            "subscribers": [
445                {
446                    "event": "users.created",
447                    "targets": [
448                        {"type": "job", "name": "send_welcome_email"},
449                        {"type": "webhook", "url": "https://example.com/hook"},
450                        {"type": "channel", "name": "notifications", "room": "org:123"},
451                        {"type": "hook", "name": "validate_org"}
452                    ]
453                },
454                {
455                    "event": "*.deleted",
456                    "targets": [
457                        {"type": "job", "name": "cleanup_job"}
458                    ]
459                }
460            ],
461            "webhooks": {
462                "secret_env": "MY_SECRET",
463                "timeout_secs": 15,
464                "max_retries": 5
465            },
466            "inbound": [
467                {
468                    "path": "/webhooks/stripe",
469                    "secret_env": "STRIPE_SECRET",
470                    "events": ["payment.completed"]
471                }
472            ]
473        }"#;
474        let cfg: EventsConfig = serde_json::from_str(json).unwrap();
475        assert_eq!(cfg.subscribers.len(), 2);
476        assert_eq!(cfg.subscribers[0].event, "users.created");
477        assert_eq!(cfg.subscribers[0].targets.len(), 4);
478        assert!(
479            matches!(&cfg.subscribers[0].targets[0], EventTarget::Job { name } if name == "send_welcome_email")
480        );
481        assert!(
482            matches!(&cfg.subscribers[0].targets[1], EventTarget::Webhook { url } if url == "https://example.com/hook")
483        );
484        assert!(
485            matches!(&cfg.subscribers[0].targets[2], EventTarget::Channel { name, room } if name == "notifications" && room.as_deref() == Some("org:123"))
486        );
487        assert!(
488            matches!(&cfg.subscribers[0].targets[3], EventTarget::Hook { name } if name == "validate_org")
489        );
490        let webhooks = cfg.webhooks.unwrap();
491        assert_eq!(webhooks.secret_env, "MY_SECRET");
492        assert_eq!(webhooks.timeout_secs, 15);
493        assert_eq!(webhooks.max_retries, 5);
494        assert_eq!(cfg.inbound.len(), 1);
495        assert_eq!(cfg.inbound[0].path, "/webhooks/stripe");
496    }
497
498    #[test]
499    fn webhook_config_defaults() {
500        let json = r#"{}"#;
501        let cfg: WebhookConfig = serde_json::from_str(json).unwrap();
502        assert_eq!(cfg.secret_env, "WEBHOOK_SECRET");
503        assert_eq!(cfg.timeout_secs, 30);
504        assert_eq!(cfg.max_retries, 3);
505    }
506
507    #[test]
508    fn events_config_empty() {
509        let json = r#"{"subscribers": [], "inbound": []}"#;
510        let cfg: EventsConfig = serde_json::from_str(json).unwrap();
511        assert!(cfg.subscribers.is_empty());
512        assert!(cfg.webhooks.is_none());
513        assert!(cfg.inbound.is_empty());
514    }
515}