Skip to main content

featherbit/plugins/native/
echo.rs

1//! The `echo` node — replaces or wraps the response body and adds response
2//! headers. A response-phase node: place it after `upstream` (between
3//! `upstream` and `client`) so it sees the upstream response.
4//!
5//! Port of APISIX's `echo` plugin (which APISIX itself documents as a
6//! demo/util plugin). `before_body`/`body`/`after_body` map to APISIX's
7//! body_filter behavior and `headers` to its header_filter behavior.
8
9use 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
18/// Rewrites the response: `body` replaces the upstream body, then
19/// `before_body` / `after_body` are concatenated around it, and `headers`
20/// are set on the response (replacing any existing values, like
21/// `ngx.header[...]`).
22///
23/// Because the body is mutated, the plugin follows featherbit's body-mutation
24/// convention: `content-length` is removed (the server recomputes it) and a
25/// compressed upstream body is decoded first, with `content-encoding`
26/// removed. This plugin never fails at execution time.
27pub struct EchoPlugin {
28    /// Replacement for the upstream response body. Supports
29    /// `{{namespace.path}}` references (no legacy `$var` interpolation —
30    /// response bodies never supported it, so this sweep must not start).
31    body: Option<Template>,
32    /// Prepended to the (possibly replaced) body. Same rendering as `body`.
33    before_body: Option<Template>,
34    /// Appended to the (possibly replaced) body. Same rendering as `body`.
35    after_body: Option<Template>,
36    /// Response headers to set (names lowercased, values replace existing).
37    /// Values support `{{namespace.path}}` references (no legacy `$var`
38    /// interpolation — response headers never supported it, so this sweep
39    /// must not start).
40    headers: HashMap<String, Template>,
41}
42
43/// Reads an optional string key, rejecting non-string values.
44fn 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    /// Builds the plugin from node config.
57    ///
58    /// Accepted keys — at least one of `body` / `before_body` / `after_body`
59    /// is required (APISIX parity):
60    /// - `body` (string): replaces the upstream response body; supports
61    ///   `{{namespace.path}}` references.
62    /// - `before_body` (string): prepended to the body (after any `body`
63    ///   replacement); supports `{{namespace.path}}` references.
64    /// - `after_body` (string): appended to the body; supports
65    ///   `{{namespace.path}}` references.
66    /// - `headers` (map `{name: value}` **or** array `[{name, value}]`, the
67    ///   UI editor's form): response headers to set. Values must be scalars
68    ///   (strings, numbers, bools — stringified) and support
69    ///   `{{namespace.path}}` references; names are lowercased; array entries
70    ///   with a blank `name` are skipped. Existing values for the same
71    ///   header are replaced.
72    ///
73    /// ```yaml
74    /// type: echo
75    /// config:
76    ///   before_body: "before the body modification "
77    ///   after_body: " after the body modification"
78    ///   headers:
79    ///     x-served-by: featherbit
80    /// ```
81    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        // Discard warnings here — the compile-time walk (a later task)
94        // reports well-formed-but-unknown references; execution must not.
95        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            // Map form (YAML): headers: { x-foo: bar }
111            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            // Array form (UI editor): headers: [{ name: x-foo, value: bar }]
116            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                        // blank row left in the UI editor
125                        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        // Discard warnings here — the compile-time walk (a later task)
143        // reports well-formed-but-unknown references; execution must not.
144        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        // Body mutation (always: from_config requires at least one body key).
166        let current: Bytes = match &self.body {
167            // Full replacement ignores the upstream body entirely.
168            Some(body) => Bytes::from(body.render(&ctx).into_owned()),
169            // before/after wrap the upstream body; a compressed body is
170            // decoded first so text concatenates onto text. If the encoding
171            // is unsupported or the stream corrupt, the raw bytes are used.
172            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(&current);
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        // Body-mutation convention: the body is now plain (decoded) and has
199        // a new length — drop the stale metadata headers.
200        ctx.response.headers.remove("content-length");
201        ctx.response.headers.remove("content-encoding");
202
203        // Header rewrites (set semantics, like ngx.header[name] = value).
204        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        // At least one body key is required (APISIX anyOf).
254        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        // Non-string body keys rejected.
261        assert!(EchoPlugin::from_config(&config(serde_json::json!({"body": 5}))).is_err());
262
263        // Non-scalar header values and non-map headers rejected.
264        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        // Valid shapes accepted.
276        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        // Stale content-length dropped per the body-mutation convention.
299        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        // `body` must render `{{...}}` references per request while a `$`
360        // money string in the same value survives byte-identically (the
361        // safety property: response bodies never went through legacy `$var`
362        // interpolation, and this sweep must not start doing so).
363        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        // The UI editor serializes headers as [{name, value}]
381        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        // Header values must render `{{...}}` references per request while a
404        // `$` money string in the same value survives byte-identically —
405        // response headers never supported legacy `$var` interpolation, and
406        // this sweep must not start.
407        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        // Name lowercased, existing value replaced (not appended).
439        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}