1use serde::{Deserialize, Serialize};
2
3use crate::DatabaseEngine;
4
5#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31#[serde(deny_unknown_fields)]
32pub struct ProjectConfig {
33 pub project: String,
35
36 #[serde(default = "default_port")]
38 pub port: u16,
39
40 #[serde(default = "default_workers")]
42 pub workers: WorkerCount,
43
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub databases: Option<indexmap::IndexMap<String, NamedDatabaseConfig>>,
47
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub cache: Option<CacheConfig>,
51
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub auth: Option<AuthConfig>,
55
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub storage: Option<StorageConfig>,
59
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub logging: Option<LoggingConfig>,
63
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub events: Option<EventsConfig>,
67
68 #[serde(default = "default_protocols")]
70 pub protocols: Vec<String>,
71
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub graphql: Option<GraphQLConfig>,
75
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub grpc: Option<GrpcConfig>,
79}
80
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89#[serde(deny_unknown_fields)]
90pub struct GraphQLConfig {
91 #[serde(default = "default_depth_limit")]
93 pub depth_limit: usize,
94
95 #[serde(default = "default_complexity_limit")]
97 pub complexity_limit: usize,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct GrpcConfig {
110 #[serde(default = "default_grpc_port")]
112 pub port: u16,
113
114 #[serde(default = "default_grpc_reflection")]
116 pub reflection: bool,
117}
118
119fn default_grpc_port() -> u16 {
120 50051
121}
122
123fn default_grpc_reflection() -> bool {
124 true
125}
126
127fn default_depth_limit() -> usize {
128 16
129}
130
131fn default_complexity_limit() -> usize {
132 256
133}
134
135fn default_port() -> u16 {
136 3000
137}
138
139fn default_protocols() -> Vec<String> {
140 vec!["rest".to_string()]
141}
142
143fn default_workers() -> WorkerCount {
144 WorkerCount::Auto
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
149pub enum WorkerCount {
150 Auto,
152 Fixed(usize),
154}
155
156impl Serialize for WorkerCount {
157 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
158 match self {
159 Self::Auto => serializer.serialize_str("auto"),
160 Self::Fixed(n) => serializer.serialize_u64(*n as u64),
161 }
162 }
163}
164
165impl<'de> Deserialize<'de> for WorkerCount {
166 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
167 let value = serde_json::Value::deserialize(deserializer)?;
168 match &value {
169 serde_json::Value::String(s) if s == "auto" => Ok(Self::Auto),
170 serde_json::Value::Number(n) => n
171 .as_u64()
172 .map(|v| Self::Fixed(v as usize))
173 .ok_or_else(|| serde::de::Error::custom("workers must be a positive integer")),
174 _ => Err(serde::de::Error::custom(
175 "workers must be \"auto\" or a positive integer",
176 )),
177 }
178 }
179}
180
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183#[serde(deny_unknown_fields)]
184pub struct NamedDatabaseConfig {
185 pub engine: DatabaseEngine,
187
188 pub url: String,
190
191 #[serde(default = "default_pool_size")]
193 pub pool_size: u32,
194}
195
196fn default_pool_size() -> u32 {
197 20
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(deny_unknown_fields)]
203pub struct CacheConfig {
204 #[serde(rename = "type")]
206 pub cache_type: String,
207
208 pub url: String,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214#[serde(deny_unknown_fields)]
215pub struct AuthConfig {
216 pub provider: String,
218
219 pub secret_env: String,
221
222 pub expiry: String,
224
225 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub refresh_expiry: Option<String>,
228}
229
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232#[serde(deny_unknown_fields)]
233pub struct StorageConfig {
234 pub provider: String,
236
237 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub bucket: Option<String>,
240
241 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub region: Option<String>,
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(deny_unknown_fields)]
249pub struct LoggingConfig {
250 #[serde(default = "default_log_level")]
252 pub level: String,
253
254 #[serde(default = "default_log_format")]
256 pub format: String,
257
258 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub otlp_endpoint: Option<String>,
261}
262
263fn default_log_level() -> String {
264 "info".to_string()
265}
266
267fn default_log_format() -> String {
268 "json".to_string()
269}
270
271#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
295#[serde(deny_unknown_fields)]
296pub struct EventsConfig {
297 #[serde(default, skip_serializing_if = "Vec::is_empty")]
299 pub subscribers: Vec<EventSubscriber>,
300
301 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub webhooks: Option<WebhookConfig>,
304
305 #[serde(default, skip_serializing_if = "Vec::is_empty")]
307 pub inbound: Vec<InboundWebhookConfig>,
308}
309
310#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
312#[serde(deny_unknown_fields)]
313pub struct EventSubscriber {
314 pub event: String,
316
317 pub targets: Vec<EventTarget>,
319}
320
321#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
323#[serde(tag = "type", rename_all = "lowercase")]
324pub enum EventTarget {
325 Job { name: String },
327 Webhook { url: String },
329 Channel {
331 name: String,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
333 room: Option<String>,
334 },
335 Hook { name: String },
337}
338
339#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
341#[serde(deny_unknown_fields)]
342pub struct WebhookConfig {
343 #[serde(default = "default_webhook_secret_env")]
345 pub secret_env: String,
346
347 #[serde(default = "default_webhook_timeout")]
349 pub timeout_secs: u64,
350
351 #[serde(default = "default_webhook_max_retries")]
353 pub max_retries: u32,
354}
355
356fn default_webhook_secret_env() -> String {
357 "WEBHOOK_SECRET".to_string()
358}
359
360fn default_webhook_timeout() -> u64 {
361 30
362}
363
364fn default_webhook_max_retries() -> u32 {
365 3
366}
367
368#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
370#[serde(deny_unknown_fields)]
371pub struct InboundWebhookConfig {
372 pub path: String,
374
375 pub secret_env: String,
377
378 #[serde(default, skip_serializing_if = "Vec::is_empty")]
380 pub events: Vec<String>,
381
382 #[serde(default = "default_signature_header")]
385 pub signature_header: String,
386}
387
388fn default_signature_header() -> String {
389 "X-Webhook-Signature".to_string()
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 #[test]
397 fn project_config_minimal() {
398 let json = r#"{"project": "my-app"}"#;
399 let cfg: ProjectConfig = serde_json::from_str(json).unwrap();
400 assert_eq!(cfg.project, "my-app");
401 assert_eq!(cfg.port, 3000);
402 assert_eq!(cfg.workers, WorkerCount::Auto);
403 assert_eq!(cfg.protocols, vec!["rest"]);
404 }
405
406 #[test]
407 fn project_config_full() {
408 let json = r#"{
409 "project": "my-api",
410 "port": 8080,
411 "workers": 4,
412 "cache": {
413 "type": "redis",
414 "url": "redis://localhost:6379"
415 },
416 "auth": {
417 "provider": "jwt",
418 "secret_env": "JWT_SECRET",
419 "expiry": "24h",
420 "refresh_expiry": "30d"
421 },
422 "storage": {
423 "provider": "s3",
424 "bucket": "my-bucket",
425 "region": "us-east-1"
426 },
427 "logging": {
428 "level": "debug",
429 "format": "json",
430 "otlp_endpoint": "http://localhost:4317"
431 }
432 }"#;
433 let cfg: ProjectConfig = serde_json::from_str(json).unwrap();
434 assert_eq!(cfg.port, 8080);
435 assert_eq!(cfg.workers, WorkerCount::Fixed(4));
436 let cache = cfg.cache.unwrap();
437 assert_eq!(cache.cache_type, "redis");
438 let auth = cfg.auth.unwrap();
439 assert_eq!(auth.provider, "jwt");
440 assert_eq!(auth.refresh_expiry.as_deref(), Some("30d"));
441 let storage = cfg.storage.unwrap();
442 assert_eq!(storage.provider, "s3");
443 let logging = cfg.logging.unwrap();
444 assert_eq!(logging.level, "debug");
445 assert_eq!(
446 logging.otlp_endpoint.as_deref(),
447 Some("http://localhost:4317")
448 );
449 }
450
451 #[test]
452 fn worker_count_auto() {
453 let wc: WorkerCount = serde_json::from_str("\"auto\"").unwrap();
454 assert_eq!(wc, WorkerCount::Auto);
455 }
456
457 #[test]
458 fn worker_count_fixed() {
459 let wc: WorkerCount = serde_json::from_str("8").unwrap();
460 assert_eq!(wc, WorkerCount::Fixed(8));
461 }
462
463 #[test]
464 fn logging_config_defaults() {
465 let json = r#"{}"#;
466 let log: LoggingConfig = serde_json::from_str(json).unwrap();
467 assert_eq!(log.level, "info");
468 assert_eq!(log.format, "json");
469 assert!(log.otlp_endpoint.is_none());
470 }
471
472 #[test]
473 fn project_config_serde_roundtrip() {
474 let cfg = ProjectConfig {
475 project: "roundtrip-test".to_string(),
476 port: 3000,
477 workers: WorkerCount::Auto,
478 databases: None,
479 cache: None,
480 auth: None,
481 storage: None,
482 logging: None,
483 events: None,
484 protocols: vec!["rest".to_string()],
485 graphql: None,
486 grpc: None,
487 };
488 let json = serde_json::to_string(&cfg).unwrap();
489 let back: ProjectConfig = serde_json::from_str(&json).unwrap();
490 assert_eq!(cfg, back);
491 }
492
493 #[test]
494 fn events_config_serde() {
495 let json = r#"{
496 "subscribers": [
497 {
498 "event": "users.created",
499 "targets": [
500 {"type": "job", "name": "send_welcome_email"},
501 {"type": "webhook", "url": "https://example.com/hook"},
502 {"type": "channel", "name": "notifications", "room": "org:123"},
503 {"type": "hook", "name": "validate_org"}
504 ]
505 },
506 {
507 "event": "*.deleted",
508 "targets": [
509 {"type": "job", "name": "cleanup_job"}
510 ]
511 }
512 ],
513 "webhooks": {
514 "secret_env": "MY_SECRET",
515 "timeout_secs": 15,
516 "max_retries": 5
517 },
518 "inbound": [
519 {
520 "path": "/webhooks/stripe",
521 "secret_env": "STRIPE_SECRET",
522 "events": ["payment.completed"]
523 }
524 ]
525 }"#;
526 let cfg: EventsConfig = serde_json::from_str(json).unwrap();
527 assert_eq!(cfg.subscribers.len(), 2);
528 assert_eq!(cfg.subscribers[0].event, "users.created");
529 assert_eq!(cfg.subscribers[0].targets.len(), 4);
530 assert!(
531 matches!(&cfg.subscribers[0].targets[0], EventTarget::Job { name } if name == "send_welcome_email")
532 );
533 assert!(
534 matches!(&cfg.subscribers[0].targets[1], EventTarget::Webhook { url } if url == "https://example.com/hook")
535 );
536 assert!(
537 matches!(&cfg.subscribers[0].targets[2], EventTarget::Channel { name, room } if name == "notifications" && room.as_deref() == Some("org:123"))
538 );
539 assert!(
540 matches!(&cfg.subscribers[0].targets[3], EventTarget::Hook { name } if name == "validate_org")
541 );
542 let webhooks = cfg.webhooks.unwrap();
543 assert_eq!(webhooks.secret_env, "MY_SECRET");
544 assert_eq!(webhooks.timeout_secs, 15);
545 assert_eq!(webhooks.max_retries, 5);
546 assert_eq!(cfg.inbound.len(), 1);
547 assert_eq!(cfg.inbound[0].path, "/webhooks/stripe");
548 }
549
550 #[test]
551 fn webhook_config_defaults() {
552 let json = r#"{}"#;
553 let cfg: WebhookConfig = serde_json::from_str(json).unwrap();
554 assert_eq!(cfg.secret_env, "WEBHOOK_SECRET");
555 assert_eq!(cfg.timeout_secs, 30);
556 assert_eq!(cfg.max_retries, 3);
557 }
558
559 #[test]
560 fn events_config_empty() {
561 let json = r#"{"subscribers": [], "inbound": []}"#;
562 let cfg: EventsConfig = serde_json::from_str(json).unwrap();
563 assert!(cfg.subscribers.is_empty());
564 assert!(cfg.webhooks.is_none());
565 assert!(cfg.inbound.is_empty());
566 }
567
568 #[test]
569 fn named_database_config() {
570 let json =
571 r#"{"engine": "postgres", "url": "postgresql://localhost/mydb", "pool_size": 10}"#;
572 let cfg: NamedDatabaseConfig = serde_json::from_str(json).unwrap();
573 assert_eq!(cfg.engine, DatabaseEngine::Postgres);
574 assert_eq!(cfg.url, "postgresql://localhost/mydb");
575 assert_eq!(cfg.pool_size, 10);
576 }
577
578 #[test]
579 fn project_config_databases() {
580 let json = r#"{
581 "project": "multi-db",
582 "databases": {
583 "default": {"engine": "postgres", "url": "postgresql:///main"},
584 "analytics": {"engine": "mysql", "url": "mysql://localhost/analytics"}
585 }
586 }"#;
587 let cfg: ProjectConfig = serde_json::from_str(json).unwrap();
588 let dbs = cfg.databases.as_ref().unwrap();
589 assert_eq!(dbs.len(), 2);
590 assert_eq!(dbs.get("default").unwrap().engine, DatabaseEngine::Postgres);
591 assert_eq!(dbs.get("analytics").unwrap().engine, DatabaseEngine::MySQL);
592 }
593
594 #[test]
595 fn project_config_protocols() {
596 let json = r#"{"project": "gql-api", "protocols": ["rest", "graphql"]}"#;
597 let cfg: ProjectConfig = serde_json::from_str(json).unwrap();
598 assert_eq!(cfg.protocols, vec!["rest", "graphql"]);
599 }
600
601 #[test]
602 fn project_config_grpc_protocol() {
603 let json = r#"{"project": "grpc-api", "protocols": ["rest", "grpc"]}"#;
604 let cfg: ProjectConfig = serde_json::from_str(json).unwrap();
605 assert_eq!(cfg.protocols, vec!["rest", "grpc"]);
606 assert!(cfg.grpc.is_none());
607 }
608
609 #[test]
610 fn grpc_config_defaults() {
611 let json = r#"{}"#;
612 let cfg: GrpcConfig = serde_json::from_str(json).unwrap();
613 assert_eq!(cfg.port, 50051);
614 assert!(cfg.reflection);
615 }
616
617 #[test]
618 fn grpc_config_custom() {
619 let json = r#"{"port": 9090, "reflection": false}"#;
620 let cfg: GrpcConfig = serde_json::from_str(json).unwrap();
621 assert_eq!(cfg.port, 9090);
622 assert!(!cfg.reflection);
623 }
624
625 #[test]
629 fn inbound_config_default_signature_header() {
630 let json = r#"{"path": "/webhooks/github", "secret_env": "GITHUB_SECRET"}"#;
631 let cfg: InboundWebhookConfig = serde_json::from_str(json).unwrap();
632 assert_eq!(cfg.signature_header, "X-Webhook-Signature");
633 }
634
635 #[test]
636 fn inbound_config_custom_signature_header() {
637 let json = r#"{"path": "/webhooks/github", "secret_env": "GITHUB_SECRET", "signature_header": "X-Hub-Signature-256"}"#;
638 let cfg: InboundWebhookConfig = serde_json::from_str(json).unwrap();
639 assert_eq!(cfg.signature_header, "X-Hub-Signature-256");
640 }
641
642 #[test]
643 fn project_config_with_grpc() {
644 let json = r#"{
645 "project": "grpc-app",
646 "protocols": ["rest", "grpc"],
647 "grpc": {"port": 50052, "reflection": true}
648 }"#;
649 let cfg: ProjectConfig = serde_json::from_str(json).unwrap();
650 assert_eq!(cfg.protocols, vec!["rest", "grpc"]);
651 let grpc = cfg.grpc.unwrap();
652 assert_eq!(grpc.port, 50052);
653 assert!(grpc.reflection);
654 }
655}