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    /// HTTP header carrying the HMAC signature for this endpoint.
421    /// Defaults to `X-Webhook-Signature`. Common values: `X-Hub-Signature-256` (GitHub), `Stripe-Signature` (Stripe).
422    #[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    // All config tests in this file use serde_json for consistency with the established
693    // pattern. The actual config format is YAML, but serde deserialization of simple
694    // struct fields is format-agnostic — JSON and YAML produce the same result here.
695    #[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}