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};
16
17/// Rewrites the response: `body` replaces the upstream body, then
18/// `before_body` / `after_body` are concatenated around it, and `headers`
19/// are set on the response (replacing any existing values, like
20/// `ngx.header[...]`).
21///
22/// Because the body is mutated, the plugin follows featherbit's body-mutation
23/// convention: `content-length` is removed (the server recomputes it) and a
24/// compressed upstream body is decoded first, with `content-encoding`
25/// removed. This plugin never fails at execution time.
26pub struct EchoPlugin {
27    /// Replacement for the upstream response body.
28    body: Option<String>,
29    /// Prepended to the (possibly replaced) body.
30    before_body: Option<String>,
31    /// Appended to the (possibly replaced) body.
32    after_body: Option<String>,
33    /// Response headers to set (names lowercased, values replace existing).
34    headers: HashMap<String, String>,
35}
36
37/// Reads an optional string key, rejecting non-string values.
38fn optional_string(
39    config: &HashMap<String, serde_json::Value>,
40    key: &str,
41) -> Result<Option<String>, String> {
42    match config.get(key) {
43        None => Ok(None),
44        Some(serde_json::Value::String(s)) => Ok(Some(s.clone())),
45        Some(_) => Err(format!("{} must be a string", key)),
46    }
47}
48
49impl EchoPlugin {
50    /// Builds the plugin from node config.
51    ///
52    /// Accepted keys — at least one of `body` / `before_body` / `after_body`
53    /// is required (APISIX parity):
54    /// - `body` (string): replaces the upstream response body.
55    /// - `before_body` (string): prepended to the body (after any `body`
56    ///   replacement).
57    /// - `after_body` (string): appended to the body.
58    /// - `headers` (map `{name: value}` **or** array `[{name, value}]`, the
59    ///   UI editor's form): response headers to set. Values must be scalars
60    ///   (strings, numbers, bools — stringified); names are lowercased;
61    ///   array entries with a blank `name` are skipped. Existing values for
62    ///   the same header are replaced.
63    ///
64    /// ```yaml
65    /// type: echo
66    /// config:
67    ///   before_body: "before the body modification "
68    ///   after_body: " after the body modification"
69    ///   headers:
70    ///     x-served-by: featherbit
71    /// ```
72    pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
73        let body = optional_string(config, "body")?;
74        let before_body = optional_string(config, "before_body")?;
75        let after_body = optional_string(config, "after_body")?;
76
77        if body.is_none() && before_body.is_none() && after_body.is_none() {
78            return Err(
79                "echo plugin requires at least one of 'body', 'before_body' or 'after_body'"
80                    .to_string(),
81            );
82        }
83
84        let scalar = |v: &serde_json::Value, name: &str| -> Result<String, String> {
85            match v {
86                serde_json::Value::String(s) => Ok(s.clone()),
87                serde_json::Value::Number(n) => Ok(n.to_string()),
88                serde_json::Value::Bool(b) => Ok(b.to_string()),
89                _ => Err(format!("headers['{}'] must be a scalar value", name)),
90            }
91        };
92
93        let headers = match config.get("headers") {
94            None => HashMap::new(),
95            // Map form (YAML): headers: { x-foo: bar }
96            Some(serde_json::Value::Object(m)) => m
97                .iter()
98                .map(|(k, v)| Ok((k.to_lowercase(), scalar(v, k)?)))
99                .collect::<Result<HashMap<_, _>, String>>()?,
100            // Array form (UI editor): headers: [{ name: x-foo, value: bar }]
101            Some(serde_json::Value::Array(items)) => {
102                let mut headers = HashMap::new();
103                for item in items {
104                    let obj = item.as_object().ok_or_else(|| {
105                        "headers entries must be objects with 'name' and 'value'".to_string()
106                    })?;
107                    let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("");
108                    if name.trim().is_empty() {
109                        // blank row left in the UI editor
110                        continue;
111                    }
112                    let value = match obj.get("value") {
113                        None => String::new(),
114                        Some(v) => scalar(v, name)?,
115                    };
116                    headers.insert(name.to_lowercase(), value);
117                }
118                headers
119            }
120            Some(_) => {
121                return Err(
122                    "headers must be a map of name: value or a list of {name, value} objects"
123                        .to_string(),
124                )
125            }
126        };
127
128        Ok(Self {
129            body,
130            before_body,
131            after_body,
132            headers,
133        })
134    }
135}
136
137#[async_trait]
138impl Plugin for EchoPlugin {
139    fn plugin_type(&self) -> &str {
140        "echo"
141    }
142
143    async fn execute(
144        &self,
145        mut ctx: Context,
146        _named_inputs: &HashMap<String, serde_json::Value>,
147    ) -> PluginResult {
148        // Body mutation (always: from_config requires at least one body key).
149        let current: Bytes = match &self.body {
150            // Full replacement ignores the upstream body entirely.
151            Some(body) => Bytes::from(body.clone()),
152            // before/after wrap the upstream body; a compressed body is
153            // decoded first so text concatenates onto text. If the encoding
154            // is unsupported or the stream corrupt, the raw bytes are used.
155            None => {
156                let encoding = ctx
157                    .response
158                    .headers
159                    .get("content-encoding")
160                    .and_then(|v| v.first())
161                    .and_then(|v| ContentEncoding::parse(v).ok())
162                    .flatten();
163                match encoding {
164                    Some(enc) => decode(&enc, &ctx.response.body)
165                        .unwrap_or_else(|_| ctx.response.body.clone()),
166                    None => ctx.response.body.clone(),
167                }
168            }
169        };
170
171        let mut buf = BytesMut::new();
172        if let Some(before) = &self.before_body {
173            buf.extend_from_slice(before.as_bytes());
174        }
175        buf.extend_from_slice(&current);
176        if let Some(after) = &self.after_body {
177            buf.extend_from_slice(after.as_bytes());
178        }
179        ctx.response.body = buf.freeze();
180
181        // Body-mutation convention: the body is now plain (decoded) and has
182        // a new length — drop the stale metadata headers.
183        ctx.response.headers.remove("content-length");
184        ctx.response.headers.remove("content-encoding");
185
186        // Header rewrites (set semantics, like ngx.header[name] = value).
187        for (name, value) in &self.headers {
188            ctx.response
189                .headers
190                .insert(name.clone(), vec![value.clone()]);
191        }
192
193        Ok(PluginOutput {
194            context: ctx,
195            named_outputs: HashMap::new(),
196        })
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
204    use crate::plugins::util::content_codec::encode;
205
206    fn test_context(body: &str) -> Context {
207        Context {
208            request: GatewayRequest {
209                method: "GET".to_string(),
210                path: "/".to_string(),
211                host: "localhost".to_string(),
212                scheme: "http".to_string(),
213                headers: HashMap::new(),
214                query_params: HashMap::new(),
215                body: Bytes::new(),
216                remote_addr: "127.0.0.1:12345".to_string(),
217                protocol: Protocol::Http1,
218            },
219            response: GatewayResponse {
220                status_code: 200,
221                headers: HashMap::new(),
222                body: Bytes::from(body.to_string()),
223            },
224            message: HashMap::new(),
225            errors: Vec::new(),
226        }
227    }
228
229    fn config(json: serde_json::Value) -> HashMap<String, serde_json::Value> {
230        serde_json::from_value(json).unwrap()
231    }
232
233    #[test]
234    fn test_echo_config_validation() {
235        // At least one body key is required (APISIX anyOf).
236        assert!(EchoPlugin::from_config(&HashMap::new()).is_err());
237        assert!(EchoPlugin::from_config(&config(serde_json::json!({
238            "headers": {"x-a": "b"}
239        })))
240        .is_err());
241
242        // Non-string body keys rejected.
243        assert!(EchoPlugin::from_config(&config(serde_json::json!({"body": 5}))).is_err());
244
245        // Non-scalar header values and non-map headers rejected.
246        assert!(EchoPlugin::from_config(&config(serde_json::json!({
247            "body": "x",
248            "headers": {"x-a": {"nested": true}}
249        })))
250        .is_err());
251        assert!(EchoPlugin::from_config(&config(serde_json::json!({
252            "body": "x",
253            "headers": ["x-a"]
254        })))
255        .is_err());
256
257        // Valid shapes accepted.
258        assert!(EchoPlugin::from_config(&config(serde_json::json!({"body": "x"}))).is_ok());
259        assert!(EchoPlugin::from_config(&config(serde_json::json!({
260            "before_body": "pre",
261            "headers": {"x-version": 2}
262        })))
263        .is_ok());
264    }
265
266    #[tokio::test]
267    async fn test_echo_body_replacement() {
268        let plugin = EchoPlugin::from_config(&config(serde_json::json!({
269            "body": "replaced"
270        })))
271        .unwrap();
272
273        let mut ctx = test_context("upstream body");
274        ctx.response
275            .headers
276            .insert("content-length".to_string(), vec!["13".to_string()]);
277
278        let result = plugin.execute(ctx, &HashMap::new()).await.unwrap();
279        assert_eq!(result.context.response.body, Bytes::from("replaced"));
280        // Stale content-length dropped per the body-mutation convention.
281        assert!(!result
282            .context
283            .response
284            .headers
285            .contains_key("content-length"));
286    }
287
288    #[tokio::test]
289    async fn test_echo_before_and_after_body_wrap_upstream() {
290        let plugin = EchoPlugin::from_config(&config(serde_json::json!({
291            "before_body": "pre|",
292            "after_body": "|post"
293        })))
294        .unwrap();
295
296        let result = plugin
297            .execute(test_context("upstream"), &HashMap::new())
298            .await
299            .unwrap();
300        assert_eq!(
301            result.context.response.body,
302            Bytes::from("pre|upstream|post")
303        );
304    }
305
306    #[tokio::test]
307    async fn test_echo_wraps_replaced_body() {
308        let plugin = EchoPlugin::from_config(&config(serde_json::json!({
309            "body": "mid",
310            "before_body": "a|",
311            "after_body": "|z"
312        })))
313        .unwrap();
314
315        let result = plugin
316            .execute(test_context("ignored"), &HashMap::new())
317            .await
318            .unwrap();
319        assert_eq!(result.context.response.body, Bytes::from("a|mid|z"));
320    }
321
322    #[tokio::test]
323    async fn test_echo_decodes_compressed_upstream_body() {
324        let plugin = EchoPlugin::from_config(&config(serde_json::json!({
325            "before_body": "pre|"
326        })))
327        .unwrap();
328
329        let mut ctx = test_context("");
330        ctx.response.body = encode(&ContentEncoding::Gzip, &Bytes::from("upstream"), 6).unwrap();
331        ctx.response
332            .headers
333            .insert("content-encoding".to_string(), vec!["gzip".to_string()]);
334        ctx.response
335            .headers
336            .insert("content-length".to_string(), vec!["28".to_string()]);
337
338        let result = plugin.execute(ctx, &HashMap::new()).await.unwrap();
339        let ctx = result.context;
340        assert_eq!(ctx.response.body, Bytes::from("pre|upstream"));
341        assert!(!ctx.response.headers.contains_key("content-encoding"));
342        assert!(!ctx.response.headers.contains_key("content-length"));
343    }
344
345    #[tokio::test]
346    async fn test_echo_headers_array_shape() {
347        // The UI editor serializes headers as [{name, value}]
348        let plugin = EchoPlugin::from_config(&config(serde_json::json!({
349            "body": "x",
350            "headers": [
351                { "name": "X-Served-By", "value": "featherbit" },
352                { "name": "", "value": "ignored blank row" }
353            ]
354        })))
355        .unwrap();
356
357        let result = plugin
358            .execute(test_context("upstream"), &HashMap::new())
359            .await
360            .unwrap();
361        let headers = &result.context.response.headers;
362        assert_eq!(
363            headers.get("x-served-by"),
364            Some(&vec!["featherbit".to_string()])
365        );
366        assert!(!headers
367            .values()
368            .any(|v| v.contains(&"ignored blank row".to_string())));
369    }
370
371    #[tokio::test]
372    async fn test_echo_sets_headers_with_replace_semantics() {
373        let plugin = EchoPlugin::from_config(&config(serde_json::json!({
374            "body": "x",
375            "headers": {"X-Served-By": "featherbit", "x-version": 2}
376        })))
377        .unwrap();
378
379        let mut ctx = test_context("upstream");
380        ctx.response
381            .headers
382            .insert("x-served-by".to_string(), vec!["upstream-host".to_string()]);
383
384        let result = plugin.execute(ctx, &HashMap::new()).await.unwrap();
385        let headers = &result.context.response.headers;
386        // Name lowercased, existing value replaced (not appended).
387        assert_eq!(
388            headers.get("x-served-by"),
389            Some(&vec!["featherbit".to_string()])
390        );
391        assert_eq!(headers.get("x-version"), Some(&vec!["2".to_string()]));
392    }
393}