Skip to main content

structured_proxy/
config.rs

1//! YAML-based proxy configuration.
2//!
3//! All product-specific behavior is driven by config, not code.
4//! Same binary, different YAML = different product proxy.
5
6use serde::Deserialize;
7use std::path::PathBuf;
8
9/// Top-level proxy configuration (loaded from YAML).
10///
11/// This and the wiring structs below (`UpstreamConfig`, `ListenConfig`,
12/// `ServiceConfig`, `DescriptorSource`) are intentionally NOT
13/// `#[non_exhaustive]`: embedding consumers build them programmatically with
14/// runtime values. The leaf auth/shield/oidc config structs are
15/// `#[non_exhaustive]` instead, since those are deserialized, not hand-built.
16#[derive(Debug, Clone, Deserialize)]
17pub struct ProxyConfig {
18    /// Upstream gRPC service(s).
19    pub upstream: UpstreamConfig,
20
21    /// Proto descriptor sources.
22    #[serde(default, deserialize_with = "deserialize_descriptor_sources")]
23    pub descriptors: Vec<DescriptorSource>,
24
25    /// Listen addresses.
26    #[serde(default)]
27    pub listen: ListenConfig,
28
29    /// Service identity (for health endpoint, metrics namespace).
30    #[serde(default)]
31    pub service: ServiceConfig,
32
33    /// Path aliases (e.g., /oauth2/* → /v1/oauth2/*).
34    #[serde(default)]
35    pub aliases: Vec<AliasConfig>,
36
37    /// OpenAPI generation.
38    #[serde(default)]
39    pub openapi: Option<OpenApiConfig>,
40
41    /// Auth configuration (JWT, forward auth, AuthZ).
42    #[serde(default)]
43    pub auth: Option<AuthConfig>,
44
45    /// Rate limiting (Shield).
46    #[serde(default)]
47    pub shield: Option<ShieldConfig>,
48
49    /// OIDC discovery (optional — for IdP proxies).
50    #[serde(default)]
51    pub oidc_discovery: Option<OidcDiscoveryConfig>,
52
53    /// Maintenance mode.
54    #[serde(default)]
55    pub maintenance: MaintenanceConfig,
56
57    /// CORS configuration.
58    #[serde(default)]
59    pub cors: CorsConfig,
60
61    /// Logging.
62    #[serde(default)]
63    pub logging: LoggingConfig,
64
65    /// Metrics endpoint classification (path patterns → class labels).
66    #[serde(default)]
67    pub metrics_classes: Vec<MetricsClassConfig>,
68
69    /// Headers to forward from HTTP to gRPC metadata.
70    #[serde(default = "default_forwarded_headers")]
71    pub forwarded_headers: Vec<String>,
72
73    /// Server-streaming response behavior.
74    #[serde(default)]
75    pub streaming: StreamingConfig,
76}
77
78fn default_forwarded_headers() -> Vec<String> {
79    vec![
80        "authorization".into(),
81        "dpop".into(),
82        "x-request-id".into(),
83        "x-forwarded-for".into(),
84        "x-forwarded-proto".into(),
85        "x-real-ip".into(),
86        "accept-language".into(),
87        "user-agent".into(),
88        "idempotency-key".into(),
89    ]
90}
91
92/// Server-streaming response behavior.
93///
94/// Server-streaming RPCs are exposed as NDJSON by default and as Server-Sent
95/// Events when the client sends `Accept: text/event-stream`. The keep-alive
96/// interval applies only to the SSE path.
97#[derive(Debug, Clone, Deserialize)]
98pub struct StreamingConfig {
99    /// SSE keep-alive interval in seconds. Comment frames are emitted on idle
100    /// streams to keep intermediaries (load balancers, nginx) from closing the
101    /// connection on read timeout. Default: 15.
102    #[serde(default = "default_sse_keep_alive_secs")]
103    pub sse_keep_alive_secs: u64,
104}
105
106fn default_sse_keep_alive_secs() -> u64 {
107    15
108}
109
110impl Default for StreamingConfig {
111    fn default() -> Self {
112        Self {
113            sse_keep_alive_secs: default_sse_keep_alive_secs(),
114        }
115    }
116}
117
118/// Upstream gRPC service configuration.
119#[derive(Debug, Clone, Deserialize)]
120pub struct UpstreamConfig {
121    /// gRPC upstream address (e.g., "http://localhost:4180").
122    pub default: String,
123}
124
125/// Descriptor loading source.
126#[derive(Debug, Clone)]
127pub enum DescriptorSource {
128    /// Pre-compiled descriptor file.
129    File { file: PathBuf },
130    /// gRPC server reflection (development mode).
131    Reflection { reflection: String },
132    /// Embedded bytes (set programmatically, not from YAML).
133    Embedded { bytes: &'static [u8] },
134}
135
136/// Helper for YAML deserialization (only File and Reflection variants).
137#[derive(Debug, Clone, Deserialize)]
138#[serde(untagged)]
139enum DescriptorSourceYaml {
140    File { file: PathBuf },
141    Reflection { reflection: String },
142}
143
144impl From<DescriptorSourceYaml> for DescriptorSource {
145    fn from(yaml: DescriptorSourceYaml) -> Self {
146        match yaml {
147            DescriptorSourceYaml::File { file } => DescriptorSource::File { file },
148            DescriptorSourceYaml::Reflection { reflection } => {
149                DescriptorSource::Reflection { reflection }
150            }
151        }
152    }
153}
154
155fn deserialize_descriptor_sources<'de, D>(
156    deserializer: D,
157) -> std::result::Result<Vec<DescriptorSource>, D::Error>
158where
159    D: serde::Deserializer<'de>,
160{
161    let yaml_sources: Vec<DescriptorSourceYaml> = Vec::deserialize(deserializer)?;
162    Ok(yaml_sources.into_iter().map(Into::into).collect())
163}
164
165/// Listen address configuration.
166#[derive(Debug, Clone, Deserialize)]
167pub struct ListenConfig {
168    /// HTTP listen address (default: "0.0.0.0:8080").
169    #[serde(default = "default_http_listen")]
170    pub http: String,
171}
172
173fn default_http_listen() -> String {
174    "0.0.0.0:8080".into()
175}
176
177impl Default for ListenConfig {
178    fn default() -> Self {
179        Self {
180            http: default_http_listen(),
181        }
182    }
183}
184
185/// Service identity.
186#[derive(Debug, Clone, Deserialize)]
187pub struct ServiceConfig {
188    /// Service name (appears in /health response and metrics namespace).
189    #[serde(default = "default_service_name")]
190    pub name: String,
191}
192
193fn default_service_name() -> String {
194    "structured-proxy".into()
195}
196
197impl Default for ServiceConfig {
198    fn default() -> Self {
199        Self {
200            name: default_service_name(),
201        }
202    }
203}
204
205/// Path alias (rewrite before routing).
206#[derive(Debug, Clone, Deserialize)]
207#[non_exhaustive]
208pub struct AliasConfig {
209    pub from: String,
210    pub to: String,
211}
212
213/// OpenAPI generation config.
214#[derive(Debug, Clone, Deserialize)]
215#[non_exhaustive]
216pub struct OpenApiConfig {
217    #[serde(default = "default_true")]
218    pub enabled: bool,
219    /// Path for OpenAPI JSON spec (default: "/openapi.json").
220    #[serde(default = "default_openapi_path")]
221    pub path: String,
222    /// Path for interactive API docs UI (default: "/docs").
223    #[serde(default = "default_docs_path")]
224    pub docs_path: String,
225    #[serde(default)]
226    pub title: Option<String>,
227    #[serde(default)]
228    pub version: Option<String>,
229}
230
231fn default_openapi_path() -> String {
232    "/openapi.json".into()
233}
234
235fn default_docs_path() -> String {
236    "/docs".into()
237}
238
239fn default_true() -> bool {
240    true
241}
242
243/// Auth configuration.
244#[derive(Debug, Clone, Deserialize)]
245#[non_exhaustive]
246pub struct AuthConfig {
247    /// Auth mode: "none", "jwt", "api_key".
248    #[serde(default = "default_auth_mode")]
249    pub mode: String,
250
251    /// JWT validation config.
252    #[serde(default)]
253    pub jwt: Option<JwtConfig>,
254
255    /// Forward auth endpoint.
256    #[serde(default)]
257    pub forward_auth: Option<ForwardAuthConfig>,
258
259    /// AuthZ integration (optional gRPC call).
260    #[serde(default)]
261    pub authz: Option<AuthzConfig>,
262}
263
264fn default_auth_mode() -> String {
265    "none".into()
266}
267
268/// JWT validation config.
269#[derive(Debug, Clone, Deserialize)]
270#[non_exhaustive]
271pub struct JwtConfig {
272    /// JWKS URI for key discovery.
273    #[serde(default)]
274    pub jwks_uri: Option<String>,
275    /// Expected issuer.
276    #[serde(default)]
277    pub issuer: Option<String>,
278    /// Expected audience.
279    #[serde(default)]
280    pub audience: Option<String>,
281    /// Path to Ed25519 public key PEM file (alternative to JWKS URI).
282    #[serde(default)]
283    pub public_key_pem_file: Option<PathBuf>,
284    /// Claims → HTTP headers mapping.
285    #[serde(default)]
286    pub claims_headers: std::collections::HashMap<String, String>,
287    /// Claim holding the user's roles (array of strings). Supports a dotted
288    /// path for nested claims, e.g. "realm_access.roles". Default: "roles".
289    #[serde(default = "default_roles_claim")]
290    pub roles_claim: String,
291}
292
293fn default_roles_claim() -> String {
294    "roles".into()
295}
296
297/// Forward auth config.
298#[derive(Debug, Clone, Deserialize)]
299#[non_exhaustive]
300pub struct ForwardAuthConfig {
301    #[serde(default)]
302    pub enabled: bool,
303    #[serde(default = "default_forward_auth_path")]
304    pub path: String,
305    /// Route policies.
306    #[serde(default)]
307    pub policies: Vec<RoutePolicyConfig>,
308    /// Login URL for 401 redirects.
309    #[serde(default)]
310    pub login_url: Option<String>,
311    /// Applications YAML file path.
312    #[serde(default)]
313    pub applications_path: Option<PathBuf>,
314}
315
316fn default_forward_auth_path() -> String {
317    "/auth/verify".into()
318}
319
320/// Route policy entry.
321#[derive(Debug, Clone, Deserialize)]
322#[non_exhaustive]
323pub struct RoutePolicyConfig {
324    pub path: String,
325    #[serde(default = "default_methods_all")]
326    pub methods: Vec<String>,
327    #[serde(default)]
328    pub require_auth: bool,
329    #[serde(default)]
330    pub required_roles: Vec<String>,
331}
332
333fn default_methods_all() -> Vec<String> {
334    vec!["*".into()]
335}
336
337/// External authorization via the Envoy ext_authz gRPC contract
338/// (`envoy.service.auth.v3.Authorization/Check`). Interops with OPA and any
339/// ext_authz server.
340#[derive(Debug, Clone, Deserialize)]
341#[non_exhaustive]
342pub struct AuthzConfig {
343    /// Enable external authorization for proxied API requests.
344    #[serde(default)]
345    pub enabled: bool,
346    /// gRPC address of the ext_authz server, e.g. `http://opa:9191`. Required
347    /// when enabled; defaults to empty so a disabled block can omit it.
348    #[serde(default)]
349    pub endpoint: String,
350    /// Per-request authorization call timeout, in milliseconds.
351    #[serde(default = "default_authz_timeout_ms")]
352    pub timeout_ms: u64,
353    /// When the authz call itself fails (unreachable / timeout), allow the
354    /// request through instead of denying. Defaults to false (fail closed).
355    #[serde(default)]
356    pub failure_mode_allow: bool,
357}
358
359fn default_authz_timeout_ms() -> u64 {
360    200
361}
362
363/// Shield (rate limiting) configuration.
364#[derive(Debug, Clone, Deserialize)]
365#[non_exhaustive]
366pub struct ShieldConfig {
367    #[serde(default)]
368    pub enabled: bool,
369    /// Endpoint classification (glob pattern → class → rate limit).
370    #[serde(default)]
371    pub endpoint_classes: Vec<EndpointClassConfig>,
372    /// Per-identifier rate limiting.
373    #[serde(default)]
374    pub identifier_endpoints: Vec<IdentifierEndpointConfig>,
375    /// Window size in seconds (default: 60).
376    #[serde(default = "default_window_secs")]
377    pub window_secs: u64,
378    /// Redis URL for shared counters across replicas (e.g. "redis://127.0.0.1/").
379    /// When unset, an in-process per-replica store is used. Requires the `redis`
380    /// build feature; otherwise the proxy logs a warning and uses the in-process
381    /// store.
382    #[serde(default)]
383    pub redis_url: Option<String>,
384    /// CIDR ranges of trusted reverse proxies / load balancers (e.g.
385    /// "10.0.0.0/8"). `X-Forwarded-For` / `X-Real-IP` are honored only when the
386    /// direct peer falls in one of these ranges; otherwise the peer socket
387    /// address is used as the client identity. Empty (the default) means do not
388    /// trust forwarding headers — set this behind a load balancer.
389    #[serde(default)]
390    pub trusted_proxies: Vec<String>,
391}
392
393fn default_window_secs() -> u64 {
394    60
395}
396
397/// Endpoint classification for rate limiting.
398#[derive(Debug, Clone, Deserialize)]
399#[non_exhaustive]
400pub struct EndpointClassConfig {
401    /// Glob pattern (e.g., "/v1/auth/**").
402    pub pattern: String,
403    /// Class name (e.g., "auth").
404    pub class: String,
405    /// Rate limit string (e.g., "20/min").
406    pub rate: String,
407}
408
409/// Per-identifier rate limiting config.
410#[derive(Debug, Clone, Deserialize)]
411#[non_exhaustive]
412pub struct IdentifierEndpointConfig {
413    pub path: String,
414    pub body_field: String,
415    pub rate: String,
416}
417
418/// OIDC discovery config.
419#[derive(Debug, Clone, Deserialize)]
420#[non_exhaustive]
421pub struct OidcDiscoveryConfig {
422    #[serde(default)]
423    pub enabled: bool,
424    pub issuer: String,
425    #[serde(default)]
426    pub authorization_endpoint: Option<String>,
427    #[serde(default)]
428    pub token_endpoint: Option<String>,
429    #[serde(default)]
430    pub userinfo_endpoint: Option<String>,
431    #[serde(default)]
432    pub jwks_uri: Option<String>,
433    #[serde(default)]
434    pub signing_key: Option<SigningKeyConfig>,
435}
436
437/// Signing key config for JWKS endpoint.
438#[derive(Debug, Clone, Deserialize)]
439#[non_exhaustive]
440pub struct SigningKeyConfig {
441    #[serde(default = "default_algorithm")]
442    pub algorithm: String,
443    pub public_key_pem_file: PathBuf,
444}
445
446fn default_algorithm() -> String {
447    "EdDSA".into()
448}
449
450/// Maintenance mode config.
451#[derive(Debug, Clone, Deserialize)]
452#[non_exhaustive]
453pub struct MaintenanceConfig {
454    #[serde(default)]
455    pub enabled: bool,
456    /// Paths exempt from maintenance mode (glob patterns).
457    #[serde(default = "default_exempt_paths")]
458    pub exempt_paths: Vec<String>,
459    #[serde(default = "default_maintenance_message")]
460    pub message: String,
461}
462
463fn default_exempt_paths() -> Vec<String> {
464    vec![
465        "/health/**".into(),
466        "/.well-known/**".into(),
467        "/metrics".into(),
468        "/auth/verify".into(),
469    ]
470}
471
472fn default_maintenance_message() -> String {
473    "Service is under maintenance. Please try again later.".into()
474}
475
476impl Default for MaintenanceConfig {
477    fn default() -> Self {
478        Self {
479            enabled: false,
480            exempt_paths: default_exempt_paths(),
481            message: default_maintenance_message(),
482        }
483    }
484}
485
486/// CORS configuration.
487#[derive(Debug, Clone, Default, Deserialize)]
488#[non_exhaustive]
489pub struct CorsConfig {
490    /// Allowed origins. Empty = permissive (dev mode).
491    #[serde(default)]
492    pub origins: Vec<String>,
493}
494
495/// Logging configuration.
496#[derive(Debug, Clone, Deserialize)]
497#[non_exhaustive]
498pub struct LoggingConfig {
499    #[serde(default = "default_log_level")]
500    pub level: String,
501    #[serde(default = "default_log_format")]
502    pub format: String,
503}
504
505fn default_log_level() -> String {
506    "info".into()
507}
508fn default_log_format() -> String {
509    "json".into()
510}
511
512impl Default for LoggingConfig {
513    fn default() -> Self {
514        Self {
515            level: default_log_level(),
516            format: default_log_format(),
517        }
518    }
519}
520
521/// Metrics endpoint classification.
522#[derive(Debug, Clone, Deserialize)]
523#[non_exhaustive]
524pub struct MetricsClassConfig {
525    /// Glob pattern for path matching.
526    pub pattern: String,
527    /// Label value for this class.
528    pub class: String,
529}
530
531impl ProxyConfig {
532    /// Load configuration from a YAML file.
533    pub fn from_file(path: &std::path::Path) -> anyhow::Result<Self> {
534        Self::from_yaml_str(&std::fs::read_to_string(path)?)
535    }
536
537    /// Parse configuration from a YAML string.
538    ///
539    /// Useful for embedding the proxy: load a baked-in config (e.g. via
540    /// `include_str!`) without touching the filesystem.
541    pub fn from_yaml_str(yaml: &str) -> anyhow::Result<Self> {
542        let config: Self = serde_yaml::from_str(yaml)?;
543        config.validate()?;
544        Ok(config)
545    }
546
547    /// Validate cross-field constraints that the type system can't express.
548    ///
549    /// Called automatically by [`from_yaml_str`](Self::from_yaml_str); call it
550    /// directly when building a [`ProxyConfig`] programmatically so the same
551    /// invariants are enforced on the embedded path.
552    pub fn validate(&self) -> anyhow::Result<()> {
553        if self.streaming.sse_keep_alive_secs == 0 {
554            anyhow::bail!("streaming.sse_keep_alive_secs must be greater than 0");
555        }
556        Ok(())
557    }
558
559    /// Parse rate string like "20/min" → requests per window.
560    pub fn parse_rate(rate: &str) -> Option<u32> {
561        let parts: Vec<&str> = rate.split('/').collect();
562        if parts.len() != 2 {
563            return None;
564        }
565        parts[0].trim().parse().ok()
566    }
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572
573    #[test]
574    fn test_minimal_config_deserialize() {
575        let yaml = r#"
576upstream:
577  default: "grpc://localhost:4180"
578"#;
579        let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
580        assert_eq!(config.upstream.default, "grpc://localhost:4180");
581        assert_eq!(config.listen.http, "0.0.0.0:8080");
582        assert_eq!(config.service.name, "structured-proxy");
583        assert_eq!(config.streaming.sse_keep_alive_secs, 15);
584        assert!(config.descriptors.is_empty());
585        assert!(config.auth.is_none());
586        assert!(config.shield.is_none());
587    }
588
589    #[test]
590    fn test_zero_sse_keep_alive_is_rejected() {
591        // A zero keep-alive would make axum's SSE timer fire continuously
592        // instead of acting as a periodic heartbeat — reject it at load time.
593        let yaml = r#"
594upstream:
595  default: "grpc://localhost:4180"
596streaming:
597  sse_keep_alive_secs: 0
598"#;
599        let err = ProxyConfig::from_yaml_str(yaml).unwrap_err();
600        assert!(err.to_string().contains("sse_keep_alive_secs"));
601    }
602
603    #[test]
604    fn test_full_config_deserialize() {
605        let yaml = r#"
606upstream:
607  default: "grpc://sid-identity:4180"
608
609descriptors:
610  - file: "/etc/proxy/sid.descriptor.bin"
611
612listen:
613  http: "0.0.0.0:9090"
614
615service:
616  name: "sid-proxy"
617
618aliases:
619  - from: "/oauth2/{path}"
620    to: "/v1/oauth2/{path}"
621
622auth:
623  mode: "jwt"
624  jwt:
625    issuer: "https://auth.example.com"
626    public_key_pem_file: "/etc/proxy/signing.pub"
627    claims_headers:
628      sub: "x-forwarded-user"
629      acr: "x-sid-auth-level"
630  forward_auth:
631    enabled: true
632    path: "/auth/verify"
633    policies:
634      - path: "/v1/admin/**"
635        require_auth: true
636        required_roles: ["admin"]
637      - path: "/v1/public/**"
638        require_auth: false
639  authz:
640    enabled: true
641    endpoint: "http://opa:9191"   # Envoy ext_authz server (gRPC)
642    timeout_ms: 200
643    failure_mode_allow: false      # fail closed: deny if authz is unreachable
644
645shield:
646  enabled: true
647  endpoint_classes:
648    - pattern: "/v1/auth/**"
649      class: "auth"
650      rate: "20/min"
651    - pattern: "/**"
652      class: "default"
653      rate: "100/min"
654  identifier_endpoints:
655    - path: "/v1/auth/opaque/login/start"
656      body_field: "identifier"
657      rate: "10/min"
658
659oidc_discovery:
660  enabled: true
661  issuer: "https://auth.example.com"
662
663maintenance:
664  enabled: false
665  exempt_paths:
666    - "/health/**"
667    - "/.well-known/**"
668
669cors:
670  origins:
671    - "https://app.example.com"
672
673metrics_classes:
674  - pattern: "/v1/auth/**"
675    class: "auth"
676  - pattern: "/v1/admin/**"
677    class: "admin"
678
679forwarded_headers:
680  - "authorization"
681  - "dpop"
682  - "x-request-id"
683"#;
684        let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
685        assert_eq!(config.upstream.default, "grpc://sid-identity:4180");
686        assert_eq!(config.listen.http, "0.0.0.0:9090");
687        assert_eq!(config.service.name, "sid-proxy");
688        assert_eq!(config.aliases.len(), 1);
689        assert!(config.auth.is_some());
690        let authz = config.auth.as_ref().unwrap().authz.as_ref().unwrap();
691        assert!(authz.enabled);
692        assert_eq!(authz.endpoint, "http://opa:9191");
693        assert_eq!(authz.timeout_ms, 200);
694        assert!(!authz.failure_mode_allow);
695        assert!(config.shield.is_some());
696        assert!(config.oidc_discovery.is_some());
697        assert_eq!(config.cors.origins.len(), 1);
698        assert_eq!(config.metrics_classes.len(), 2);
699        assert_eq!(config.forwarded_headers.len(), 3);
700    }
701
702    #[test]
703    fn authz_disabled_without_endpoint_parses() {
704        // A disabled authz block need not supply an endpoint.
705        let yaml = r#"
706upstream:
707  default: "grpc://localhost:4180"
708descriptors:
709  - file: "/x.bin"
710auth:
711  mode: "jwt"
712  authz:
713    enabled: false
714"#;
715        let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
716        let authz = config.auth.unwrap().authz.unwrap();
717        assert!(!authz.enabled);
718        assert_eq!(authz.endpoint, "");
719    }
720
721    #[test]
722    fn test_descriptor_source_file() {
723        let yaml = r#"
724upstream:
725  default: "grpc://localhost:4180"
726descriptors:
727  - file: "/etc/proxy/service.descriptor.bin"
728"#;
729        let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
730        assert_eq!(config.descriptors.len(), 1);
731        match &config.descriptors[0] {
732            DescriptorSource::File { file } => {
733                assert_eq!(file.to_str().unwrap(), "/etc/proxy/service.descriptor.bin");
734            }
735            _ => panic!("expected File descriptor source"),
736        }
737    }
738
739    #[test]
740    fn test_descriptor_source_reflection() {
741        let yaml = r#"
742upstream:
743  default: "grpc://localhost:4180"
744descriptors:
745  - reflection: "grpc://localhost:4180"
746"#;
747        let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
748        match &config.descriptors[0] {
749            DescriptorSource::Reflection { reflection } => {
750                assert_eq!(reflection, "grpc://localhost:4180");
751            }
752            _ => panic!("expected Reflection descriptor source"),
753        }
754    }
755
756    #[test]
757    fn test_parse_rate() {
758        assert_eq!(ProxyConfig::parse_rate("20/min"), Some(20));
759        assert_eq!(ProxyConfig::parse_rate("100/min"), Some(100));
760        assert_eq!(ProxyConfig::parse_rate("5/min"), Some(5));
761        assert_eq!(ProxyConfig::parse_rate("invalid"), None);
762    }
763
764    #[test]
765    fn test_openapi_config_deserialize() {
766        let yaml = r#"
767upstream:
768  default: "grpc://localhost:4180"
769openapi:
770  enabled: true
771  path: "/api/openapi.json"
772  docs_path: "/api/docs"
773  title: "Test API"
774  version: "2.0.0"
775"#;
776        let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
777        let openapi = config.openapi.unwrap();
778        assert!(openapi.enabled);
779        assert_eq!(openapi.path, "/api/openapi.json");
780        assert_eq!(openapi.docs_path, "/api/docs");
781        assert_eq!(openapi.title.unwrap(), "Test API");
782        assert_eq!(openapi.version.unwrap(), "2.0.0");
783    }
784
785    #[test]
786    fn test_openapi_config_defaults() {
787        let yaml = r#"
788upstream:
789  default: "grpc://localhost:4180"
790openapi:
791  enabled: true
792"#;
793        let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
794        let openapi = config.openapi.unwrap();
795        assert!(openapi.enabled);
796        assert_eq!(openapi.path, "/openapi.json");
797        assert_eq!(openapi.docs_path, "/docs");
798        assert!(openapi.title.is_none());
799        assert!(openapi.version.is_none());
800    }
801}