diff --git a/crates/common/src/settings.rs b/crates/common/src/settings.rs index 6b2d4ca8..5963b602 100644 --- a/crates/common/src/settings.rs +++ b/crates/common/src/settings.rs @@ -310,7 +310,7 @@ pub struct Settings { #[serde(default, deserialize_with = "vec_from_seq_or_map")] #[validate(nested)] pub handlers: Vec, - #[serde(default)] + #[serde(default, deserialize_with = "map_from_obj_or_str")] pub response_headers: HashMap, pub request_signing: Option, #[serde(default)] @@ -423,6 +423,48 @@ fn validate_path(value: &str) -> Result<(), ValidationError> { // This lets env vars like TRUSTED_SERVER__INTEGRATIONS__PREBID__BIDDERS__0=smartadserver work, which the config env source // represents as an object {"0": "value"} rather than a sequence. Also supports string inputs that are // JSON arrays or comma-separated values. +/// Deserializes a `HashMap` from either: +/// - A TOML table / JSON object (standard deserialization) +/// - A JSON string (e.g. from env var: `'{"Key": "value"}'`) +/// +/// This allows setting map fields via environment variables while +/// preserving key casing and special characters like hyphens. +pub(crate) fn map_from_obj_or_str<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let v = JsonValue::deserialize(deserializer)?; + match v { + JsonValue::Object(map) => map + .into_iter() + .map(|(k, v)| { + let val = match v { + JsonValue::String(s) => s, + other => other.to_string(), + }; + Ok((k, val)) + }) + .collect(), + JsonValue::String(s) => { + let txt = s.trim(); + if txt.starts_with('{') { + serde_json::from_str::>(txt) + .map_err(serde::de::Error::custom) + } else { + Err(serde::de::Error::custom( + "expected JSON object string, e.g. '{\"Key\": \"value\"}'", + )) + } + } + JsonValue::Null => Ok(HashMap::new()), + other => Err(serde::de::Error::custom(format!( + "expected object or JSON string, got {other}", + ))), + } +} + pub(crate) fn vec_from_seq_or_map<'de, D, T>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, @@ -792,6 +834,33 @@ mod tests { ); } + #[test] + fn test_response_headers_override_with_json_env() { + let toml_str = crate_test_settings_str(); + let env_key = format!( + "{}{}RESPONSE_HEADERS", + ENVIRONMENT_VARIABLE_PREFIX, ENVIRONMENT_VARIABLE_SEPARATOR, + ); + + temp_env::with_var( + env_key, + Some(r#"{"X-Robots-Tag": "noindex", "X-Custom-Header": "custom value"}"#), + || { + let settings = Settings::from_toml(&toml_str) + .expect("Settings should parse with JSON response_headers env"); + assert_eq!(settings.response_headers.len(), 2); + assert_eq!( + settings.response_headers.get("X-Robots-Tag"), + Some(&"noindex".to_string()) + ); + assert_eq!( + settings.response_headers.get("X-Custom-Header"), + Some(&"custom value".to_string()) + ); + }, + ); + } + #[test] fn test_settings_extra_fields() { let toml_str = crate_test_settings_str() + "\nhello = 1"; diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index d308e778..d193e856 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -434,10 +434,16 @@ Cache-Control = "public, max-age=3600" **Environment Override**: +Use a JSON object to preserve header name casing and hyphens: + ```bash -TRUSTED_SERVER__RESPONSE_HEADERS__X_CUSTOM_HEADER="custom value" +TRUSTED_SERVER__RESPONSE_HEADERS='{"X-Robots-Tag": "noindex", "X-Custom-Header": "custom value"}' ``` +::: tip Why JSON? +Individual env var keys like `TRUSTED_SERVER__RESPONSE_HEADERS__X_CUSTOM_HEADER` lose hyphens and casing (becoming `x_custom_header`). The JSON format preserves exact header names. +::: + **Use Cases**: - Custom tracking headers diff --git a/trusted-server.toml b/trusted-server.toml index cdad1f50..5ad18888 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -25,6 +25,9 @@ template = "{{ client_ip }}:{{ user_agent }}:{{ accept_language }}:{{ accept_enc # Allows publishers to include tags such as X-Robots-Tag: noindex # [response_headers] # X-Custom-Header = "custom header value" +# +# Or via environment variable (JSON preserves header name casing and hyphens): +# TRUSTED_SERVER__RESPONSE_HEADERS='{"X-Robots-Tag": "noindex", "X-Custom-Header": "custom value"}' # Request Signing Configuration # Enable signing of OpenRTB requests and other API calls @@ -38,10 +41,9 @@ enabled = true server_url = "http://68.183.113.79:8000" timeout_ms = 1000 bidders = ["kargo", "rubicon", "appnexus", "openx"] -auto_configure = false debug = false # debug_query_params = "" -# script_handler = "/prebid.js" +# script_patterns = ["/prebid.js"] [integrations.nextjs] enabled = false