1use serde::Deserialize;
4use serde_json::Value;
5use std::collections::HashMap;
6
7#[derive(Debug, Clone, Deserialize)]
8pub struct Config {
9 #[serde(default = "default_addr")]
10 pub addr: String,
11 #[serde(default = "default_admin_addr")]
12 pub admin_addr: String,
13 #[serde(default = "default_metrics_addr")]
14 pub metrics_addr: String,
15 #[serde(default)]
16 pub context: ContextConfig,
17 #[serde(default)]
18 pub routes: Vec<Route>,
19}
20
21fn default_addr() -> String {
22 "0.0.0.0:8787".into()
23}
24fn default_admin_addr() -> String {
25 "127.0.0.1:8788".into()
26}
27fn default_metrics_addr() -> String {
28 "0.0.0.0:9090".into()
29}
30
31#[derive(Debug, Clone, Default, Deserialize)]
34pub struct ContextConfig {
35 #[serde(default, rename = "static")]
36 pub static_values: HashMap<String, String>,
37 #[serde(default)]
38 pub env: HashMap<String, String>,
39}
40
41#[derive(Debug, Clone, Deserialize)]
42pub struct Route {
43 #[serde(default)]
44 pub name: Option<String>,
45 pub path_prefix: String,
46 pub upstream: String,
47 #[serde(default)]
48 pub strip_prefix: bool,
49 #[serde(default)]
50 pub methods: Vec<String>,
51 #[serde(default)]
53 pub headers: HashMap<String, String>,
54 #[serde(default)]
55 pub require_context: Vec<String>,
56 #[serde(default)]
57 pub request_steps: Vec<RequestStep>,
58 #[serde(default)]
59 pub response_steps: Vec<ResponseStep>,
60}
61
62impl Route {
63 pub fn label(&self) -> &str {
65 self.name.as_deref().unwrap_or(&self.path_prefix)
66 }
67}
68
69#[derive(Debug, Clone, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum RequestStep {
74 Inject(InjectSpec),
75 Wrap(WrapSpec),
76 Transform(String),
77}
78
79#[derive(Debug, Clone, Deserialize)]
80#[serde(rename_all = "snake_case")]
81pub enum ResponseStep {
82 ErrorRemap(ErrorRemapSpec),
83 Transform(String),
84}
85
86#[derive(Debug, Clone, Deserialize)]
90pub struct InjectSpec {
91 #[serde(default)]
92 pub header: Option<String>,
93 #[serde(default)]
94 pub body: Option<String>,
95 #[serde(default)]
96 pub from_context: Option<String>,
97 #[serde(default, rename = "const")]
98 pub constant: Option<Value>,
99}
100
101#[derive(Debug, Clone, Deserialize)]
103pub struct WrapSpec {
104 pub under: String,
105 #[serde(default)]
106 pub inject: Vec<InjectSpec>,
107}
108
109#[derive(Debug, Clone, Deserialize)]
111pub struct ErrorRemapSpec {
112 pub when_status: u16,
113 pub error: String,
114 #[serde(default)]
115 pub detail: Option<String>,
116}
117
118impl Config {
119 pub fn load() -> anyhow::Result<Self> {
120 let path = std::env::var("SYNAPSE_PROXY_CONFIG_PATH")
121 .unwrap_or_else(|_| "synapse-proxy.toml".to_string());
122 let content =
123 std::fs::read_to_string(&path).map_err(|e| anyhow::anyhow!("reading {path}: {e}"))?;
124 let mut config = Self::from_toml_str(&content)?;
125 if let Ok(addr) = std::env::var("SYNAPSE_PROXY_ADDR") {
126 if !addr.trim().is_empty() {
127 config.addr = addr;
128 }
129 }
130 Ok(config)
131 }
132 pub fn from_toml_str(s: &str) -> anyhow::Result<Self> {
133 toml::from_str(s).map_err(Into::into)
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140
141 const SAMPLE: &str = r#"
142 addr = "0.0.0.0:8787"
143 admin_addr = "127.0.0.1:8788"
144 metrics_addr = "0.0.0.0:9090"
145 [context]
146 static = { tenant = "acme" }
147 env = { org = "BROKER_ORG_ID" }
148 [[routes]]
149 name = "cortex"
150 path_prefix = "/v1/cortex"
151 upstream = "http://cortex:8080"
152 strip_prefix = true
153 methods = ["POST"]
154 require_context = ["org"]
155 request_steps = [
156 { inject = { header = "X-Tenant-Id", from_context = "org" } },
157 { inject = { header = "X-User-Id", const = "_default" } },
158 ]
159 [[routes]]
160 name = "call"
161 path_prefix = "/v1/call"
162 upstream = "http://up:8080"
163 request_steps = [ { wrap = { under = "request", inject = [ { body = "org", from_context = "org" } ] } } ]
164 response_steps = [ { error_remap = { when_status = 401, error = "auth_expired" } } ]
165 "#;
166
167 #[test]
168 fn parses_listeners_context_and_steps() {
169 let c = Config::from_toml_str(SAMPLE).unwrap();
170 assert_eq!(c.admin_addr, "127.0.0.1:8788");
171 assert_eq!(c.metrics_addr, "0.0.0.0:9090");
172 assert_eq!(
173 c.context.static_values.get("tenant").map(String::as_str),
174 Some("acme")
175 );
176 assert_eq!(
177 c.context.env.get("org").map(String::as_str),
178 Some("BROKER_ORG_ID")
179 );
180 let cortex = &c.routes[0];
181 assert_eq!(cortex.label(), "cortex");
182 assert_eq!(cortex.methods, vec!["POST"]);
183 assert_eq!(cortex.require_context, vec!["org"]);
184 assert_eq!(cortex.request_steps.len(), 2);
185 assert!(matches!(cortex.request_steps[0], RequestStep::Inject(_)));
186 let call = &c.routes[1];
187 assert!(matches!(call.request_steps[0], RequestStep::Wrap(_)));
188 assert!(matches!(
189 call.response_steps[0],
190 ResponseStep::ErrorRemap(_)
191 ));
192 }
193
194 #[test]
195 fn label_falls_back_to_path_prefix() {
196 let c = Config::from_toml_str(
197 r#"[[routes]]
198 path_prefix = "/x"
199 upstream = "http://u""#,
200 )
201 .unwrap();
202 assert_eq!(c.routes[0].label(), "/x");
203 }
204}