Skip to main content

shaperail_core/
config.rs

1use serde::{Deserialize, Serialize};
2
3use crate::DatabaseEngine;
4
5/// Project-level configuration, parsed from `shaperail.config.yaml`.
6///
7/// ```yaml
8/// project: my-api
9/// port: 3000
10/// workers: auto
11/// database:
12///   type: postgresql
13///   host: localhost
14///   port: 5432
15///   name: my_api_db
16///   pool_size: 20
17/// ```
18///
19/// Multi-database (M14):
20/// ```yaml
21/// databases:
22///   default:
23///     engine: postgres
24///     url: ${DATABASE_URL}
25///   analytics:
26///     engine: mysql
27///     url: mysql://user:pass@localhost/analytics
28///   cache_db:
29///     engine: sqlite
30///     url: file:cache.db
31/// ```
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct ProjectConfig {
35    /// Project name.
36    pub project: String,
37
38    /// HTTP server port.
39    #[serde(default = "default_port")]
40    pub port: u16,
41
42    /// Number of worker threads ("auto" or a number).
43    #[serde(default = "default_workers")]
44    pub workers: WorkerCount,
45
46    /// Single database configuration (legacy). Ignored if `databases` is set.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub database: Option<DatabaseConfig>,
49
50    /// Named database connections (M14). When set, resources use `db: <name>` to select connection.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub databases: Option<indexmap::IndexMap<String, NamedDatabaseConfig>>,
53
54    /// Cache (Redis) configuration.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub cache: Option<CacheConfig>,
57
58    /// Authentication configuration.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub auth: Option<AuthConfig>,
61
62    /// Object storage configuration.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub storage: Option<StorageConfig>,
65
66    /// Logging and observability configuration.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub logging: Option<LoggingConfig>,
69
70    /// Events and webhooks configuration.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub events: Option<EventsConfig>,
73
74    /// Enabled API protocols (M15/M16). Default when omitted: `["rest"]`. Allowed: `rest`, `graphql`, `grpc`.
75    #[serde(default = "default_protocols")]
76    pub protocols: Vec<String>,
77
78    /// GraphQL configuration (M15). Depth and complexity limits.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub graphql: Option<GraphQLConfig>,
81
82    /// gRPC configuration (M16). Port and reflection settings.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub grpc: Option<GrpcConfig>,
85}
86
87/// GraphQL-specific configuration (M15).
88///
89/// ```yaml
90/// graphql:
91///   depth_limit: 10
92///   complexity_limit: 200
93/// ```
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95#[serde(deny_unknown_fields)]
96pub struct GraphQLConfig {
97    /// Maximum query nesting depth. Default: 16.
98    #[serde(default = "default_depth_limit")]
99    pub depth_limit: usize,
100
101    /// Maximum query complexity score. Default: 256.
102    #[serde(default = "default_complexity_limit")]
103    pub complexity_limit: usize,
104}
105
106/// gRPC-specific configuration (M16).
107///
108/// ```yaml
109/// grpc:
110///   port: 50051
111///   reflection: true
112/// ```
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(deny_unknown_fields)]
115pub struct GrpcConfig {
116    /// gRPC server port. Default: 50051.
117    #[serde(default = "default_grpc_port")]
118    pub port: u16,
119
120    /// Enable gRPC server reflection (for grpcurl). Default: true.
121    #[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/// Worker thread count: either automatic or a fixed number.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub enum WorkerCount {
156    /// Automatically detect based on CPU cores.
157    Auto,
158    /// Fixed number of workers.
159    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/// Database connection configuration (legacy single-DB).
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(deny_unknown_fields)]
190pub struct DatabaseConfig {
191    /// Database type (e.g., "postgresql").
192    #[serde(rename = "type")]
193    pub db_type: String,
194
195    /// Database host.
196    #[serde(default = "default_host")]
197    pub host: String,
198
199    /// Database port.
200    #[serde(default = "default_db_port")]
201    pub port: u16,
202
203    /// Database name.
204    pub name: String,
205
206    /// Connection pool size.
207    #[serde(default = "default_pool_size")]
208    pub pool_size: u32,
209}
210
211/// Named database connection (M14 multi-database). Used in `databases: <name>: <config>`.
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(deny_unknown_fields)]
214pub struct NamedDatabaseConfig {
215    /// Engine: postgres, mysql, sqlite, mongodb.
216    pub engine: DatabaseEngine,
217
218    /// Connection URL. Env var interpolation supported (e.g. ${DATABASE_URL}).
219    pub url: String,
220
221    /// Connection pool size (SQL only). Default 20.
222    #[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/// Redis cache configuration.
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(deny_unknown_fields)]
241pub struct CacheConfig {
242    /// Cache type (e.g., "redis").
243    #[serde(rename = "type")]
244    pub cache_type: String,
245
246    /// Redis connection URL.
247    pub url: String,
248}
249
250/// Authentication configuration.
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(deny_unknown_fields)]
253pub struct AuthConfig {
254    /// Auth provider (e.g., "jwt").
255    pub provider: String,
256
257    /// Environment variable name holding the JWT secret.
258    pub secret_env: String,
259
260    /// Token expiry duration (e.g., "24h").
261    pub expiry: String,
262
263    /// Refresh token expiry duration (e.g., "30d").
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub refresh_expiry: Option<String>,
266}
267
268/// Object storage configuration.
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270#[serde(deny_unknown_fields)]
271pub struct StorageConfig {
272    /// Storage provider (e.g., "s3", "gcs", "local").
273    pub provider: String,
274
275    /// Storage bucket name.
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub bucket: Option<String>,
278
279    /// Cloud region.
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub region: Option<String>,
282}
283
284/// Logging and observability configuration.
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
286#[serde(deny_unknown_fields)]
287pub struct LoggingConfig {
288    /// Log level (e.g., "info", "debug", "warn").
289    #[serde(default = "default_log_level")]
290    pub level: String,
291
292    /// Log format (e.g., "json", "pretty").
293    #[serde(default = "default_log_format")]
294    pub format: String,
295
296    /// OpenTelemetry OTLP endpoint.
297    #[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/// Events and webhooks configuration.
310///
311/// ```yaml
312/// events:
313///   subscribers:
314///     - event: "user.created"
315///       targets:
316///         - type: webhook
317///           url: "https://example.com/hooks/user-created"
318///         - type: job
319///           name: send_welcome_email
320///         - type: channel
321///           name: notifications
322///           room: "org:{org_id}"
323///   webhooks:
324///     secret_env: WEBHOOK_SECRET
325///     timeout_secs: 30
326///     max_retries: 3
327///   inbound:
328///     - path: /webhooks/stripe
329///       secret_env: STRIPE_WEBHOOK_SECRET
330///       events: ["payment.completed", "subscription.updated"]
331/// ```
332#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
333#[serde(deny_unknown_fields)]
334pub struct EventsConfig {
335    /// Event subscriber definitions.
336    #[serde(default, skip_serializing_if = "Vec::is_empty")]
337    pub subscribers: Vec<EventSubscriber>,
338
339    /// Outbound webhook global settings.
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub webhooks: Option<WebhookConfig>,
342
343    /// Inbound webhook endpoint definitions.
344    #[serde(default, skip_serializing_if = "Vec::is_empty")]
345    pub inbound: Vec<InboundWebhookConfig>,
346}
347
348/// An event subscriber routes events to targets.
349#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
350#[serde(deny_unknown_fields)]
351pub struct EventSubscriber {
352    /// Event name pattern (e.g., "user.created", "*.deleted").
353    pub event: String,
354
355    /// Targets to dispatch the event to.
356    pub targets: Vec<EventTarget>,
357}
358
359/// A target for event dispatch.
360#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
361#[serde(tag = "type", rename_all = "lowercase")]
362pub enum EventTarget {
363    /// Enqueue a background job.
364    Job { name: String },
365    /// POST to an external webhook URL.
366    Webhook { url: String },
367    /// Broadcast to a WebSocket channel/room.
368    Channel {
369        name: String,
370        #[serde(default, skip_serializing_if = "Option::is_none")]
371        room: Option<String>,
372    },
373    /// Execute a hook function.
374    Hook { name: String },
375}
376
377/// Global outbound webhook settings.
378#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
379#[serde(deny_unknown_fields)]
380pub struct WebhookConfig {
381    /// Environment variable holding the HMAC signing secret.
382    #[serde(default = "default_webhook_secret_env")]
383    pub secret_env: String,
384
385    /// HTTP timeout for webhook delivery in seconds.
386    #[serde(default = "default_webhook_timeout")]
387    pub timeout_secs: u64,
388
389    /// Maximum retry attempts for failed deliveries.
390    #[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/// Inbound webhook endpoint configuration.
407#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
408#[serde(deny_unknown_fields)]
409pub struct InboundWebhookConfig {
410    /// URL path for the inbound webhook (e.g., "/webhooks/stripe").
411    pub path: String,
412
413    /// Environment variable holding the verification secret.
414    pub secret_env: String,
415
416    /// Event names this endpoint accepts.
417    #[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}