1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17#[serde(deny_unknown_fields)]
18pub struct ProjectConfig {
19 pub project: String,
21
22 #[serde(default = "default_port")]
24 pub port: u16,
25
26 #[serde(default = "default_workers")]
28 pub workers: WorkerCount,
29
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub database: Option<DatabaseConfig>,
33
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub cache: Option<CacheConfig>,
37
38 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub auth: Option<AuthConfig>,
41
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub storage: Option<StorageConfig>,
45
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub logging: Option<LoggingConfig>,
49
50 #[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#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum WorkerCount {
66 Auto,
68 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(deny_unknown_fields)]
100pub struct DatabaseConfig {
101 #[serde(rename = "type")]
103 pub db_type: String,
104
105 #[serde(default = "default_host")]
107 pub host: String,
108
109 #[serde(default = "default_db_port")]
111 pub port: u16,
112
113 pub name: String,
115
116 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(deny_unknown_fields)]
136pub struct CacheConfig {
137 #[serde(rename = "type")]
139 pub cache_type: String,
140
141 pub url: String,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(deny_unknown_fields)]
148pub struct AuthConfig {
149 pub provider: String,
151
152 pub secret_env: String,
154
155 pub expiry: String,
157
158 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub refresh_expiry: Option<String>,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(deny_unknown_fields)]
166pub struct StorageConfig {
167 pub provider: String,
169
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub bucket: Option<String>,
173
174 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub region: Option<String>,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(deny_unknown_fields)]
182pub struct LoggingConfig {
183 #[serde(default = "default_log_level")]
185 pub level: String,
186
187 #[serde(default = "default_log_format")]
189 pub format: String,
190
191 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
228#[serde(deny_unknown_fields)]
229pub struct EventsConfig {
230 #[serde(default, skip_serializing_if = "Vec::is_empty")]
232 pub subscribers: Vec<EventSubscriber>,
233
234 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub webhooks: Option<WebhookConfig>,
237
238 #[serde(default, skip_serializing_if = "Vec::is_empty")]
240 pub inbound: Vec<InboundWebhookConfig>,
241}
242
243#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
245#[serde(deny_unknown_fields)]
246pub struct EventSubscriber {
247 pub event: String,
249
250 pub targets: Vec<EventTarget>,
252}
253
254#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
256#[serde(tag = "type", rename_all = "lowercase")]
257pub enum EventTarget {
258 Job { name: String },
260 Webhook { url: String },
262 Channel {
264 name: String,
265 #[serde(default, skip_serializing_if = "Option::is_none")]
266 room: Option<String>,
267 },
268 Hook { name: String },
270}
271
272#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
274#[serde(deny_unknown_fields)]
275pub struct WebhookConfig {
276 #[serde(default = "default_webhook_secret_env")]
278 pub secret_env: String,
279
280 #[serde(default = "default_webhook_timeout")]
282 pub timeout_secs: u64,
283
284 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
303#[serde(deny_unknown_fields)]
304pub struct InboundWebhookConfig {
305 pub path: String,
307
308 pub secret_env: String,
310
311 #[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}