Skip to main content

featherbit/config/
gateway.rs

1//! Schema for `gateway.yaml`: routes (match rules bound to a policy) and
2//! node-graph policies (nodes plus success/error edges). This file is the
3//! hot-reloadable half of the configuration and is also mutated at runtime
4//! by the Admin API.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// Root of `gateway.yaml`: the route table and the policies it references.
10///
11/// Both sections default to empty, so a missing or minimal file is valid.
12///
13/// ```yaml
14/// routes:
15///   - name: api
16///     match: { path: /api/* }
17///     policy: api-policy
18/// policies:
19///   - name: api-policy
20///     nodes:
21///       - { id: in, type: listener }
22///       - { id: up, type: upstream, config: { url: "http://backend:3000" } }
23///     edges:
24///       - { from: in.out, to: up.in }
25/// ```
26#[derive(Debug, Deserialize, Serialize, Clone)]
27pub struct GatewayConfig {
28    /// Routes evaluated in declaration order; the first match wins.
29    #[serde(default)]
30    pub routes: Vec<RouteConfig>,
31    /// Policies referenced by name from `routes`.
32    #[serde(default)]
33    pub policies: Vec<PolicyConfig>,
34    /// Named API clients with per-auth-plugin credentials; resolved by auth
35    /// nodes configured with `use_consumers: true`.
36    #[serde(default)]
37    pub consumers: Vec<crate::consumers::ConsumerConfig>,
38    /// Reusable named subgraphs, referenced from policies by nodes of
39    /// `type: supernode`; inlined at compile time (see src/graph/expand.rs).
40    #[serde(default)]
41    pub supernodes: Vec<SupernodeConfig>,
42    /// Named, typed plugin configurations shared by any number of plugin
43    /// nodes via `config_ref`; resolved at compile time (src/config/resolve.rs).
44    #[serde(default)]
45    pub plugin_configs: Vec<PluginConfigDef>,
46    /// Named shared stores (redis/valkey connections) referenced by plugin
47    /// config (`store: <name>`); clients are built at config-apply time
48    /// (src/stores/), so `${ENV_VAR}` placeholders never leave this struct
49    /// resolved.
50    #[serde(default)]
51    pub stores: Vec<StoreConfig>,
52}
53
54/// Binds a request match rule to a named policy.
55#[derive(Debug, Deserialize, Serialize, Clone)]
56pub struct RouteConfig {
57    /// Unique route name, used in logs, metrics labels, and the Admin API.
58    pub name: String,
59    /// Conditions a request must satisfy (YAML key: `match`).
60    #[serde(rename = "match")]
61    pub match_rule: MatchRule,
62    /// Name of the [`PolicyConfig`] to execute; must exist or compilation fails.
63    pub policy: String,
64}
65
66/// Request-matching conditions for a route.
67///
68/// All specified criteria must match (logical AND); every field defaults to
69/// unset/empty, which matches any request.
70#[derive(Debug, Deserialize, Serialize, Clone)]
71pub struct MatchRule {
72    /// Path pattern to match (e.g. `/api/*`); `None` matches any path.
73    #[serde(default)]
74    pub path: Option<String>,
75    /// Allowed HTTP methods; empty means any method.
76    #[serde(default)]
77    pub methods: Vec<String>,
78    /// Required header name → value pairs; empty means no header constraints.
79    #[serde(default)]
80    pub headers: HashMap<String, String>,
81    /// Required `Host` value; `None` matches any host.
82    #[serde(default)]
83    pub host: Option<String>,
84}
85
86impl MatchRule {
87    /// Resolves `${ENV_VAR:-default}` placeholders in every matchable field.
88    ///
89    /// Called on the route-table copy when routes are compiled; the stored
90    /// config keeps the placeholder form (gateway config is loaded raw so
91    /// the Admin API never serves resolved values).
92    pub fn interpolate_env(&mut self) {
93        let resolve = |s: &mut String| {
94            if s.contains("${") {
95                *s = super::loader::interpolate_env(s);
96            }
97        };
98        if let Some(path) = &mut self.path {
99            resolve(path);
100        }
101        if let Some(host) = &mut self.host {
102            resolve(host);
103        }
104        for method in &mut self.methods {
105            resolve(method);
106        }
107        for value in self.headers.values_mut() {
108            resolve(value);
109        }
110    }
111}
112
113/// A node-graph policy: a named pipeline of plugin nodes wired by edges.
114///
115/// Compiled into a `CompiledGraph` at load/reload time; execution starts at
116/// the `listener` node and follows each node's `success`/`error` ports.
117#[derive(Debug, Deserialize, Serialize, Clone)]
118pub struct PolicyConfig {
119    /// Unique policy name, referenced by [`RouteConfig::policy`].
120    pub name: String,
121    /// Optional id of a node to jump to when a node errors without an explicit error edge.
122    #[serde(default)]
123    pub error_handler: Option<String>,
124    /// Plugin nodes making up the pipeline.
125    #[serde(default)]
126    pub nodes: Vec<NodeConfig>,
127    /// Directed connections between node ports.
128    #[serde(default)]
129    pub edges: Vec<EdgeConfig>,
130}
131
132/// One plugin node in a policy graph.
133#[derive(Debug, Deserialize, Serialize, Clone)]
134pub struct NodeConfig {
135    /// Unique node id within the policy, referenced by edges as `id.port`.
136    pub id: String,
137    /// Plugin type (YAML key: `type`), e.g. `upstream`, `key-auth`, `script`;
138    /// must be one of the registered plugin types.
139    #[serde(rename = "type")]
140    pub node_type: String,
141    /// Free-form plugin-specific configuration; defaults to empty.
142    #[serde(default)]
143    pub config: HashMap<String, serde_json::Value>,
144    /// Optional name of a shared [`PluginConfigDef`] to inherit configuration
145    /// from. The effective config is the shared config with this node's own
146    /// `config` keys layered on top (shallow merge, local wins), materialized
147    /// at compile time — the stored form always keeps the reference.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub config_ref: Option<String>,
150    /// Canvas coordinates for the Web UI node editor; ignored by the engine
151    /// and omitted from serialized output when unset.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub position: Option<Position>,
154}
155
156/// 2D canvas coordinates of a node in the Web UI graph editor.
157#[derive(Debug, Deserialize, Serialize, Clone)]
158pub struct Position {
159    pub x: f64,
160    pub y: f64,
161}
162
163/// A directed edge between two node ports, written as `node_id.port`.
164///
165/// Ports: `out`, `success`, `error` on the source side; `in` on the target
166/// side (e.g. `from: auth.success`, `to: upstream.in`).
167#[derive(Debug, Deserialize, Serialize, Clone)]
168pub struct EdgeConfig {
169    /// Source endpoint, `node_id.port`.
170    pub from: String,
171    /// Target endpoint, `node_id.port`.
172    pub to: String,
173}
174
175/// A reusable named subgraph with a boundary of one `input`, one or more
176/// `output`, and one or more `error` pseudo-nodes; each output/error node's
177/// id is an instance port name (`output` = the `success` port; `error` =
178/// the default error port) (declared in `nodes` like a policy declares
179/// `listener`/`client`, so the UI can persist positions).
180///
181/// Instances appear in policies as nodes of `type: supernode` with
182/// `config: { name: <this name> }` and are inlined at compile time —
183/// stored configuration always keeps the compact reference form.
184#[derive(Debug, Deserialize, Serialize, Clone)]
185pub struct SupernodeConfig {
186    /// Unique supernode name, referenced from policy nodes' `config.name`.
187    pub name: String,
188    /// Optional human-readable description (shown in the UI library).
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub description: Option<String>,
191    /// Inner plugin nodes plus the boundary pseudo-nodes: one `input`, one
192    /// or more `output`, and one or more `error` pseudo-nodes; each
193    /// output/error node's id is an instance port name (`output` = the
194    /// `success` port; `error` = the default error port).
195    #[serde(default)]
196    pub nodes: Vec<NodeConfig>,
197    /// Directed connections; boundary edges use `input.out`, `output.in`,
198    /// `error.in` endpoints.
199    #[serde(default)]
200    pub edges: Vec<EdgeConfig>,
201}
202
203/// A named, shared plugin configuration, referenced by plugin nodes of the
204/// matching type via [`NodeConfig::config_ref`]. Editing a shared config
205/// re-resolves and recompiles every referencing policy atomically.
206#[derive(Debug, Deserialize, Serialize, Clone)]
207pub struct PluginConfigDef {
208    /// Unique name, referenced by `config_ref`.
209    pub name: String,
210    /// Plugin type this config is for (YAML key: `type`); only nodes of the
211    /// same type may reference it.
212    #[serde(rename = "type")]
213    pub plugin_type: String,
214    /// Optional human-readable description (shown in the UI library).
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub description: Option<String>,
217    /// The shared plugin configuration; same shape as [`NodeConfig::config`].
218    #[serde(default)]
219    pub config: HashMap<String, serde_json::Value>,
220}
221
222/// A named shared store: a redis/valkey connection referenced by name from
223/// plugin config. Declared under top-level `stores:`. `url` and `password`
224/// support `${ENV_VAR:-default}` placeholders, resolved only when the client
225/// is built — the stored config (and everything the Admin API serves) keeps
226/// the raw placeholder.
227#[derive(Debug, Deserialize, Serialize, Clone)]
228pub struct StoreConfig {
229    /// Unique name, referenced by plugin config (`store: <name>`).
230    pub name: String,
231    /// Backend type (YAML key: `type`): `redis` or `valkey` — aliases for the
232    /// same RESP backend.
233    #[serde(rename = "type")]
234    pub store_type: String,
235    /// Optional human-readable description (shown in the UI library).
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub description: Option<String>,
238    /// Connection URL (`redis://` or `rediss://`).
239    pub url: String,
240    /// Optional password; overrides any password embedded in `url`.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub password: Option<String>,
243    /// Namespace prefix for every key this store writes.
244    #[serde(default = "default_store_key_prefix")]
245    pub key_prefix: String,
246    /// Reserved for HA topologies (`sentinel`/`cluster`); v1 accepts only
247    /// `standalone` (the default) and rejects anything else at config load.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub topology: Option<String>,
250    /// Reserved for HA topologies (sentinel/cluster endpoint lists); rejected
251    /// at config load in v1.
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub urls: Option<Vec<String>>,
254    /// Connect/response timeout applied to the client and to `ping`. Bounds a
255    /// single attempt; see `connect_budget_ms` for the total.
256    #[serde(default = "default_store_connect_timeout_ms")]
257    pub connect_timeout_ms: u64,
258    /// Total budget for establishing the first connection, covering every
259    /// retry and the backoff between them.
260    ///
261    /// `connect_timeout_ms` bounds one attempt only, so without this the
262    /// connection manager's retry schedule decides how long a request waits
263    /// on an unreachable store -- tens of seconds with the crate defaults, on
264    /// the first request after an outage begins. A failed connect is not
265    /// cached, so the next request tries again.
266    #[serde(default = "default_store_connect_budget_ms")]
267    pub connect_budget_ms: u64,
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub tls: Option<StoreTlsConfig>,
270}
271
272fn default_store_key_prefix() -> String {
273    "fb".to_string()
274}
275
276fn default_store_connect_timeout_ms() -> u64 {
277    2000
278}
279
280fn default_store_connect_budget_ms() -> u64 {
281    5000
282}
283
284/// TLS options for a `rediss://` store.
285#[derive(Debug, Deserialize, Serialize, Clone)]
286pub struct StoreTlsConfig {
287    /// PEM CA bundle for a private CA.
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub ca_cert_path: Option<String>,
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    /// Old configs without a `supernodes:` section must stay valid, and a
297    /// definition must round-trip through YAML unchanged.
298    #[test]
299    fn test_supernodes_default_empty_and_roundtrip() {
300        let gw: GatewayConfig = serde_yaml::from_str("{}").unwrap();
301        assert!(gw.supernodes.is_empty());
302
303        let yaml = r#"
304supernodes:
305  - name: secured-call
306    description: "auth + upstream"
307    nodes:
308      - { id: input,  type: input }
309      - { id: output, type: output }
310      - { id: error,  type: error }
311      - { id: up, type: upstream, config: { url: "http://svc" } }
312    edges:
313      - { from: input.out,  to: up.in }
314      - { from: up.success, to: output.in }
315      - { from: up.error,   to: error.in }
316"#;
317        let gw: GatewayConfig = serde_yaml::from_str(yaml).unwrap();
318        assert_eq!(gw.supernodes.len(), 1);
319        let sn = &gw.supernodes[0];
320        assert_eq!(sn.name, "secured-call");
321        assert_eq!(sn.description.as_deref(), Some("auth + upstream"));
322        assert_eq!(sn.nodes.len(), 4);
323        assert_eq!(sn.edges.len(), 3);
324
325        let out = serde_yaml::to_string(&GatewayConfig {
326            routes: vec![],
327            policies: vec![],
328            consumers: vec![],
329            supernodes: gw.supernodes.clone(),
330            plugin_configs: vec![],
331            stores: vec![],
332        })
333        .unwrap();
334        let back: GatewayConfig = serde_yaml::from_str(&out).unwrap();
335        assert_eq!(back.supernodes[0].name, "secured-call");
336        assert_eq!(back.supernodes[0].nodes.len(), 4);
337    }
338
339    /// Old configs stay valid; a shared config and a `config_ref` round-trip
340    /// through YAML; `config_ref` is omitted from output when unset.
341    #[test]
342    fn test_plugin_configs_default_empty_and_roundtrip() {
343        let gw: GatewayConfig = serde_yaml::from_str("{}").unwrap();
344        assert!(gw.plugin_configs.is_empty());
345
346        let yaml = r#"
347plugin_configs:
348  - name: corp-oidc
349    type: openid-connect
350    description: "Corporate IdP client"
351    config:
352      client_id: gateway
353      scope: openid
354policies:
355  - name: p
356    nodes:
357      - { id: auth, type: openid-connect, config_ref: corp-oidc, config: { scope: "openid profile" } }
358"#;
359        let gw: GatewayConfig = serde_yaml::from_str(yaml).unwrap();
360        assert_eq!(gw.plugin_configs.len(), 1);
361        let def = &gw.plugin_configs[0];
362        assert_eq!(def.name, "corp-oidc");
363        assert_eq!(def.plugin_type, "openid-connect");
364        assert_eq!(def.description.as_deref(), Some("Corporate IdP client"));
365        assert_eq!(def.config["client_id"], serde_json::json!("gateway"));
366        let node = &gw.policies[0].nodes[0];
367        assert_eq!(node.config_ref.as_deref(), Some("corp-oidc"));
368        assert_eq!(node.config["scope"], serde_json::json!("openid profile"));
369
370        // Round-trip keeps the reference form; nodes without a ref omit the key.
371        let out = serde_yaml::to_string(&gw).unwrap();
372        assert!(out.contains("config_ref: corp-oidc"), "{out}");
373        let plain: GatewayConfig = serde_yaml::from_str(
374            "policies:\n  - name: q\n    nodes:\n      - { id: a, type: cors }\n",
375        )
376        .unwrap();
377        let plain_out = serde_yaml::to_string(&plain).unwrap();
378        assert!(!plain_out.contains("config_ref"), "{plain_out}");
379    }
380
381    /// Old configs stay valid; a store declaration round-trips through YAML
382    /// with placeholders preserved verbatim; optionals are omitted from output.
383    #[test]
384    fn test_stores_default_empty_and_roundtrip() {
385        let gw: GatewayConfig = serde_yaml::from_str("{}").unwrap();
386        assert!(gw.stores.is_empty());
387
388        let yaml = r#"
389stores:
390  - name: sessions-redis
391    type: valkey
392    description: "Shared session/counter backend"
393    url: ${REDIS_URL:-redis://127.0.0.1:6379}
394    password: ${REDIS_PASSWORD:-}
395    key_prefix: fb
396    connect_timeout_ms: 1500
397    tls:
398      ca_cert_path: /etc/ssl/redis-ca.pem
399"#;
400        let gw: GatewayConfig = serde_yaml::from_str(yaml).unwrap();
401        assert_eq!(gw.stores.len(), 1);
402        let s = &gw.stores[0];
403        assert_eq!(s.name, "sessions-redis");
404        assert_eq!(s.store_type, "valkey");
405        assert_eq!(s.url, "${REDIS_URL:-redis://127.0.0.1:6379}");
406        assert_eq!(s.password.as_deref(), Some("${REDIS_PASSWORD:-}"));
407        assert_eq!(s.key_prefix, "fb");
408        assert_eq!(s.connect_timeout_ms, 1500);
409        assert_eq!(
410            s.tls.as_ref().unwrap().ca_cert_path.as_deref(),
411            Some("/etc/ssl/redis-ca.pem")
412        );
413        assert!(s.topology.is_none());
414        assert!(s.urls.is_none());
415
416        // Defaults apply when omitted.
417        let gw: GatewayConfig = serde_yaml::from_str(
418            "stores:\n  - name: s1\n    type: redis\n    url: redis://localhost\n",
419        )
420        .unwrap();
421        assert_eq!(gw.stores[0].key_prefix, "fb");
422        assert_eq!(gw.stores[0].connect_timeout_ms, 2000);
423
424        // Round-trip: raw placeholder survives serialization, no null noise.
425        let out = serde_yaml::to_string(&gw).unwrap();
426        assert!(out.contains("redis://localhost"), "{out}");
427        assert!(!out.contains("description"), "{out}");
428        assert!(!out.contains("topology"), "{out}");
429    }
430}