1use serde::{Deserialize, Serialize};
2
3use crate::DatabaseEngine;
4
5#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct ProjectConfig {
35 pub project: String,
37
38 #[serde(default = "default_port")]
40 pub port: u16,
41
42 #[serde(default = "default_workers")]
44 pub workers: WorkerCount,
45
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub database: Option<DatabaseConfig>,
49
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub databases: Option<indexmap::IndexMap<String, NamedDatabaseConfig>>,
53
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub cache: Option<CacheConfig>,
57
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub auth: Option<AuthConfig>,
61
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub storage: Option<StorageConfig>,
65
66 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub logging: Option<LoggingConfig>,
69
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub events: Option<EventsConfig>,
73
74 #[serde(default = "default_protocols")]
76 pub protocols: Vec<String>,
77
78 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub graphql: Option<GraphQLConfig>,
81
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub grpc: Option<GrpcConfig>,
85}
86
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95#[serde(deny_unknown_fields)]
96pub struct GraphQLConfig {
97 #[serde(default = "default_depth_limit")]
99 pub depth_limit: usize,
100
101 #[serde(default = "default_complexity_limit")]
103 pub complexity_limit: usize,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(deny_unknown_fields)]
115pub struct GrpcConfig {
116 #[serde(default = "default_grpc_port")]
118 pub port: u16,
119
120 #[serde(default = "default_grpc_reflection")]
122 pub reflection: bool,
123}
124
125fn default_grpc_port() -> u16 {
126 50051
127}
128
129fn default_grpc_reflection() -> bool {
130 true
131}
132
133fn default_depth_limit() -> usize {
134 16
135}
136
137fn default_complexity_limit() -> usize {
138 256
139}
140
141fn default_port() -> u16 {
142 3000
143}
144
145fn default_protocols() -> Vec<String> {
146 vec!["rest".to_string()]
147}
148
149fn default_workers() -> WorkerCount {
150 WorkerCount::Auto
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
155pub enum WorkerCount {
156 Auto,
158 Fixed(usize),
160}
161
162impl Serialize for WorkerCount {
163 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
164 match self {
165 Self::Auto => serializer.serialize_str("auto"),
166 Self::Fixed(n) => serializer.serialize_u64(*n as u64),
167 }
168 }
169}
170
171impl<'de> Deserialize<'de> for WorkerCount {
172 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
173 let value = serde_json::Value::deserialize(deserializer)?;
174 match &value {
175 serde_json::Value::String(s) if s == "auto" => Ok(Self::Auto),
176 serde_json::Value::Number(n) => n
177 .as_u64()
178 .map(|v| Self::Fixed(v as usize))
179 .ok_or_else(|| serde::de::Error::custom("workers must be a positive integer")),
180 _ => Err(serde::de::Error::custom(
181 "workers must be \"auto\" or a positive integer",
182 )),
183 }
184 }
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(deny_unknown_fields)]
190pub struct DatabaseConfig {
191 #[serde(rename = "type")]
193 pub db_type: String,
194
195 #[serde(default = "default_host")]
197 pub host: String,
198
199 #[serde(default = "default_db_port")]
201 pub port: u16,
202
203 pub name: String,
205
206 #[serde(default = "default_pool_size")]
208 pub pool_size: u32,
209}
210
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(deny_unknown_fields)]
214pub struct NamedDatabaseConfig {
215 pub engine: DatabaseEngine,
217
218 pub url: String,
220
221 #[serde(default = "default_pool_size")]
223 pub pool_size: u32,
224}
225
226fn default_host() -> String {
227 "localhost".to_string()
228}
229
230fn default_db_port() -> u16 {
231 5432
232}
233
234fn default_pool_size() -> u32 {
235 20
236}
237
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(deny_unknown_fields)]
241pub struct CacheConfig {
242 #[serde(rename = "type")]
244 pub cache_type: String,
245
246 pub url: String,
248}
249
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(deny_unknown_fields)]
253pub struct AuthConfig {
254 pub provider: String,
256
257 pub secret_env: String,
259
260 pub expiry: String,
262
263 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub refresh_expiry: Option<String>,
266}
267
268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270#[serde(deny_unknown_fields)]
271pub struct StorageConfig {
272 pub provider: String,
274
275 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub bucket: Option<String>,
278
279 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub region: Option<String>,
282}
283
284#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
286#[serde(deny_unknown_fields)]
287pub struct LoggingConfig {
288 #[serde(default = "default_log_level")]
290 pub level: String,
291
292 #[serde(default = "default_log_format")]
294 pub format: String,
295
296 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub otlp_endpoint: Option<String>,
299}
300
301fn default_log_level() -> String {
302 "info".to_string()
303}
304
305fn default_log_format() -> String {
306 "json".to_string()
307}
308
309#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
333#[serde(deny_unknown_fields)]
334pub struct EventsConfig {
335 #[serde(default, skip_serializing_if = "Vec::is_empty")]
337 pub subscribers: Vec<EventSubscriber>,
338
339 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub webhooks: Option<WebhookConfig>,
342
343 #[serde(default, skip_serializing_if = "Vec::is_empty")]
345 pub inbound: Vec<InboundWebhookConfig>,
346}
347
348#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
350#[serde(deny_unknown_fields)]
351pub struct EventSubscriber {
352 pub event: String,
354
355 pub targets: Vec<EventTarget>,
357}
358
359#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
361#[serde(tag = "type", rename_all = "lowercase")]
362pub enum EventTarget {
363 Job { name: String },
365 Webhook { url: String },
367 Channel {
369 name: String,
370 #[serde(default, skip_serializing_if = "Option::is_none")]
371 room: Option<String>,
372 },
373 Hook { name: String },
375}
376
377#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
379#[serde(deny_unknown_fields)]
380pub struct WebhookConfig {
381 #[serde(default = "default_webhook_secret_env")]
383 pub secret_env: String,
384
385 #[serde(default = "default_webhook_timeout")]
387 pub timeout_secs: u64,
388
389 #[serde(default = "default_webhook_max_retries")]
391 pub max_retries: u32,
392}
393
394fn default_webhook_secret_env() -> String {
395 "WEBHOOK_SECRET".to_string()
396}
397
398fn default_webhook_timeout() -> u64 {
399 30
400}
401
402fn default_webhook_max_retries() -> u32 {
403 3
404}
405
406#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
408#[serde(deny_unknown_fields)]
409pub struct InboundWebhookConfig {
410 pub path: String,
412
413 pub secret_env: String,
415
416 #[serde(default, skip_serializing_if = "Vec::is_empty")]
418 pub events: Vec<String>,
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424
425 #[test]
426 fn project_config_minimal() {
427 let json = r#"{"project": "my-app"}"#;
428 let cfg: ProjectConfig = serde_json::from_str(json).unwrap();
429 assert_eq!(cfg.project, "my-app");
430 assert_eq!(cfg.port, 3000);
431 assert_eq!(cfg.workers, WorkerCount::Auto);
432 assert!(cfg.database.is_none());
433 assert_eq!(cfg.protocols, vec!["rest"]);
434 }
435
436 #[test]
437 fn project_config_full() {
438 let json = r#"{
439 "project": "my-api",
440 "port": 8080,
441 "workers": 4,
442 "database": {
443 "type": "postgresql",
444 "host": "db.example.com",
445 "port": 5433,
446 "name": "my_db",
447 "pool_size": 10
448 },
449 "cache": {
450 "type": "redis",
451 "url": "redis://localhost:6379"
452 },
453 "auth": {
454 "provider": "jwt",
455 "secret_env": "JWT_SECRET",
456 "expiry": "24h",
457 "refresh_expiry": "30d"
458 },
459 "storage": {
460 "provider": "s3",
461 "bucket": "my-bucket",
462 "region": "us-east-1"
463 },
464 "logging": {
465 "level": "debug",
466 "format": "json",
467 "otlp_endpoint": "http://localhost:4317"
468 }
469 }"#;
470 let cfg: ProjectConfig = serde_json::from_str(json).unwrap();
471 assert_eq!(cfg.port, 8080);
472 assert_eq!(cfg.workers, WorkerCount::Fixed(4));
473 let db = cfg.database.unwrap();
474 assert_eq!(db.db_type, "postgresql");
475 assert_eq!(db.host, "db.example.com");
476 assert_eq!(db.port, 5433);
477 assert_eq!(db.pool_size, 10);
478 let cache = cfg.cache.unwrap();
479 assert_eq!(cache.cache_type, "redis");
480 let auth = cfg.auth.unwrap();
481 assert_eq!(auth.provider, "jwt");
482 assert_eq!(auth.refresh_expiry.as_deref(), Some("30d"));
483 let storage = cfg.storage.unwrap();
484 assert_eq!(storage.provider, "s3");
485 let logging = cfg.logging.unwrap();
486 assert_eq!(logging.level, "debug");
487 assert_eq!(
488 logging.otlp_endpoint.as_deref(),
489 Some("http://localhost:4317")
490 );
491 }
492
493 #[test]
494 fn worker_count_auto() {
495 let wc: WorkerCount = serde_json::from_str("\"auto\"").unwrap();
496 assert_eq!(wc, WorkerCount::Auto);
497 }
498
499 #[test]
500 fn worker_count_fixed() {
501 let wc: WorkerCount = serde_json::from_str("8").unwrap();
502 assert_eq!(wc, WorkerCount::Fixed(8));
503 }
504
505 #[test]
506 fn database_config_defaults() {
507 let json = r#"{"type": "postgresql", "name": "test_db"}"#;
508 let db: DatabaseConfig = serde_json::from_str(json).unwrap();
509 assert_eq!(db.host, "localhost");
510 assert_eq!(db.port, 5432);
511 assert_eq!(db.pool_size, 20);
512 }
513
514 #[test]
515 fn logging_config_defaults() {
516 let json = r#"{}"#;
517 let log: LoggingConfig = serde_json::from_str(json).unwrap();
518 assert_eq!(log.level, "info");
519 assert_eq!(log.format, "json");
520 assert!(log.otlp_endpoint.is_none());
521 }
522
523 #[test]
524 fn project_config_serde_roundtrip() {
525 let cfg = ProjectConfig {
526 project: "roundtrip-test".to_string(),
527 port: 3000,
528 workers: WorkerCount::Auto,
529 database: Some(DatabaseConfig {
530 db_type: "postgresql".to_string(),
531 host: "localhost".to_string(),
532 port: 5432,
533 name: "test".to_string(),
534 pool_size: 20,
535 }),
536 databases: None,
537 cache: None,
538 auth: None,
539 storage: None,
540 logging: None,
541 events: None,
542 protocols: vec!["rest".to_string()],
543 graphql: None,
544 grpc: None,
545 };
546 let json = serde_json::to_string(&cfg).unwrap();
547 let back: ProjectConfig = serde_json::from_str(&json).unwrap();
548 assert_eq!(cfg, back);
549 }
550
551 #[test]
552 fn events_config_serde() {
553 let json = r#"{
554 "subscribers": [
555 {
556 "event": "users.created",
557 "targets": [
558 {"type": "job", "name": "send_welcome_email"},
559 {"type": "webhook", "url": "https://example.com/hook"},
560 {"type": "channel", "name": "notifications", "room": "org:123"},
561 {"type": "hook", "name": "validate_org"}
562 ]
563 },
564 {
565 "event": "*.deleted",
566 "targets": [
567 {"type": "job", "name": "cleanup_job"}
568 ]
569 }
570 ],
571 "webhooks": {
572 "secret_env": "MY_SECRET",
573 "timeout_secs": 15,
574 "max_retries": 5
575 },
576 "inbound": [
577 {
578 "path": "/webhooks/stripe",
579 "secret_env": "STRIPE_SECRET",
580 "events": ["payment.completed"]
581 }
582 ]
583 }"#;
584 let cfg: EventsConfig = serde_json::from_str(json).unwrap();
585 assert_eq!(cfg.subscribers.len(), 2);
586 assert_eq!(cfg.subscribers[0].event, "users.created");
587 assert_eq!(cfg.subscribers[0].targets.len(), 4);
588 assert!(
589 matches!(&cfg.subscribers[0].targets[0], EventTarget::Job { name } if name == "send_welcome_email")
590 );
591 assert!(
592 matches!(&cfg.subscribers[0].targets[1], EventTarget::Webhook { url } if url == "https://example.com/hook")
593 );
594 assert!(
595 matches!(&cfg.subscribers[0].targets[2], EventTarget::Channel { name, room } if name == "notifications" && room.as_deref() == Some("org:123"))
596 );
597 assert!(
598 matches!(&cfg.subscribers[0].targets[3], EventTarget::Hook { name } if name == "validate_org")
599 );
600 let webhooks = cfg.webhooks.unwrap();
601 assert_eq!(webhooks.secret_env, "MY_SECRET");
602 assert_eq!(webhooks.timeout_secs, 15);
603 assert_eq!(webhooks.max_retries, 5);
604 assert_eq!(cfg.inbound.len(), 1);
605 assert_eq!(cfg.inbound[0].path, "/webhooks/stripe");
606 }
607
608 #[test]
609 fn webhook_config_defaults() {
610 let json = r#"{}"#;
611 let cfg: WebhookConfig = serde_json::from_str(json).unwrap();
612 assert_eq!(cfg.secret_env, "WEBHOOK_SECRET");
613 assert_eq!(cfg.timeout_secs, 30);
614 assert_eq!(cfg.max_retries, 3);
615 }
616
617 #[test]
618 fn events_config_empty() {
619 let json = r#"{"subscribers": [], "inbound": []}"#;
620 let cfg: EventsConfig = serde_json::from_str(json).unwrap();
621 assert!(cfg.subscribers.is_empty());
622 assert!(cfg.webhooks.is_none());
623 assert!(cfg.inbound.is_empty());
624 }
625
626 #[test]
627 fn named_database_config() {
628 let json =
629 r#"{"engine": "postgres", "url": "postgresql://localhost/mydb", "pool_size": 10}"#;
630 let cfg: NamedDatabaseConfig = serde_json::from_str(json).unwrap();
631 assert_eq!(cfg.engine, DatabaseEngine::Postgres);
632 assert_eq!(cfg.url, "postgresql://localhost/mydb");
633 assert_eq!(cfg.pool_size, 10);
634 }
635
636 #[test]
637 fn project_config_databases() {
638 let json = r#"{
639 "project": "multi-db",
640 "databases": {
641 "default": {"engine": "postgres", "url": "postgresql:///main"},
642 "analytics": {"engine": "mysql", "url": "mysql://localhost/analytics"}
643 }
644 }"#;
645 let cfg: ProjectConfig = serde_json::from_str(json).unwrap();
646 let dbs = cfg.databases.as_ref().unwrap();
647 assert_eq!(dbs.len(), 2);
648 assert_eq!(dbs.get("default").unwrap().engine, DatabaseEngine::Postgres);
649 assert_eq!(dbs.get("analytics").unwrap().engine, DatabaseEngine::MySQL);
650 }
651
652 #[test]
653 fn project_config_protocols() {
654 let json = r#"{"project": "gql-api", "protocols": ["rest", "graphql"]}"#;
655 let cfg: ProjectConfig = serde_json::from_str(json).unwrap();
656 assert_eq!(cfg.protocols, vec!["rest", "graphql"]);
657 }
658
659 #[test]
660 fn project_config_grpc_protocol() {
661 let json = r#"{"project": "grpc-api", "protocols": ["rest", "grpc"]}"#;
662 let cfg: ProjectConfig = serde_json::from_str(json).unwrap();
663 assert_eq!(cfg.protocols, vec!["rest", "grpc"]);
664 assert!(cfg.grpc.is_none());
665 }
666
667 #[test]
668 fn grpc_config_defaults() {
669 let json = r#"{}"#;
670 let cfg: GrpcConfig = serde_json::from_str(json).unwrap();
671 assert_eq!(cfg.port, 50051);
672 assert!(cfg.reflection);
673 }
674
675 #[test]
676 fn grpc_config_custom() {
677 let json = r#"{"port": 9090, "reflection": false}"#;
678 let cfg: GrpcConfig = serde_json::from_str(json).unwrap();
679 assert_eq!(cfg.port, 9090);
680 assert!(!cfg.reflection);
681 }
682
683 #[test]
684 fn project_config_with_grpc() {
685 let json = r#"{
686 "project": "grpc-app",
687 "protocols": ["rest", "grpc"],
688 "grpc": {"port": 50052, "reflection": true}
689 }"#;
690 let cfg: ProjectConfig = serde_json::from_str(json).unwrap();
691 assert_eq!(cfg.protocols, vec!["rest", "grpc"]);
692 let grpc = cfg.grpc.unwrap();
693 assert_eq!(grpc.port, 50052);
694 assert!(grpc.reflection);
695 }
696}