1use std::collections::HashMap;
14
15use serde_json::Value;
16
17use super::gateway::{GatewayConfig, NodeConfig, PluginConfigDef};
18
19const RESERVED_TYPES: [&str; 6] = [
22 "listener",
23 "client",
24 "supernode",
25 "input",
26 "output",
27 "error",
28];
29
30pub fn resolve_plugin_configs(gw: &GatewayConfig) -> Result<GatewayConfig, String> {
37 let mut seen = std::collections::HashSet::new();
39 for def in &gw.plugin_configs {
40 if !seen.insert(def.name.as_str()) {
41 return Err(format!("Duplicate plugin config name '{}'", def.name));
42 }
43 if RESERVED_TYPES.contains(&def.plugin_type.as_str()) {
44 return Err(format!(
45 "Plugin config '{}' uses reserved type '{}'",
46 def.name, def.plugin_type
47 ));
48 }
49 if !crate::plugins::KNOWN_PLUGIN_TYPES.contains(&def.plugin_type.as_str()) {
50 return Err(format!(
51 "Plugin config '{}' references unknown plugin type '{}'",
52 def.name, def.plugin_type
53 ));
54 }
55 }
56
57 let by_name: HashMap<&str, &PluginConfigDef> = gw
58 .plugin_configs
59 .iter()
60 .map(|d| (d.name.as_str(), d))
61 .collect();
62
63 let mut out = gw.clone();
64 for policy in &mut out.policies {
65 let ctx = format!("policy '{}'", policy.name);
66 resolve_nodes(&mut policy.nodes, &by_name, &ctx)?;
67 }
68 for sn in &mut out.supernodes {
69 let ctx = format!("supernode '{}'", sn.name);
70 resolve_nodes(&mut sn.nodes, &by_name, &ctx)?;
71 }
72 Ok(out)
73}
74
75fn resolve_nodes(
78 nodes: &mut [NodeConfig],
79 by_name: &HashMap<&str, &PluginConfigDef>,
80 ctx: &str,
81) -> Result<(), String> {
82 for node in nodes {
83 let Some(ref_name) = node.config_ref.take() else {
84 continue;
85 };
86 if node.node_type == "supernode" {
87 return Err(format!(
88 "{ctx}: supernode instance node '{}' cannot use config_ref",
89 node.id
90 ));
91 }
92 let def = by_name.get(ref_name.as_str()).ok_or_else(|| {
93 format!(
94 "{ctx}: node '{}' references unknown plugin config '{}'",
95 node.id, ref_name
96 )
97 })?;
98 if def.plugin_type != node.node_type {
99 return Err(format!(
100 "{ctx}: node '{}' ({}) references plugin config '{}' of type '{}'",
101 node.id, node.node_type, ref_name, def.plugin_type
102 ));
103 }
104 let mut merged: HashMap<String, Value> = def.config.clone();
105 merged.extend(std::mem::take(&mut node.config));
106 node.config = merged;
107 }
108 Ok(())
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use crate::config::{EdgeConfig, PolicyConfig, SupernodeConfig};
115 use serde_json::json;
116
117 fn def(name: &str, plugin_type: &str, config: serde_json::Value) -> PluginConfigDef {
118 PluginConfigDef {
119 name: name.into(),
120 plugin_type: plugin_type.into(),
121 description: None,
122 config: serde_json::from_value(config).unwrap(),
123 }
124 }
125
126 fn node(id: &str, ty: &str, config_ref: Option<&str>, config: serde_json::Value) -> NodeConfig {
127 NodeConfig {
128 id: id.into(),
129 node_type: ty.into(),
130 config: serde_json::from_value(config).unwrap(),
131 config_ref: config_ref.map(String::from),
132 position: None,
133 }
134 }
135
136 fn gw_with(policy_nodes: Vec<NodeConfig>, defs: Vec<PluginConfigDef>) -> GatewayConfig {
137 let mut gw: GatewayConfig = serde_yaml::from_str("{}").unwrap();
138 gw.plugin_configs = defs;
139 gw.policies = vec![PolicyConfig {
140 name: "p".into(),
141 error_handler: None,
142 nodes: policy_nodes,
143 edges: Vec::new(),
144 }];
145 gw
146 }
147
148 #[test]
149 fn test_merge_local_wins_and_ref_cleared() {
150 let gw = gw_with(
151 vec![node(
152 "auth",
153 "openid-connect",
154 Some("corp"),
155 json!({"scope": "openid profile"}),
156 )],
157 vec![def(
158 "corp",
159 "openid-connect",
160 json!({"client_id": "gw", "scope": "openid"}),
161 )],
162 );
163 let out = resolve_plugin_configs(&gw).unwrap();
164 let n = &out.policies[0].nodes[0];
165 assert_eq!(n.config["client_id"], json!("gw")); assert_eq!(n.config["scope"], json!("openid profile")); assert!(
168 n.config_ref.is_none(),
169 "ref must be cleared in resolved copy"
170 );
171 assert_eq!(gw.policies[0].nodes[0].config_ref.as_deref(), Some("corp"));
173 }
174
175 #[test]
176 fn test_no_ref_passthrough_unchanged() {
177 let gw = gw_with(vec![node("a", "cors", None, json!({"x": 1}))], vec![]);
178 let out = resolve_plugin_configs(&gw).unwrap();
179 assert_eq!(out.policies[0].nodes[0].config["x"], json!(1));
180 }
181
182 #[test]
183 fn test_unknown_ref_is_error() {
184 let gw = gw_with(vec![node("a", "cors", Some("nope"), json!({}))], vec![]);
185 let err = resolve_plugin_configs(&gw).unwrap_err();
186 assert!(
187 err.contains("unknown plugin config 'nope'") && err.contains("'a'"),
188 "{err}"
189 );
190 }
191
192 #[test]
193 fn test_type_mismatch_is_error() {
194 let gw = gw_with(
195 vec![node("a", "cors", Some("corp"), json!({}))],
196 vec![def("corp", "openid-connect", json!({}))],
197 );
198 let err = resolve_plugin_configs(&gw).unwrap_err();
199 assert!(
200 err.contains("'corp'") && err.contains("openid-connect") && err.contains("cors"),
201 "{err}"
202 );
203 }
204
205 #[test]
206 fn test_supernode_definition_nodes_resolve() {
207 let mut gw = gw_with(
208 vec![],
209 vec![def("m", "mocking", json!({"response_status": 200}))],
210 );
211 gw.supernodes = vec![SupernodeConfig {
212 name: "sn".into(),
213 description: None,
214 nodes: vec![
215 node("input", "input", None, json!({})),
216 node("output", "output", None, json!({})),
217 node("error", "error", None, json!({})),
218 node(
219 "mock",
220 "mocking",
221 Some("m"),
222 json!({"content_type": "text/plain"}),
223 ),
224 ],
225 edges: vec![
226 EdgeConfig {
227 from: "input.out".into(),
228 to: "mock.in".into(),
229 },
230 EdgeConfig {
231 from: "mock.success".into(),
232 to: "output.in".into(),
233 },
234 ],
235 }];
236 let out = resolve_plugin_configs(&gw).unwrap();
237 let inner = &out.supernodes[0].nodes[3];
238 assert_eq!(inner.config["response_status"], json!(200));
239 assert_eq!(inner.config["content_type"], json!("text/plain"));
240 assert!(inner.config_ref.is_none());
241 }
242
243 #[test]
244 fn test_supernode_instance_node_cannot_carry_ref() {
245 let gw = gw_with(
246 vec![node("sn", "supernode", Some("m"), json!({"name": "x"}))],
247 vec![def("m", "mocking", json!({}))],
248 );
249 let err = resolve_plugin_configs(&gw).unwrap_err();
250 assert!(
251 err.contains("supernode") && err.contains("config_ref"),
252 "{err}"
253 );
254 }
255
256 #[test]
257 fn test_duplicate_def_names_rejected() {
258 let gw = gw_with(
259 vec![],
260 vec![def("d", "cors", json!({})), def("d", "cors", json!({}))],
261 );
262 let err = resolve_plugin_configs(&gw).unwrap_err();
263 assert!(err.contains("Duplicate plugin config name 'd'"), "{err}");
264 }
265
266 #[test]
267 fn test_unknown_and_reserved_def_types_rejected() {
268 let gw = gw_with(vec![], vec![def("d", "openid-conect", json!({}))]);
269 let err = resolve_plugin_configs(&gw).unwrap_err();
270 assert!(err.contains("unknown plugin type 'openid-conect'"), "{err}");
271
272 for ty in RESERVED_TYPES {
273 let gw = gw_with(vec![], vec![def("d", ty, json!({}))]);
274 let err = resolve_plugin_configs(&gw).unwrap_err();
275 assert!(err.contains("reserved"), "type {ty}: {err}");
276 }
277 }
278}