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/// databases:
12///   default:
13///     engine: postgres
14///     url: ${DATABASE_URL}
15/// ```
16///
17/// Multi-database (M14):
18/// ```yaml
19/// databases:
20///   default:
21///     engine: postgres
22///     url: ${DATABASE_URL}
23///   analytics:
24///     engine: mysql
25///     url: mysql://user:pass@localhost/analytics
26///   cache_db:
27///     engine: sqlite
28///     url: file:cache.db
29/// ```
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31#[serde(deny_unknown_fields)]
32pub struct ProjectConfig {
33    /// Project name.
34    pub project: String,
35
36    /// HTTP server port.
37    #[serde(default = "default_port")]
38    pub port: u16,
39
40    /// Number of worker threads ("auto" or a number).
41    #[serde(default = "default_workers")]
42    pub workers: WorkerCount,
43
44    /// Named database connections (M14). When set, resources use `db: <name>` to select connection.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub databases: Option<indexmap::IndexMap<String, NamedDatabaseConfig>>,
47
48    /// Cache (Redis) configuration.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub cache: Option<CacheConfig>,
51
52    /// Authentication configuration.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub auth: Option<AuthConfig>,
55
56    /// Object storage configuration.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub storage: Option<StorageConfig>,
59
60    /// Logging and observability configuration.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub logging: Option<LoggingConfig>,
63
64    /// Events and webhooks configuration.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub events: Option<EventsConfig>,
67
68    /// Enabled API protocols (M15/M16). Default when omitted: `["rest"]`. Allowed: `rest`, `graphql`, `grpc`.
69    #[serde(default = "default_protocols")]
70    pub protocols: Vec<String>,
71
72    /// GraphQL configuration (M15). Depth and complexity limits.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub graphql: Option<GraphQLConfig>,
75
76    /// gRPC configuration (M16). Port and reflection settings.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub grpc: Option<GrpcConfig>,
79}
80
81/// GraphQL-specific configuration (M15).
82///
83/// ```yaml
84/// graphql:
85///   depth_limit: 10
86///   complexity_limit: 200
87/// ```
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89#[serde(deny_unknown_fields)]
90pub struct GraphQLConfig {
91    /// Maximum query nesting depth. Default: 16.
92    #[serde(default = "default_depth_limit")]
93    pub depth_limit: usize,
94
95    /// Maximum query complexity score. Default: 256.
96    #[serde(default = "default_complexity_limit")]
97    pub complexity_limit: usize,
98}
99
100/// gRPC-specific configuration (M16).
101///
102/// ```yaml
103/// grpc:
104///   port: 50051
105///   reflection: true
106/// ```
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct GrpcConfig {
110    /// gRPC server port. Default: 50051.
111    #[serde(default = "default_grpc_port")]
112    pub port: u16,
113
114    /// Enable gRPC server reflection (for grpcurl). Default: true.
115    #[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/// Worker thread count: either automatic or a fixed number.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub enum WorkerCount {
150    /// Automatically detect based on CPU cores.
151    Auto,
152    /// Fixed number of workers.
153    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/// Named database connection (M14 multi-database). Used in `databases: <name>: <config>`.
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183#[serde(deny_unknown_fields)]
184pub struct NamedDatabaseConfig {
185    /// Engine: postgres, mysql, sqlite, mongodb.
186    pub engine: DatabaseEngine,
187
188    /// Connection URL. Env var interpolation supported (e.g. ${DATABASE_URL}).
189    pub url: String,
190
191    /// Connection pool size (SQL only). Default 20.
192    #[serde(default = "default_pool_size")]
193    pub pool_size: u32,
194}
195
196fn default_pool_size() -> u32 {
197    20
198}
199
200/// Redis cache configuration.
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(deny_unknown_fields)]
203pub struct CacheConfig {
204    /// Cache type (e.g., "redis").
205    #[serde(rename = "type")]
206    pub cache_type: String,
207
208    /// Redis connection URL.
209    pub url: String,
210}
211
212/// Authentication configuration.
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214#[serde(deny_unknown_fields)]
215pub struct AuthConfig {
216    /// Auth provider (e.g., "jwt").
217    pub provider: String,
218
219    /// Environment variable name holding the JWT secret.
220    pub secret_env: String,
221
222    /// Token expiry duration (e.g., "24h").
223    pub expiry: String,
224
225    /// Refresh token expiry duration (e.g., "30d").
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub refresh_expiry: Option<String>,
228}
229
230/// Object storage configuration.
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232#[serde(deny_unknown_fields)]
233pub struct StorageConfig {
234    /// Storage provider (e.g., "s3", "gcs", "local").
235    pub provider: String,
236
237    /// Storage bucket name.
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub bucket: Option<String>,
240
241    /// Cloud region.
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub region: Option<String>,
244}
245
246/// Logging and observability configuration.
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(deny_unknown_fields)]
249pub struct LoggingConfig {
250    /// Log level (e.g., "info", "debug", "warn").
251    #[serde(default = "default_log_level")]
252    pub level: String,
253
254    /// Log format (e.g., "json", "pretty").
255    #[serde(default = "default_log_format")]
256    pub format: String,
257
258    /// OpenTelemetry OTLP endpoint.
259    #[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/// Events and webhooks configuration.
272///
273/// ```yaml
274/// events:
275///   subscribers:
276///     - event: "user.created"
277///       targets:
278///         - type: webhook
279///           url: "https://example.com/hooks/user-created"
280///         - type: job
281///           name: send_welcome_email
282///         - type: channel
283///           name: notifications
284///           room: "org:{org_id}"
285///   webhooks:
286///     secret_env: WEBHOOK_SECRET
287///     timeout_secs: 30
288///     max_retries: 3
289///   inbound:
290///     - path: /webhooks/stripe
291///       secret_env: STRIPE_WEBHOOK_SECRET
292///       events: ["payment.completed", "subscription.updated"]
293/// ```
294#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
295#[serde(deny_unknown_fields)]
296pub struct EventsConfig {
297    /// Event subscriber definitions.
298    #[serde(default, skip_serializing_if = "Vec::is_empty")]
299    pub subscribers: Vec<EventSubscriber>,
300
301    /// Outbound webhook global settings.
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub webhooks: Option<WebhookConfig>,
304
305    /// Inbound webhook endpoint definitions.
306    #[serde(default, skip_serializing_if = "Vec::is_empty")]
307    pub inbound: Vec<InboundWebhookConfig>,
308}
309
310/// An event subscriber routes events to targets.
311#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
312#[serde(deny_unknown_fields)]
313pub struct EventSubscriber {
314    /// Event name pattern (e.g., "user.created", "*.deleted").
315    pub event: String,
316
317    /// Targets to dispatch the event to.
318    pub targets: Vec<EventTarget>,
319}
320
321/// A target for event dispatch.
322#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
323#[serde(tag = "type", rename_all = "lowercase")]
324pub enum EventTarget {
325    /// Enqueue a background job.
326    Job { name: String },
327    /// POST to an external webhook URL.
328    Webhook { url: String },
329    /// Broadcast to a WebSocket channel/room.
330    Channel {
331        name: String,
332        #[serde(default, skip_serializing_if = "Option::is_none")]
333        room: Option<String>,
334    },
335    /// Execute a hook function.
336    Hook { name: String },
337}
338
339/// Global outbound webhook settings.
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
341#[serde(deny_unknown_fields)]
342pub struct WebhookConfig {
343    /// Environment variable holding the HMAC signing secret.
344    #[serde(default = "default_webhook_secret_env")]
345    pub secret_env: String,
346
347    /// HTTP timeout for webhook delivery in seconds.
348    #[serde(default = "default_webhook_timeout")]
349    pub timeout_secs: u64,
350
351    /// Maximum retry attempts for failed deliveries.
352    #[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/// Inbound webhook endpoint configuration.
369#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
370#[serde(deny_unknown_fields)]
371pub struct InboundWebhookConfig {
372    /// URL path for the inbound webhook (e.g., "/webhooks/stripe").
373    pub path: String,
374
375    /// Environment variable holding the verification secret.
376    pub secret_env: String,
377
378    /// Event names this endpoint accepts.
379    #[serde(default, skip_serializing_if = "Vec::is_empty")]
380    pub events: Vec<String>,
381
382    /// HTTP header carrying the HMAC signature for this endpoint.
383    /// Defaults to `X-Webhook-Signature`. Common values: `X-Hub-Signature-256` (GitHub), `Stripe-Signature` (Stripe).
384    #[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    // All config tests in this file use serde_json for consistency with the established
626    // pattern. The actual config format is YAML, but serde deserialization of simple
627    // struct fields is format-agnostic — JSON and YAML produce the same result here.
628    #[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}