use serde::Deserialize;
use std::path::PathBuf;
#[derive(Debug, Clone, Deserialize)]
pub struct ProxyConfig {
pub upstream: UpstreamConfig,
#[serde(default, deserialize_with = "deserialize_descriptor_sources")]
pub descriptors: Vec<DescriptorSource>,
#[serde(default)]
pub listen: ListenConfig,
#[serde(default)]
pub service: ServiceConfig,
#[serde(default)]
pub aliases: Vec<AliasConfig>,
#[serde(default)]
pub openapi: Option<OpenApiConfig>,
#[serde(default)]
pub auth: Option<AuthConfig>,
#[serde(default)]
pub shield: Option<ShieldConfig>,
#[serde(default)]
pub oidc_discovery: Option<OidcDiscoveryConfig>,
#[serde(default)]
pub health: HealthConfig,
#[serde(default)]
pub metrics: MetricsConfig,
#[serde(default)]
pub maintenance: MaintenanceConfig,
#[serde(default)]
pub cors: CorsConfig,
#[serde(default)]
pub logging: LoggingConfig,
#[serde(default)]
pub metrics_classes: Vec<MetricsClassConfig>,
#[serde(default = "default_forwarded_headers")]
pub forwarded_headers: Vec<String>,
#[serde(default)]
pub streaming: StreamingConfig,
}
fn default_forwarded_headers() -> Vec<String> {
vec![
"authorization".into(),
"dpop".into(),
"x-request-id".into(),
"x-forwarded-for".into(),
"x-forwarded-proto".into(),
"x-real-ip".into(),
"accept-language".into(),
"user-agent".into(),
"idempotency-key".into(),
]
}
#[derive(Debug, Clone, Deserialize)]
pub struct StreamingConfig {
#[serde(default = "default_sse_keep_alive_secs")]
pub sse_keep_alive_secs: u64,
}
fn default_sse_keep_alive_secs() -> u64 {
15
}
impl Default for StreamingConfig {
fn default() -> Self {
Self {
sse_keep_alive_secs: default_sse_keep_alive_secs(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct UpstreamConfig {
pub default: String,
}
#[derive(Debug, Clone)]
pub enum DescriptorSource {
File { file: PathBuf },
Reflection { reflection: String },
Embedded { bytes: &'static [u8] },
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum DescriptorSourceYaml {
File { file: PathBuf },
Reflection { reflection: String },
}
impl From<DescriptorSourceYaml> for DescriptorSource {
fn from(yaml: DescriptorSourceYaml) -> Self {
match yaml {
DescriptorSourceYaml::File { file } => DescriptorSource::File { file },
DescriptorSourceYaml::Reflection { reflection } => {
DescriptorSource::Reflection { reflection }
}
}
}
}
fn deserialize_descriptor_sources<'de, D>(
deserializer: D,
) -> std::result::Result<Vec<DescriptorSource>, D::Error>
where
D: serde::Deserializer<'de>,
{
let yaml_sources: Vec<DescriptorSourceYaml> = Vec::deserialize(deserializer)?;
Ok(yaml_sources.into_iter().map(Into::into).collect())
}
#[derive(Debug, Clone, Deserialize)]
pub struct ListenConfig {
#[serde(default = "default_http_listen")]
pub http: String,
}
fn default_http_listen() -> String {
"0.0.0.0:8080".into()
}
impl Default for ListenConfig {
fn default() -> Self {
Self {
http: default_http_listen(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct ServiceConfig {
#[serde(default = "default_service_name")]
pub name: String,
}
fn default_service_name() -> String {
"structured-proxy".into()
}
impl Default for ServiceConfig {
fn default() -> Self {
Self {
name: default_service_name(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct AliasConfig {
pub from: String,
pub to: String,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct OpenApiConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_openapi_path")]
pub path: String,
#[serde(default = "default_docs_path")]
pub docs_path: String,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub version: Option<String>,
}
fn default_openapi_path() -> String {
"/openapi.json".into()
}
fn default_docs_path() -> String {
"/docs".into()
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct AuthConfig {
#[serde(default = "default_auth_mode")]
pub mode: String,
#[serde(default)]
pub jwt: Option<JwtConfig>,
#[serde(default)]
pub forward_auth: Option<ForwardAuthConfig>,
#[serde(default)]
pub authz: Option<AuthzConfig>,
}
fn default_auth_mode() -> String {
"none".into()
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct JwtConfig {
#[serde(default)]
pub jwks_uri: Option<String>,
#[serde(default)]
pub issuer: Option<String>,
#[serde(default)]
pub audience: Option<String>,
#[serde(default)]
pub public_key_pem_file: Option<PathBuf>,
#[serde(default)]
pub claims_headers: std::collections::HashMap<String, String>,
#[serde(default = "default_roles_claim")]
pub roles_claim: String,
}
fn default_roles_claim() -> String {
"roles".into()
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct ForwardAuthConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_forward_auth_path")]
pub path: String,
#[serde(default)]
pub policies: Vec<RoutePolicyConfig>,
#[serde(default)]
pub login_url: Option<String>,
#[serde(default)]
pub applications_path: Option<PathBuf>,
}
fn default_forward_auth_path() -> String {
"/auth/verify".into()
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct RoutePolicyConfig {
pub path: String,
#[serde(default = "default_methods_all")]
pub methods: Vec<String>,
#[serde(default)]
pub require_auth: bool,
#[serde(default)]
pub required_roles: Vec<String>,
}
fn default_methods_all() -> Vec<String> {
vec!["*".into()]
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct AuthzConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub endpoint: String,
#[serde(default = "default_authz_timeout_ms")]
pub timeout_ms: u64,
#[serde(default)]
pub failure_mode_allow: bool,
}
fn default_authz_timeout_ms() -> u64 {
200
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct ShieldConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub profiles: std::collections::HashMap<String, LimitProfileConfig>,
#[serde(default)]
pub rules: Vec<RateRuleConfig>,
#[serde(default)]
pub default_profile: Option<String>,
#[serde(default)]
pub jwt_limits: Option<JwtLimitConfig>,
#[serde(default)]
pub limit_service: Option<LimitServiceConfig>,
#[serde(default)]
pub sync: Option<SyncConfig>,
#[serde(default)]
pub trusted_proxies: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct LimitProfileConfig {
pub rate: String,
#[serde(default)]
pub burst: Option<u64>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct RateRuleConfig {
pub pattern: String,
#[serde(default)]
pub key: KeySourceConfig,
#[serde(default)]
pub profile: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum KeySourceConfig {
#[default]
Ip,
Header {
name: String,
},
JwtClaim {
claim: String,
},
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
struct KeySourceRaw {
#[serde(rename = "type")]
kind: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
claim: Option<String>,
}
impl<'de> Deserialize<'de> for KeySourceConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
let raw = KeySourceRaw::deserialize(deserializer)?;
match raw.kind.as_str() {
"ip" => {
if raw.name.is_some() || raw.claim.is_some() {
return Err(D::Error::custom("key type 'ip' takes no other fields"));
}
Ok(Self::Ip)
}
"header" => {
if raw.claim.is_some() {
return Err(D::Error::custom(
"key type 'header' takes 'name', not 'claim'",
));
}
let name = raw.name.ok_or_else(|| D::Error::missing_field("name"))?;
Ok(Self::Header { name })
}
"jwt_claim" => {
if raw.name.is_some() {
return Err(D::Error::custom(
"key type 'jwt_claim' takes 'claim', not 'name'",
));
}
let claim = raw.claim.ok_or_else(|| D::Error::missing_field("claim"))?;
Ok(Self::JwtClaim { claim })
}
other => Err(D::Error::unknown_variant(
other,
&["ip", "header", "jwt_claim"],
)),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct JwtLimitConfig {
#[serde(default = "default_tier_claim")]
pub tier_claim: String,
#[serde(default = "default_rpm_claim")]
pub rpm_claim: String,
#[serde(default = "default_burst_claim")]
pub burst_claim: String,
}
fn default_tier_claim() -> String {
"ratelimit_tier".to_string()
}
fn default_rpm_claim() -> String {
"ratelimit_rpm".to_string()
}
fn default_burst_claim() -> String {
"ratelimit_burst".to_string()
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct LimitServiceConfig {
pub endpoint: String,
#[serde(default = "default_limit_ttl_secs")]
pub ttl_secs: u64,
#[serde(default = "default_limit_timeout_ms")]
pub timeout_ms: u64,
}
fn default_limit_ttl_secs() -> u64 {
300
}
fn default_limit_timeout_ms() -> u64 {
500
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct SyncConfig {
pub redis_url: String,
#[serde(default = "default_sync_interval_ms")]
pub interval_ms: u64,
}
fn default_sync_interval_ms() -> u64 {
500
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct OidcDiscoveryConfig {
#[serde(default)]
pub enabled: bool,
pub issuer: String,
#[serde(default)]
pub authorization_endpoint: Option<String>,
#[serde(default)]
pub token_endpoint: Option<String>,
#[serde(default)]
pub userinfo_endpoint: Option<String>,
#[serde(default)]
pub jwks_uri: Option<String>,
#[serde(default)]
pub signing_key: Option<SigningKeyConfig>,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct SigningKeyConfig {
#[serde(default = "default_algorithm")]
pub algorithm: String,
pub public_key_pem_file: PathBuf,
}
fn default_algorithm() -> String {
"EdDSA".into()
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct HealthConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_health_path")]
pub path: String,
#[serde(default = "default_health_live_path")]
pub live_path: String,
#[serde(default = "default_health_ready_path")]
pub ready_path: String,
#[serde(default = "default_health_startup_path")]
pub startup_path: String,
}
fn default_health_path() -> String {
"/health".into()
}
fn default_health_live_path() -> String {
"/health/live".into()
}
fn default_health_ready_path() -> String {
"/health/ready".into()
}
fn default_health_startup_path() -> String {
"/health/startup".into()
}
impl Default for HealthConfig {
fn default() -> Self {
Self {
enabled: true,
path: default_health_path(),
live_path: default_health_live_path(),
ready_path: default_health_ready_path(),
startup_path: default_health_startup_path(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct MetricsConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_metrics_path")]
pub path: String,
}
fn default_metrics_path() -> String {
"/metrics".into()
}
impl Default for MetricsConfig {
fn default() -> Self {
Self {
enabled: true,
path: default_metrics_path(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct MaintenanceConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_exempt_paths")]
pub exempt_paths: Vec<String>,
#[serde(default = "default_maintenance_message")]
pub message: String,
}
fn default_exempt_paths() -> Vec<String> {
vec![
"/health/**".into(),
"/.well-known/**".into(),
"/metrics".into(),
"/auth/verify".into(),
]
}
fn default_maintenance_message() -> String {
"Service is under maintenance. Please try again later.".into()
}
impl Default for MaintenanceConfig {
fn default() -> Self {
Self {
enabled: false,
exempt_paths: default_exempt_paths(),
message: default_maintenance_message(),
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[non_exhaustive]
pub struct CorsConfig {
#[serde(default)]
pub origins: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct LoggingConfig {
#[serde(default = "default_log_level")]
pub level: String,
#[serde(default = "default_log_format")]
pub format: String,
}
fn default_log_level() -> String {
"info".into()
}
fn default_log_format() -> String {
"json".into()
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
level: default_log_level(),
format: default_log_format(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct MetricsClassConfig {
pub pattern: String,
pub class: String,
}
impl ProxyConfig {
pub fn from_file(path: &std::path::Path) -> anyhow::Result<Self> {
Self::from_yaml_str(&std::fs::read_to_string(path)?)
}
pub fn from_yaml_str(yaml: &str) -> anyhow::Result<Self> {
let config: Self = serde_yaml::from_str(yaml)?;
config.validate()?;
Ok(config)
}
pub fn validate(&self) -> anyhow::Result<()> {
if self.streaming.sse_keep_alive_secs == 0 {
anyhow::bail!("streaming.sse_keep_alive_secs must be greater than 0");
}
self.validate_edge_paths()?;
Ok(())
}
fn validate_edge_paths(&self) -> anyhow::Result<()> {
let mut seen = std::collections::HashSet::new();
let mut check = |label: &str, path: &str| -> anyhow::Result<()> {
if !path.starts_with('/') {
anyhow::bail!("endpoint path {path:?} ({label}) must start with '/'");
}
if !seen.insert(path.to_string()) {
anyhow::bail!("duplicate endpoint path {path:?} ({label}); each built-in endpoint must have a distinct path");
}
Ok(())
};
if self.health.enabled {
check("health.path", &self.health.path)?;
check("health.live_path", &self.health.live_path)?;
check("health.ready_path", &self.health.ready_path)?;
check("health.startup_path", &self.health.startup_path)?;
}
if self.metrics.enabled {
check("metrics.path", &self.metrics.path)?;
}
if let Some(openapi) = self.openapi.as_ref().filter(|o| o.enabled) {
check("openapi.path", &openapi.path)?;
check("openapi.docs_path", &openapi.docs_path)?;
}
Ok(())
}
pub fn parse_rate(rate: &str) -> Option<u32> {
let parts: Vec<&str> = rate.split('/').collect();
if parts.len() != 2 {
return None;
}
parts[0].trim().parse().ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_minimal_config_deserialize() {
let yaml = r#"
upstream:
default: "grpc://localhost:4180"
"#;
let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.upstream.default, "grpc://localhost:4180");
assert_eq!(config.listen.http, "0.0.0.0:8080");
assert_eq!(config.service.name, "structured-proxy");
assert_eq!(config.streaming.sse_keep_alive_secs, 15);
assert!(config.descriptors.is_empty());
assert!(config.auth.is_none());
assert!(config.shield.is_none());
}
#[test]
fn health_and_metrics_defaults_and_overrides() {
let min: ProxyConfig =
serde_yaml::from_str("upstream:\n default: \"grpc://x:1\"\n").unwrap();
assert!(min.health.enabled);
assert_eq!(min.health.path, "/health");
assert_eq!(min.health.ready_path, "/health/ready");
assert!(min.metrics.enabled);
assert_eq!(min.metrics.path, "/metrics");
let yaml = r#"
upstream:
default: "grpc://x:1"
health:
path: "/internal/health"
metrics:
enabled: false
path: "/internal/metrics"
"#;
let cfg: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(cfg.health.path, "/internal/health");
assert_eq!(cfg.health.live_path, "/health/live");
assert!(!cfg.metrics.enabled);
assert_eq!(cfg.metrics.path, "/internal/metrics");
}
#[test]
fn duplicate_probe_paths_are_rejected() {
let yaml = r#"
upstream:
default: "grpc://x:1"
health:
path: "/health/live"
"#;
let err = ProxyConfig::from_yaml_str(yaml).unwrap_err();
assert!(err.to_string().contains("duplicate endpoint path"));
let yaml2 = r#"
upstream:
default: "grpc://x:1"
metrics:
path: "/health"
"#;
let err2 = ProxyConfig::from_yaml_str(yaml2).unwrap_err();
assert!(err2.to_string().contains("duplicate endpoint path"));
let yaml3 = r#"
upstream:
default: "grpc://x:1"
health:
enabled: false
path: "/metrics"
"#;
assert!(ProxyConfig::from_yaml_str(yaml3).is_ok());
}
#[test]
fn malformed_edge_path_is_rejected() {
let yaml = r#"
upstream:
default: "grpc://x:1"
health:
path: "health"
"#;
let err = ProxyConfig::from_yaml_str(yaml).unwrap_err();
assert!(err.to_string().contains("must start with '/'"));
}
#[test]
fn test_zero_sse_keep_alive_is_rejected() {
let yaml = r#"
upstream:
default: "grpc://localhost:4180"
streaming:
sse_keep_alive_secs: 0
"#;
let err = ProxyConfig::from_yaml_str(yaml).unwrap_err();
assert!(err.to_string().contains("sse_keep_alive_secs"));
}
#[test]
fn test_full_config_deserialize() {
let yaml = r#"
upstream:
default: "grpc://sid-identity:4180"
descriptors:
- file: "/etc/proxy/sid.descriptor.bin"
listen:
http: "0.0.0.0:9090"
service:
name: "sid-proxy"
aliases:
- from: "/oauth2/{path}"
to: "/v1/oauth2/{path}"
auth:
mode: "jwt"
jwt:
issuer: "https://auth.example.com"
public_key_pem_file: "/etc/proxy/signing.pub"
claims_headers:
sub: "x-forwarded-user"
acr: "x-sid-auth-level"
forward_auth:
enabled: true
path: "/auth/verify"
policies:
- path: "/v1/admin/**"
require_auth: true
required_roles: ["admin"]
- path: "/v1/public/**"
require_auth: false
authz:
enabled: true
endpoint: "http://opa:9191" # Envoy ext_authz server (gRPC)
timeout_ms: 200
failure_mode_allow: false # fail closed: deny if authz is unreachable
shield:
enabled: true
profiles:
auth: { rate: "20/min", burst: 5 }
default: { rate: "100/min" }
premium: { rate: "1000/min", burst: 50 }
default_profile: "default"
jwt_limits:
tier_claim: "ratelimit_tier"
rules:
- pattern: "/v1/auth/**"
key: { type: ip }
profile: "auth"
- pattern: "/v1/**"
key: { type: jwt_claim, claim: "sub" }
trusted_proxies: ["10.0.0.0/8"]
oidc_discovery:
enabled: true
issuer: "https://auth.example.com"
maintenance:
enabled: false
exempt_paths:
- "/health/**"
- "/.well-known/**"
cors:
origins:
- "https://app.example.com"
metrics_classes:
- pattern: "/v1/auth/**"
class: "auth"
- pattern: "/v1/admin/**"
class: "admin"
forwarded_headers:
- "authorization"
- "dpop"
- "x-request-id"
"#;
let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.upstream.default, "grpc://sid-identity:4180");
assert_eq!(config.listen.http, "0.0.0.0:9090");
assert_eq!(config.service.name, "sid-proxy");
assert_eq!(config.aliases.len(), 1);
assert!(config.auth.is_some());
let authz = config.auth.as_ref().unwrap().authz.as_ref().unwrap();
assert!(authz.enabled);
assert_eq!(authz.endpoint, "http://opa:9191");
assert_eq!(authz.timeout_ms, 200);
assert!(!authz.failure_mode_allow);
assert!(config.shield.is_some());
assert!(config.oidc_discovery.is_some());
assert_eq!(config.cors.origins.len(), 1);
assert_eq!(config.metrics_classes.len(), 2);
assert_eq!(config.forwarded_headers.len(), 3);
}
#[test]
fn authz_disabled_without_endpoint_parses() {
let yaml = r#"
upstream:
default: "grpc://localhost:4180"
descriptors:
- file: "/x.bin"
auth:
mode: "jwt"
authz:
enabled: false
"#;
let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
let authz = config.auth.unwrap().authz.unwrap();
assert!(!authz.enabled);
assert_eq!(authz.endpoint, "");
}
#[test]
fn test_descriptor_source_file() {
let yaml = r#"
upstream:
default: "grpc://localhost:4180"
descriptors:
- file: "/etc/proxy/service.descriptor.bin"
"#;
let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.descriptors.len(), 1);
match &config.descriptors[0] {
DescriptorSource::File { file } => {
assert_eq!(file.to_str().unwrap(), "/etc/proxy/service.descriptor.bin");
}
_ => panic!("expected File descriptor source"),
}
}
#[test]
fn test_descriptor_source_reflection() {
let yaml = r#"
upstream:
default: "grpc://localhost:4180"
descriptors:
- reflection: "grpc://localhost:4180"
"#;
let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
match &config.descriptors[0] {
DescriptorSource::Reflection { reflection } => {
assert_eq!(reflection, "grpc://localhost:4180");
}
_ => panic!("expected Reflection descriptor source"),
}
}
#[test]
fn test_parse_rate() {
assert_eq!(ProxyConfig::parse_rate("20/min"), Some(20));
assert_eq!(ProxyConfig::parse_rate("100/min"), Some(100));
assert_eq!(ProxyConfig::parse_rate("5/min"), Some(5));
assert_eq!(ProxyConfig::parse_rate("invalid"), None);
}
#[test]
fn shield_rejects_unknown_field() {
let yaml = r#"
upstream:
default: "grpc://localhost:4180"
shield:
enabled: true
profiles:
auth: { rate: "20/min", burst: 5 }
rules:
- pattern: "/v1/**"
key: { type: ip }
profil: "auth"
"#;
let err = serde_yaml::from_str::<ProxyConfig>(yaml);
assert!(err.is_err(), "unknown shield field must be rejected");
}
#[test]
fn shield_rejects_unknown_field_in_rule_key() {
let yaml = r#"
upstream:
default: "grpc://localhost:4180"
shield:
enabled: true
profiles:
auth: { rate: "20/min", burst: 5 }
rules:
- pattern: "/v1/**"
key: { type: ip, name: x-api-key }
profile: "auth"
"#;
let err = serde_yaml::from_str::<ProxyConfig>(yaml);
assert!(err.is_err(), "unknown field in a rule key must be rejected");
}
#[test]
fn test_openapi_config_deserialize() {
let yaml = r#"
upstream:
default: "grpc://localhost:4180"
openapi:
enabled: true
path: "/api/openapi.json"
docs_path: "/api/docs"
title: "Test API"
version: "2.0.0"
"#;
let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
let openapi = config.openapi.unwrap();
assert!(openapi.enabled);
assert_eq!(openapi.path, "/api/openapi.json");
assert_eq!(openapi.docs_path, "/api/docs");
assert_eq!(openapi.title.unwrap(), "Test API");
assert_eq!(openapi.version.unwrap(), "2.0.0");
}
#[test]
fn test_openapi_config_defaults() {
let yaml = r#"
upstream:
default: "grpc://localhost:4180"
openapi:
enabled: true
"#;
let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
let openapi = config.openapi.unwrap();
assert!(openapi.enabled);
assert_eq!(openapi.path, "/openapi.json");
assert_eq!(openapi.docs_path, "/docs");
assert!(openapi.title.is_none());
assert!(openapi.version.is_none());
}
}