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 #[serde(default = "default_signature_header")]
423 pub signature_header: String,
424}
425
426fn default_signature_header() -> String {
427 "X-Webhook-Signature".to_string()
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433
434 #[test]
435 fn project_config_minimal() {
436 let json = r#"{"project": "my-app"}"#;
437 let cfg: ProjectConfig = serde_json::from_str(json).unwrap();
438 assert_eq!(cfg.project, "my-app");
439 assert_eq!(cfg.port, 3000);
440 assert_eq!(cfg.workers, WorkerCount::Auto);
441 assert!(cfg.database.is_none());
442 assert_eq!(cfg.protocols, vec!["rest"]);
443 }
444
445 #[test]
446 fn project_config_full() {
447 let json = r#"{
448 "project": "my-api",
449 "port": 8080,
450 "workers": 4,
451 "database": {
452 "type": "postgresql",
453 "host": "db.example.com",
454 "port": 5433,
455 "name": "my_db",
456 "pool_size": 10
457 },
458 "cache": {
459 "type": "redis",
460 "url": "redis://localhost:6379"
461 },
462 "auth": {
463 "provider": "jwt",
464 "secret_env": "JWT_SECRET",
465 "expiry": "24h",
466 "refresh_expiry": "30d"
467 },
468 "storage": {
469 "provider": "s3",
470 "bucket": "my-bucket",
471 "region": "us-east-1"
472 },
473 "logging": {
474 "level": "debug",
475 "format": "json",
476 "otlp_endpoint": "http://localhost:4317"
477 }
478 }"#;
479 let cfg: ProjectConfig = serde_json::from_str(json).unwrap();
480 assert_eq!(cfg.port, 8080);
481 assert_eq!(cfg.workers, WorkerCount::Fixed(4));
482 let db = cfg.database.unwrap();
483 assert_eq!(db.db_type, "postgresql");
484 assert_eq!(db.host, "db.example.com");
485 assert_eq!(db.port, 5433);
486 assert_eq!(db.pool_size, 10);
487 let cache = cfg.cache.unwrap();
488 assert_eq!(cache.cache_type, "redis");
489 let auth = cfg.auth.unwrap();
490 assert_eq!(auth.provider, "jwt");
491 assert_eq!(auth.refresh_expiry.as_deref(), Some("30d"));
492 let storage = cfg.storage.unwrap();
493 assert_eq!(storage.provider, "s3");
494 let logging = cfg.logging.unwrap();
495 assert_eq!(logging.level, "debug");
496 assert_eq!(
497 logging.otlp_endpoint.as_deref(),
498 Some("http://localhost:4317")
499 );
500 }
501
502 #[test]
503 fn worker_count_auto() {
504 let wc: WorkerCount = serde_json::from_str("\"auto\"").unwrap();
505 assert_eq!(wc, WorkerCount::Auto);
506 }
507
508 #[test]
509 fn worker_count_fixed() {
510 let wc: WorkerCount = serde_json::from_str("8").unwrap();
511 assert_eq!(wc, WorkerCount::Fixed(8));
512 }
513
514 #[test]
515 fn database_config_defaults() {
516 let json = r#"{"type": "postgresql", "name": "test_db"}"#;
517 let db: DatabaseConfig = serde_json::from_str(json).unwrap();
518 assert_eq!(db.host, "localhost");
519 assert_eq!(db.port, 5432);
520 assert_eq!(db.pool_size, 20);
521 }
522
523 #[test]
524 fn logging_config_defaults() {
525 let json = r#"{}"#;
526 let log: LoggingConfig = serde_json::from_str(json).unwrap();
527 assert_eq!(log.level, "info");
528 assert_eq!(log.format, "json");
529 assert!(log.otlp_endpoint.is_none());
530 }
531
532 #[test]
533 fn project_config_serde_roundtrip() {
534 let cfg = ProjectConfig {
535 project: "roundtrip-test".to_string(),
536 port: 3000,
537 workers: WorkerCount::Auto,
538 database: Some(DatabaseConfig {
539 db_type: "postgresql".to_string(),
540 host: "localhost".to_string(),
541 port: 5432,
542 name: "test".to_string(),
543 pool_size: 20,
544 }),
545 databases: None,
546 cache: None,
547 auth: None,
548 storage: None,
549 logging: None,
550 events: None,
551 protocols: vec!["rest".to_string()],
552 graphql: None,
553 grpc: None,
554 };
555 let json = serde_json::to_string(&cfg).unwrap();
556 let back: ProjectConfig = serde_json::from_str(&json).unwrap();
557 assert_eq!(cfg, back);
558 }
559
560 #[test]
561 fn events_config_serde() {
562 let json = r#"{
563 "subscribers": [
564 {
565 "event": "users.created",
566 "targets": [
567 {"type": "job", "name": "send_welcome_email"},
568 {"type": "webhook", "url": "https://example.com/hook"},
569 {"type": "channel", "name": "notifications", "room": "org:123"},
570 {"type": "hook", "name": "validate_org"}
571 ]
572 },
573 {
574 "event": "*.deleted",
575 "targets": [
576 {"type": "job", "name": "cleanup_job"}
577 ]
578 }
579 ],
580 "webhooks": {
581 "secret_env": "MY_SECRET",
582 "timeout_secs": 15,
583 "max_retries": 5
584 },
585 "inbound": [
586 {
587 "path": "/webhooks/stripe",
588 "secret_env": "STRIPE_SECRET",
589 "events": ["payment.completed"]
590 }
591 ]
592 }"#;
593 let cfg: EventsConfig = serde_json::from_str(json).unwrap();
594 assert_eq!(cfg.subscribers.len(), 2);
595 assert_eq!(cfg.subscribers[0].event, "users.created");
596 assert_eq!(cfg.subscribers[0].targets.len(), 4);
597 assert!(
598 matches!(&cfg.subscribers[0].targets[0], EventTarget::Job { name } if name == "send_welcome_email")
599 );
600 assert!(
601 matches!(&cfg.subscribers[0].targets[1], EventTarget::Webhook { url } if url == "https://example.com/hook")
602 );
603 assert!(
604 matches!(&cfg.subscribers[0].targets[2], EventTarget::Channel { name, room } if name == "notifications" && room.as_deref() == Some("org:123"))
605 );
606 assert!(
607 matches!(&cfg.subscribers[0].targets[3], EventTarget::Hook { name } if name == "validate_org")
608 );
609 let webhooks = cfg.webhooks.unwrap();
610 assert_eq!(webhooks.secret_env, "MY_SECRET");
611 assert_eq!(webhooks.timeout_secs, 15);
612 assert_eq!(webhooks.max_retries, 5);
613 assert_eq!(cfg.inbound.len(), 1);
614 assert_eq!(cfg.inbound[0].path, "/webhooks/stripe");
615 }
616
617 #[test]
618 fn webhook_config_defaults() {
619 let json = r#"{}"#;
620 let cfg: WebhookConfig = serde_json::from_str(json).unwrap();
621 assert_eq!(cfg.secret_env, "WEBHOOK_SECRET");
622 assert_eq!(cfg.timeout_secs, 30);
623 assert_eq!(cfg.max_retries, 3);
624 }
625
626 #[test]
627 fn events_config_empty() {
628 let json = r#"{"subscribers": [], "inbound": []}"#;
629 let cfg: EventsConfig = serde_json::from_str(json).unwrap();
630 assert!(cfg.subscribers.is_empty());
631 assert!(cfg.webhooks.is_none());
632 assert!(cfg.inbound.is_empty());
633 }
634
635 #[test]
636 fn named_database_config() {
637 let json =
638 r#"{"engine": "postgres", "url": "postgresql://localhost/mydb", "pool_size": 10}"#;
639 let cfg: NamedDatabaseConfig = serde_json::from_str(json).unwrap();
640 assert_eq!(cfg.engine, DatabaseEngine::Postgres);
641 assert_eq!(cfg.url, "postgresql://localhost/mydb");
642 assert_eq!(cfg.pool_size, 10);
643 }
644
645 #[test]
646 fn project_config_databases() {
647 let json = r#"{
648 "project": "multi-db",
649 "databases": {
650 "default": {"engine": "postgres", "url": "postgresql:///main"},
651 "analytics": {"engine": "mysql", "url": "mysql://localhost/analytics"}
652 }
653 }"#;
654 let cfg: ProjectConfig = serde_json::from_str(json).unwrap();
655 let dbs = cfg.databases.as_ref().unwrap();
656 assert_eq!(dbs.len(), 2);
657 assert_eq!(dbs.get("default").unwrap().engine, DatabaseEngine::Postgres);
658 assert_eq!(dbs.get("analytics").unwrap().engine, DatabaseEngine::MySQL);
659 }
660
661 #[test]
662 fn project_config_protocols() {
663 let json = r#"{"project": "gql-api", "protocols": ["rest", "graphql"]}"#;
664 let cfg: ProjectConfig = serde_json::from_str(json).unwrap();
665 assert_eq!(cfg.protocols, vec!["rest", "graphql"]);
666 }
667
668 #[test]
669 fn project_config_grpc_protocol() {
670 let json = r#"{"project": "grpc-api", "protocols": ["rest", "grpc"]}"#;
671 let cfg: ProjectConfig = serde_json::from_str(json).unwrap();
672 assert_eq!(cfg.protocols, vec!["rest", "grpc"]);
673 assert!(cfg.grpc.is_none());
674 }
675
676 #[test]
677 fn grpc_config_defaults() {
678 let json = r#"{}"#;
679 let cfg: GrpcConfig = serde_json::from_str(json).unwrap();
680 assert_eq!(cfg.port, 50051);
681 assert!(cfg.reflection);
682 }
683
684 #[test]
685 fn grpc_config_custom() {
686 let json = r#"{"port": 9090, "reflection": false}"#;
687 let cfg: GrpcConfig = serde_json::from_str(json).unwrap();
688 assert_eq!(cfg.port, 9090);
689 assert!(!cfg.reflection);
690 }
691
692 #[test]
696 fn inbound_config_default_signature_header() {
697 let json = r#"{"path": "/webhooks/github", "secret_env": "GITHUB_SECRET"}"#;
698 let cfg: InboundWebhookConfig = serde_json::from_str(json).unwrap();
699 assert_eq!(cfg.signature_header, "X-Webhook-Signature");
700 }
701
702 #[test]
703 fn inbound_config_custom_signature_header() {
704 let json = r#"{"path": "/webhooks/github", "secret_env": "GITHUB_SECRET", "signature_header": "X-Hub-Signature-256"}"#;
705 let cfg: InboundWebhookConfig = serde_json::from_str(json).unwrap();
706 assert_eq!(cfg.signature_header, "X-Hub-Signature-256");
707 }
708
709 #[test]
710 fn project_config_with_grpc() {
711 let json = r#"{
712 "project": "grpc-app",
713 "protocols": ["rest", "grpc"],
714 "grpc": {"port": 50052, "reflection": true}
715 }"#;
716 let cfg: ProjectConfig = serde_json::from_str(json).unwrap();
717 assert_eq!(cfg.protocols, vec!["rest", "grpc"]);
718 let grpc = cfg.grpc.unwrap();
719 assert_eq!(grpc.port, 50052);
720 assert!(grpc.reflection);
721 }
722}