Skip to main content

synapse_proxy/
config.rs

1//! Proxy config: listeners, context sources, and routes with transform steps.
2
3use 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/// Context sources. `static_values` are TOML literals; `env` maps a context key
32/// to the env var it is read from at startup.
33#[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    /// Cycle-1 sugar: static headers injected on every forwarded request.
52    #[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    /// Metrics/label identifier: explicit `name`, else `path_prefix`.
64    pub fn label(&self) -> &str {
65        self.name.as_deref().unwrap_or(&self.path_prefix)
66    }
67}
68
69/// A request-pipeline step: a built-in (externally tagged by key) or a named
70/// custom transform.
71#[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/// Inject a context value or constant into a header or a (dotted) body path.
87/// Exactly one of `header`/`body` and one of `from_context`/`const` must be set;
88/// validated when the transform is built (Task 4).
89#[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/// Nest the incoming JSON body under `under` and inject sibling body fields.
102#[derive(Debug, Clone, Deserialize)]
103pub struct WrapSpec {
104    pub under: String,
105    #[serde(default)]
106    pub inject: Vec<InjectSpec>,
107}
108
109/// Map an upstream status to a normalized `{error, detail}` body (status kept).
110#[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}