use serde::Deserialize;
use std::path::PathBuf;
#[derive(Debug, Clone, Deserialize)]
pub struct ProxyConfig {
#[serde(default)]
pub upstream: Option<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,
#[serde(default)]
pub concurrency: Option<ConcurrencyConfig>,
#[serde(default)]
pub grpc_web: GrpcWebConfig,
#[serde(default)]
pub transcode: TranscodeConfig,
}
impl Default for ProxyConfig {
fn default() -> Self {
Self {
upstream: None,
descriptors: Vec::new(),
listen: ListenConfig::default(),
service: ServiceConfig::default(),
aliases: Vec::new(),
openapi: None,
auth: None,
shield: None,
oidc_discovery: None,
health: HealthConfig::default(),
metrics: MetricsConfig::default(),
maintenance: MaintenanceConfig::default(),
cors: CorsConfig::default(),
logging: LoggingConfig::default(),
metrics_classes: Vec::new(),
forwarded_headers: default_forwarded_headers(),
streaming: StreamingConfig::default(),
concurrency: None,
grpc_web: GrpcWebConfig::default(),
transcode: TranscodeConfig::default(),
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TranscodeConfig {
#[serde(default)]
pub only: Vec<String>,
}
pub fn from_yaml<T: serde::de::DeserializeOwned>(yaml: &str) -> anyhow::Result<T> {
Ok(serde_yaml::from_str(yaml)?)
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GrpcWebConfig {
#[serde(default)]
pub translate: bool,
}
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, Default, Deserialize)]
pub(crate) struct TranscodeFileConfig {
#[serde(default)]
error_details: Option<ErrorDetailsFileConfig>,
#[serde(default)]
streaming: StreamingFileConfig,
#[serde(default)]
response_headers: Option<ResponseHeadersFileConfig>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ResponseHeadersFileConfig {
#[serde(default)]
deny: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ErrorDetailsFileConfig {
#[serde(default = "default_true")]
enabled: bool,
#[serde(default)]
opaque: bool,
#[serde(default)]
routes: Vec<ErrorDetailsRouteFileConfig>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ErrorDetailsRouteFileConfig {
pattern: String,
#[serde(default)]
enabled: Option<bool>,
#[serde(default)]
opaque: Option<bool>,
}
#[derive(Debug, Default, Deserialize)]
struct StreamingFileConfig {
#[serde(default)]
ndjson_envelope: bool,
}
pub(crate) const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
"upstream",
"descriptors",
"listen",
"service",
"aliases",
"openapi",
"auth",
"shield",
"oidc_discovery",
"health",
"metrics",
"maintenance",
"cors",
"logging",
"metrics_classes",
"forwarded_headers",
"streaming",
"error_details",
"response_headers",
"runtime",
"concurrency",
"grpc_web",
"transcode",
];
pub(crate) const KNOWN_STREAMING_KEYS: &[&str] = &["sse_keep_alive_secs", "ndjson_envelope"];
pub(crate) fn unknown_config_keys(yaml: &str) -> Vec<String> {
let Ok(serde_yaml::Value::Mapping(map)) = serde_yaml::from_str(yaml) else {
return Vec::new();
};
let mut unknown: Vec<String> = map
.keys()
.filter_map(|key| key.as_str())
.filter(|key| !KNOWN_TOP_LEVEL_KEYS.contains(key))
.map(str::to_owned)
.collect();
if let Some(serde_yaml::Value::Mapping(streaming)) = map.get("streaming") {
unknown.extend(
streaming
.keys()
.filter_map(|key| key.as_str())
.filter(|key| !KNOWN_STREAMING_KEYS.contains(key))
.map(|key| format!("streaming.{key}")),
);
}
unknown
}
impl TranscodeFileConfig {
pub(crate) fn options(&self) -> Result<crate::transcode::TranscodeOptions, String> {
use crate::transcode::error::ErrorDetailsPolicy;
let mut options = crate::transcode::TranscodeOptions::default()
.with_ndjson_envelope(self.streaming.ndjson_envelope);
if let Some(cfg) = &self.response_headers {
let deny = cfg
.deny
.iter()
.map(|name| {
http::HeaderName::from_bytes(name.as_bytes()).map_err(|_| {
format!("response_headers.deny entry {name:?} is not a header name")
})
})
.collect::<Result<Vec<_>, _>>()?;
options = options.with_denied_response_headers(deny);
}
if let Some(cfg) = &self.error_details {
let base = if cfg.enabled {
ErrorDetailsPolicy::default()
} else {
ErrorDetailsPolicy::disabled()
}
.with_opaque_details(cfg.opaque);
let policy = cfg.routes.iter().try_fold(base, |policy, rule| {
if rule.enabled.is_none() && rule.opaque.is_none() {
return Err(format!(
"error details route {:?} sets neither enabled nor opaque",
rule.pattern
));
}
policy.rule(&rule.pattern, rule.enabled, rule.opaque)
})?;
options = options.with_error_details(policy);
}
Ok(options)
}
}
#[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)]
#[serde(deny_unknown_fields)]
pub struct ListenConfig {
#[serde(default = "default_http_listen")]
pub http: String,
#[serde(default)]
pub max_connections: Option<usize>,
#[serde(default)]
pub tls: Option<ListenTlsConfig>,
#[serde(default = "default_idle_timeout_secs")]
pub idle_timeout_secs: u64,
#[serde(default = "default_header_read_timeout_secs")]
pub header_read_timeout_secs: u64,
#[serde(default = "default_drain_timeout_secs")]
pub drain_timeout_secs: u64,
}
fn default_drain_timeout_secs() -> u64 {
25
}
fn default_idle_timeout_secs() -> u64 {
60
}
fn default_header_read_timeout_secs() -> u64 {
30
}
fn default_tls_handshake_timeout_secs() -> u64 {
10
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ListenTlsConfig {
pub cert_file: PathBuf,
pub key_file: PathBuf,
#[serde(default)]
pub client_ca_file: Option<PathBuf>,
#[serde(default)]
pub client_auth: Option<ClientAuth>,
#[serde(default = "default_tls_handshake_timeout_secs")]
pub handshake_timeout_secs: u64,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ClientAuth {
#[default]
Required,
Optional,
}
fn default_http_listen() -> String {
"0.0.0.0:8080".into()
}
impl Default for ListenConfig {
fn default() -> Self {
Self {
http: default_http_listen(),
max_connections: None,
tls: None,
idle_timeout_secs: default_idle_timeout_secs(),
header_read_timeout_secs: default_header_read_timeout_secs(),
drain_timeout_secs: default_drain_timeout_secs(),
}
}
}
#[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>,
#[serde(default)]
pub scope: Option<ScopeConfig>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScopeConfig {
#[serde(default)]
pub traffic: Option<Vec<Traffic>>,
#[serde(default)]
pub paths: Vec<String>,
#[serde(default)]
pub methods: Vec<String>,
}
impl ScopeConfig {
pub fn traffic(traffic: impl IntoIterator<Item = Traffic>) -> Self {
Self {
traffic: Some(traffic.into_iter().collect()),
paths: Vec::new(),
methods: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Traffic {
Transcoded,
Endpoints,
Grpc,
Fallback,
All,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConcurrencyConfig {
pub max_in_flight: usize,
#[serde(default)]
pub scope: Option<ScopeConfig>,
}
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 = "default_jwks_max_age_secs")]
pub jwks_max_age_secs: u64,
#[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,
#[serde(default)]
pub cache: JwtCacheConfig,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct JwtCacheConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_jwt_cache_max_entries")]
pub max_entries: usize,
#[serde(default = "default_jwt_cache_max_ttl_secs")]
pub max_ttl_secs: u64,
#[serde(default = "default_jwt_cache_max_token_bytes")]
pub max_token_bytes: usize,
}
pub(crate) fn default_jwks_max_age_secs() -> u64 {
300
}
fn default_jwt_cache_max_entries() -> usize {
10_000
}
fn default_jwt_cache_max_ttl_secs() -> u64 {
60
}
fn default_jwt_cache_max_token_bytes() -> usize {
4096
}
impl Default for JwtCacheConfig {
fn default() -> Self {
Self {
enabled: true,
max_entries: default_jwt_cache_max_entries(),
max_ttl_secs: default_jwt_cache_max_ttl_secs(),
max_token_bytes: default_jwt_cache_max_token_bytes(),
}
}
}
pub(crate) 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,
#[serde(default)]
pub scope: Option<ScopeConfig>,
}
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>,
#[serde(default)]
pub scope: Option<ScopeConfig>,
}
#[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,
#[serde(default)]
pub scope: Option<ScopeConfig>,
}
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(),
scope: None,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct CorsConfig {
#[serde(default)]
pub origins: Vec<String>,
#[serde(default)]
pub expose_headers: Vec<String>,
#[serde(default)]
pub max_age_secs: Option<u64>,
#[serde(default = "default_true")]
pub grpc_web: bool,
}
impl Default for CorsConfig {
fn default() -> Self {
Self {
origins: Vec::new(),
expose_headers: Vec::new(),
max_age_secs: None,
grpc_web: true,
}
}
}
#[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");
}
if self.grpc_web.translate && !self.cors.grpc_web {
anyhow::bail!(
"grpc_web.translate needs cors.grpc_web: an upstream that speaks only \
gRPC cannot answer a browser's preflight for a gRPC-Web call"
);
}
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;