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}