featherbit/plugins/native/
echo.rs1use async_trait::async_trait;
10use bytes::{Bytes, BytesMut};
11use std::collections::HashMap;
12
13use crate::context::Context;
14use crate::plugins::util::content_codec::{decode, ContentEncoding};
15use crate::plugins::{Plugin, PluginOutput, PluginResult};
16use crate::vars::template::Template;
17
18pub struct EchoPlugin {
28 body: Option<Template>,
32 before_body: Option<Template>,
34 after_body: Option<Template>,
36 headers: HashMap<String, Template>,
41}
42
43fn optional_string(
45 config: &HashMap<String, serde_json::Value>,
46 key: &str,
47) -> Result<Option<String>, String> {
48 match config.get(key) {
49 None => Ok(None),
50 Some(serde_json::Value::String(s)) => Ok(Some(s.clone())),
51 Some(_) => Err(format!("{} must be a string", key)),
52 }
53}
54
55impl EchoPlugin {
56 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
82 let body = optional_string(config, "body")?;
83 let before_body = optional_string(config, "before_body")?;
84 let after_body = optional_string(config, "after_body")?;
85
86 if body.is_none() && before_body.is_none() && after_body.is_none() {
87 return Err(
88 "echo plugin requires at least one of 'body', 'before_body' or 'after_body'"
89 .to_string(),
90 );
91 }
92
93 let body = body.map(|s| Template::parse(&s).0);
96 let before_body = before_body.map(|s| Template::parse(&s).0);
97 let after_body = after_body.map(|s| Template::parse(&s).0);
98
99 let scalar = |v: &serde_json::Value, name: &str| -> Result<String, String> {
100 match v {
101 serde_json::Value::String(s) => Ok(s.clone()),
102 serde_json::Value::Number(n) => Ok(n.to_string()),
103 serde_json::Value::Bool(b) => Ok(b.to_string()),
104 _ => Err(format!("headers['{}'] must be a scalar value", name)),
105 }
106 };
107
108 let headers: HashMap<String, String> = match config.get("headers") {
109 None => HashMap::new(),
110 Some(serde_json::Value::Object(m)) => m
112 .iter()
113 .map(|(k, v)| Ok((k.to_lowercase(), scalar(v, k)?)))
114 .collect::<Result<HashMap<_, _>, String>>()?,
115 Some(serde_json::Value::Array(items)) => {
117 let mut headers = HashMap::new();
118 for item in items {
119 let obj = item.as_object().ok_or_else(|| {
120 "headers entries must be objects with 'name' and 'value'".to_string()
121 })?;
122 let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("");
123 if name.trim().is_empty() {
124 continue;
126 }
127 let value = match obj.get("value") {
128 None => String::new(),
129 Some(v) => scalar(v, name)?,
130 };
131 headers.insert(name.to_lowercase(), value);
132 }
133 headers
134 }
135 Some(_) => {
136 return Err(
137 "headers must be a map of name: value or a list of {name, value} objects"
138 .to_string(),
139 )
140 }
141 };
142 let headers = headers
145 .into_iter()
146 .map(|(name, value)| (name, Template::parse(&value).0))
147 .collect();
148
149 Ok(Self {
150 body,
151 before_body,
152 after_body,
153 headers,
154 })
155 }
156}
157
158#[async_trait]
159impl Plugin for EchoPlugin {
160 fn plugin_type(&self) -> &str {
161 "echo"
162 }
163
164 async fn execute(&self, mut ctx: Context) -> PluginResult {
165 let current: Bytes = match &self.body {
167 Some(body) => Bytes::from(body.render(&ctx).into_owned()),
169 None => {
173 let encoding = ctx
174 .response
175 .headers
176 .get("content-encoding")
177 .and_then(|v| v.first())
178 .and_then(|v| ContentEncoding::parse(v).ok())
179 .flatten();
180 match encoding {
181 Some(enc) => decode(&enc, &ctx.response.body)
182 .unwrap_or_else(|_| ctx.response.body.clone()),
183 None => ctx.response.body.clone(),
184 }
185 }
186 };
187
188 let mut buf = BytesMut::new();
189 if let Some(before) = &self.before_body {
190 buf.extend_from_slice(before.render(&ctx).as_bytes());
191 }
192 buf.extend_from_slice(¤t);
193 if let Some(after) = &self.after_body {
194 buf.extend_from_slice(after.render(&ctx).as_bytes());
195 }
196 ctx.response.body = buf.freeze();
197
198 ctx.response.headers.remove("content-length");
201 ctx.response.headers.remove("content-encoding");
202
203 let rendered: Vec<(String, String)> = self
205 .headers
206 .iter()
207 .map(|(name, tmpl)| (name.clone(), tmpl.render(&ctx).into_owned()))
208 .collect();
209 for (name, value) in rendered {
210 ctx.response.headers.insert(name, vec![value]);
211 }
212
213 Ok(PluginOutput::success(ctx))
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
221 use crate::plugins::util::content_codec::encode;
222
223 fn test_context(body: &str) -> Context {
224 Context {
225 request: GatewayRequest {
226 method: "GET".to_string(),
227 path: "/".to_string(),
228 host: "localhost".to_string(),
229 scheme: "http".to_string(),
230 headers: HashMap::new(),
231 query_params: HashMap::new(),
232 body: Bytes::new(),
233 remote_addr: "127.0.0.1:12345".to_string(),
234 protocol: Protocol::Http1,
235 },
236 response: GatewayResponse {
237 status_code: 200,
238 headers: HashMap::new(),
239 body: Bytes::from(body.to_string()),
240 stream: None,
241 },
242 message: HashMap::new(),
243 errors: Vec::new(),
244 }
245 }
246
247 fn config(json: serde_json::Value) -> HashMap<String, serde_json::Value> {
248 serde_json::from_value(json).unwrap()
249 }
250
251 #[test]
252 fn test_echo_config_validation() {
253 assert!(EchoPlugin::from_config(&HashMap::new()).is_err());
255 assert!(EchoPlugin::from_config(&config(serde_json::json!({
256 "headers": {"x-a": "b"}
257 })))
258 .is_err());
259
260 assert!(EchoPlugin::from_config(&config(serde_json::json!({"body": 5}))).is_err());
262
263 assert!(EchoPlugin::from_config(&config(serde_json::json!({
265 "body": "x",
266 "headers": {"x-a": {"nested": true}}
267 })))
268 .is_err());
269 assert!(EchoPlugin::from_config(&config(serde_json::json!({
270 "body": "x",
271 "headers": ["x-a"]
272 })))
273 .is_err());
274
275 assert!(EchoPlugin::from_config(&config(serde_json::json!({"body": "x"}))).is_ok());
277 assert!(EchoPlugin::from_config(&config(serde_json::json!({
278 "before_body": "pre",
279 "headers": {"x-version": 2}
280 })))
281 .is_ok());
282 }
283
284 #[tokio::test]
285 async fn test_echo_body_replacement() {
286 let plugin = EchoPlugin::from_config(&config(serde_json::json!({
287 "body": "replaced"
288 })))
289 .unwrap();
290
291 let mut ctx = test_context("upstream body");
292 ctx.response
293 .headers
294 .insert("content-length".to_string(), vec!["13".to_string()]);
295
296 let result = plugin.execute(ctx).await.unwrap();
297 assert_eq!(result.context.response.body, Bytes::from("replaced"));
298 assert!(!result
300 .context
301 .response
302 .headers
303 .contains_key("content-length"));
304 }
305
306 #[tokio::test]
307 async fn test_echo_before_and_after_body_wrap_upstream() {
308 let plugin = EchoPlugin::from_config(&config(serde_json::json!({
309 "before_body": "pre|",
310 "after_body": "|post"
311 })))
312 .unwrap();
313
314 let result = plugin.execute(test_context("upstream")).await.unwrap();
315 assert_eq!(
316 result.context.response.body,
317 Bytes::from("pre|upstream|post")
318 );
319 }
320
321 #[tokio::test]
322 async fn test_echo_wraps_replaced_body() {
323 let plugin = EchoPlugin::from_config(&config(serde_json::json!({
324 "body": "mid",
325 "before_body": "a|",
326 "after_body": "|z"
327 })))
328 .unwrap();
329
330 let result = plugin.execute(test_context("ignored")).await.unwrap();
331 assert_eq!(result.context.response.body, Bytes::from("a|mid|z"));
332 }
333
334 #[tokio::test]
335 async fn test_echo_decodes_compressed_upstream_body() {
336 let plugin = EchoPlugin::from_config(&config(serde_json::json!({
337 "before_body": "pre|"
338 })))
339 .unwrap();
340
341 let mut ctx = test_context("");
342 ctx.response.body = encode(&ContentEncoding::Gzip, &Bytes::from("upstream"), 6).unwrap();
343 ctx.response
344 .headers
345 .insert("content-encoding".to_string(), vec!["gzip".to_string()]);
346 ctx.response
347 .headers
348 .insert("content-length".to_string(), vec!["28".to_string()]);
349
350 let result = plugin.execute(ctx).await.unwrap();
351 let ctx = result.context;
352 assert_eq!(ctx.response.body, Bytes::from("pre|upstream"));
353 assert!(!ctx.response.headers.contains_key("content-encoding"));
354 assert!(!ctx.response.headers.contains_key("content-length"));
355 }
356
357 #[tokio::test]
358 async fn test_echo_body_renders_template_and_leaves_dollar_untouched() {
359 let plugin = EchoPlugin::from_config(&config(serde_json::json!({
364 "body": "path={{request.path}} price=$19.99"
365 })))
366 .unwrap();
367
368 let mut ctx = test_context("upstream body");
369 ctx.request.path = "/api/orders".to_string();
370
371 let result = plugin.execute(ctx).await.unwrap();
372 assert_eq!(
373 result.context.response.body,
374 Bytes::from("path=/api/orders price=$19.99")
375 );
376 }
377
378 #[tokio::test]
379 async fn test_echo_headers_array_shape() {
380 let plugin = EchoPlugin::from_config(&config(serde_json::json!({
382 "body": "x",
383 "headers": [
384 { "name": "X-Served-By", "value": "featherbit" },
385 { "name": "", "value": "ignored blank row" }
386 ]
387 })))
388 .unwrap();
389
390 let result = plugin.execute(test_context("upstream")).await.unwrap();
391 let headers = &result.context.response.headers;
392 assert_eq!(
393 headers.get("x-served-by"),
394 Some(&vec!["featherbit".to_string()])
395 );
396 assert!(!headers
397 .values()
398 .any(|v| v.contains(&"ignored blank row".to_string())));
399 }
400
401 #[tokio::test]
402 async fn test_echo_header_value_renders_template_and_leaves_dollar_untouched() {
403 let plugin = EchoPlugin::from_config(&config(serde_json::json!({
408 "body": "x",
409 "headers": {"x-path": "path={{request.path}} price=$19.99"}
410 })))
411 .unwrap();
412
413 let mut ctx = test_context("upstream");
414 ctx.request.path = "/api/orders".to_string();
415
416 let result = plugin.execute(ctx).await.unwrap();
417 assert_eq!(
418 result.context.response.headers.get("x-path"),
419 Some(&vec!["path=/api/orders price=$19.99".to_string()])
420 );
421 }
422
423 #[tokio::test]
424 async fn test_echo_sets_headers_with_replace_semantics() {
425 let plugin = EchoPlugin::from_config(&config(serde_json::json!({
426 "body": "x",
427 "headers": {"X-Served-By": "featherbit", "x-version": 2}
428 })))
429 .unwrap();
430
431 let mut ctx = test_context("upstream");
432 ctx.response
433 .headers
434 .insert("x-served-by".to_string(), vec!["upstream-host".to_string()]);
435
436 let result = plugin.execute(ctx).await.unwrap();
437 let headers = &result.context.response.headers;
438 assert_eq!(
440 headers.get("x-served-by"),
441 Some(&vec!["featherbit".to_string()])
442 );
443 assert_eq!(headers.get("x-version"), Some(&vec!["2".to_string()]));
444 }
445}