From 2535613563b8f4aedf5a723a040e4aef83a429fc Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sat, 20 Jun 2026 13:08:18 +0300 Subject: [PATCH 1/2] fix(config): keep embedded-constructed structs constructible The previous blanket #[non_exhaustive] broke embedded consumers that build the config programmatically with struct literals (runtime upstream port, listen address, baked-in descriptors) instead of loading YAML. Scope non_exhaustive correctly: remove it from the wiring structs an embedding consumer legitimately constructs (ProxyConfig, UpstreamConfig, ListenConfig, ServiceConfig, and the DescriptorSource enum); keep it on the leaf auth/shield/oidc/etc. structs that are deserialized, not hand-built, and that actually churn. Add ProxyConfig::from_yaml_str for embedding a baked-in config without a file, and a separate-crate integration test that reproduces the embedded construction pattern so a re-added non_exhaustive is caught at compile time. Removing non_exhaustive is a relaxation (more permissive), so this is a non-breaking patch. Closes #45 --- src/config.rs | 23 +++++++++++------- tests/embedded.rs | 60 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 tests/embedded.rs diff --git a/src/config.rs b/src/config.rs index c60ab81..9f31004 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,8 +7,13 @@ use serde::Deserialize; use std::path::PathBuf; /// Top-level proxy configuration (loaded from YAML). +/// +/// This and the wiring structs below (`UpstreamConfig`, `ListenConfig`, +/// `ServiceConfig`, `DescriptorSource`) are intentionally NOT +/// `#[non_exhaustive]`: embedding consumers build them programmatically with +/// runtime values. The leaf auth/shield/oidc config structs are +/// `#[non_exhaustive]` instead, since those are deserialized, not hand-built. #[derive(Debug, Clone, Deserialize)] -#[non_exhaustive] pub struct ProxyConfig { /// Upstream gRPC service(s). pub upstream: UpstreamConfig, @@ -82,7 +87,6 @@ fn default_forwarded_headers() -> Vec { /// Upstream gRPC service configuration. #[derive(Debug, Clone, Deserialize)] -#[non_exhaustive] pub struct UpstreamConfig { /// gRPC upstream address (e.g., "http://localhost:4180"). pub default: String, @@ -90,7 +94,6 @@ pub struct UpstreamConfig { /// Descriptor loading source. #[derive(Debug, Clone)] -#[non_exhaustive] pub enum DescriptorSource { /// Pre-compiled descriptor file. File { file: PathBuf }, @@ -131,7 +134,6 @@ where /// Listen address configuration. #[derive(Debug, Clone, Deserialize)] -#[non_exhaustive] pub struct ListenConfig { /// HTTP listen address (default: "0.0.0.0:8080"). #[serde(default = "default_http_listen")] @@ -152,7 +154,6 @@ impl Default for ListenConfig { /// Service identity. #[derive(Debug, Clone, Deserialize)] -#[non_exhaustive] pub struct ServiceConfig { /// Service name (appears in /health response and metrics namespace). #[serde(default = "default_service_name")] @@ -500,9 +501,15 @@ pub struct MetricsClassConfig { impl ProxyConfig { /// Load configuration from a YAML file. pub fn from_file(path: &std::path::Path) -> anyhow::Result { - let content = std::fs::read_to_string(path)?; - let config: Self = serde_yaml::from_str(&content)?; - Ok(config) + Self::from_yaml_str(&std::fs::read_to_string(path)?) + } + + /// Parse configuration from a YAML string. + /// + /// Useful for embedding the proxy: load a baked-in config (e.g. via + /// `include_str!`) without touching the filesystem. + pub fn from_yaml_str(yaml: &str) -> anyhow::Result { + Ok(serde_yaml::from_str(yaml)?) } /// Parse rate string like "20/min" → requests per window. diff --git a/tests/embedded.rs b/tests/embedded.rs new file mode 100644 index 0000000..6e41008 --- /dev/null +++ b/tests/embedded.rs @@ -0,0 +1,60 @@ +//! Guards the embedded-construction contract. +//! +//! Downstream products embed the proxy by building a [`ProxyConfig`] +//! programmatically (runtime upstream port, listen address, baked-in +//! descriptors) rather than loading a YAML file. This test lives in a separate +//! crate, so it sees the config types exactly as an external consumer does: if +//! any wiring struct gains `#[non_exhaustive]`, this stops compiling and the +//! embedded API regression is caught here. + +use structured_proxy::config::{ + DescriptorSource, ListenConfig, ProxyConfig, ServiceConfig, UpstreamConfig, +}; +use structured_proxy::ProxyServer; + +#[test] +fn embedded_config_is_constructible() { + static DESCRIPTOR_BYTES: &[u8] = &[]; + let config = ProxyConfig { + upstream: UpstreamConfig { + default: "http://127.0.0.1:50051".into(), + }, + descriptors: vec![DescriptorSource::Embedded { + bytes: DESCRIPTOR_BYTES, + }], + listen: ListenConfig { + http: "0.0.0.0:8080".into(), + }, + service: ServiceConfig { + name: "embedded-test".into(), + }, + aliases: vec![], + openapi: None, + auth: None, + shield: None, + oidc_discovery: None, + maintenance: Default::default(), + cors: Default::default(), + logging: Default::default(), + metrics_classes: vec![], + forwarded_headers: vec!["authorization".into()], + }; + // The server accepts a programmatically-built config (the embedded path). + let _server = ProxyServer::from_config(config); +} + +#[test] +fn from_yaml_str_loads_config() { + let config = ProxyConfig::from_yaml_str( + r#" +upstream: + default: "http://127.0.0.1:50051" +descriptors: + - file: "/x.bin" +service: + name: "yaml-test" +"#, + ) + .unwrap(); + assert_eq!(config.service.name, "yaml-test"); +} From bbc1fef98201dce7ecd31e905d7038ec9931ba05 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sat, 20 Jun 2026 13:19:24 +0300 Subject: [PATCH 2/2] docs(test): clarify forwarded_headers in the embedded test A programmatic config literal bypasses serde defaults, so note that the single header is arbitrary for this constructibility test and that real embeddings must set the headers they need (or load via from_file / from_yaml_str where the default list applies). Part of #45 --- tests/embedded.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/embedded.rs b/tests/embedded.rs index 6e41008..0f88510 100644 --- a/tests/embedded.rs +++ b/tests/embedded.rs @@ -37,6 +37,10 @@ fn embedded_config_is_constructible() { cors: Default::default(), logging: Default::default(), metrics_classes: vec![], + // Arbitrary: this test only exercises that the config is constructible, + // not header forwarding. NOTE for real embeddings: a programmatic literal + // bypasses the serde defaults, so set every header you need here (or load + // the config via from_file / from_yaml_str, where the default list applies). forwarded_headers: vec!["authorization".into()], }; // The server accepts a programmatically-built config (the embedded path).