Skip to main content

featherbit/plugins/native/
body_transformer.rs

1//! The `body-transformer` node — rewrites the request and/or response body
2//! from a template.
3//!
4//! # Deviation from APISIX (read this first)
5//!
6//! APISIX's `body-transformer` renders full **lua-resty-template** templates
7//! (arbitrary Lua expressions, loops, helpers) over bodies decoded from
8//! `xml`, `json`, `encoded`, `args`, `plain`, or `multipart` input.
9//! featherbit implements a deliberate subset:
10//!
11//! - `input_format` supports **`json` only** (other formats are rejected at
12//!   config load; omitting it defaults to `json`).
13//! - Templates are plain strings with two placeholder forms:
14//!   - `{{body.x.y}}` — a dotted path resolved from the parsed JSON body
15//!     (numeric segments index arrays; `{{body}}` is the whole document).
16//!     Strings are inserted raw (unquoted), numbers/bools verbatim, missing
17//!     values and `null` as the empty string, and objects/arrays as compact
18//!     JSON.
19//!   - `{{$var}}` — a context variable via [`crate::vars::resolve`]
20//!     (`$uri`, `$http_x_id`, `$arg_page`, ...).
21//!
22//!   Text outside `{{ }}` additionally passes through
23//!   [`crate::vars::interpolate`], so bare `$var` references work there too.
24//! - `template_is_base64` is not supported (templates are stored literally
25//!   in YAML) and is rejected at config load.
26
27use async_trait::async_trait;
28use bytes::Bytes;
29use std::collections::HashMap;
30
31use crate::context::{Context, GatewayError};
32use crate::plugins::util::content_codec::{self, ContentEncoding};
33use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
34
35/// Rewrites `context.request.body` and/or `context.response.body` by
36/// rendering a template against the parsed JSON body and context variables.
37///
38/// A node with a `request` transform must sit **before** the `upstream`
39/// node; a node with a `response` transform must sit **after** it (the
40/// response body is empty until the upstream runs). Configuring both on one
41/// node only makes sense in single-node positions where both sides are
42/// populated — normally use two nodes.
43///
44/// Failures (a non-empty body that is not valid JSON, or an undecodable
45/// response encoding) exit through the `error` port with code
46/// `BODY_DECODE_FAILED` — status 400 for request bodies, 502 for response
47/// bodies. An empty body renders the template with all `{{body...}}`
48/// placeholders empty.
49pub struct BodyTransformerPlugin {
50    request: Option<String>,
51    response: Option<String>,
52}
53
54/// Validates one `request`/`response` transform object and returns its
55/// template string.
56fn parse_transform(
57    config: &HashMap<String, serde_json::Value>,
58    key: &str,
59) -> Result<Option<String>, String> {
60    let Some(raw) = config.get(key) else {
61        return Ok(None);
62    };
63    let obj = raw
64        .as_object()
65        .ok_or_else(|| format!("body-transformer: '{}' must be an object", key))?;
66
67    let template = obj
68        .get("template")
69        .and_then(|v| v.as_str())
70        .filter(|s| !s.is_empty())
71        .ok_or_else(|| format!("body-transformer: '{}.template' is required", key))?;
72
73    match obj.get("input_format").and_then(|v| v.as_str()) {
74        None | Some("json") => {}
75        Some(other) => {
76            return Err(format!(
77                "body-transformer: '{}.input_format' '{}' is not supported — featherbit implements 'json' only",
78                key, other
79            ));
80        }
81    }
82
83    if obj.get("template_is_base64").and_then(|v| v.as_bool()) == Some(true) {
84        return Err(format!(
85            "body-transformer: '{}.template_is_base64' is not supported — store the template literally",
86            key
87        ));
88    }
89
90    // Fail fast on unbalanced placeholder braces.
91    let mut rest = template;
92    while let Some(open) = rest.find("{{") {
93        match rest[open + 2..].find("}}") {
94            Some(close) => rest = &rest[open + 2 + close + 2..],
95            None => {
96                return Err(format!(
97                    "body-transformer: '{}.template' has an unclosed '{{{{' placeholder",
98                    key
99                ));
100            }
101        }
102    }
103
104    Ok(Some(template.to_string()))
105}
106
107/// Resolves a dotted path (`x.y.0`) inside a JSON value; an empty path
108/// returns the value itself.
109fn json_path<'a>(mut value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
110    if path.is_empty() {
111        return Some(value);
112    }
113    for seg in path.split('.') {
114        value = match seg.parse::<usize>() {
115            Ok(idx) => value.get(idx)?,
116            Err(_) => value.get(seg)?,
117        };
118    }
119    Some(value)
120}
121
122/// Stringifies a resolved JSON value for template insertion: strings raw,
123/// scalars verbatim, `null` empty, containers as compact JSON.
124fn json_to_template_string(value: &serde_json::Value) -> String {
125    match value {
126        serde_json::Value::String(s) => s.clone(),
127        serde_json::Value::Null => String::new(),
128        serde_json::Value::Number(n) => n.to_string(),
129        serde_json::Value::Bool(b) => b.to_string(),
130        other => serde_json::to_string(other).unwrap_or_default(),
131    }
132}
133
134/// Renders a template: `{{body.path}}` from the parsed body, `{{$var}}` via
135/// [`crate::vars::resolve`], and `$var` interpolation on the literal text
136/// between placeholders. Unknown placeholders resolve to the empty string;
137/// values substituted from the body are **not** re-interpolated.
138fn render(ctx: &Context, template: &str, body: &serde_json::Value) -> String {
139    let mut out = String::with_capacity(template.len());
140    let mut rest = template;
141
142    while let Some(open) = rest.find("{{") {
143        // Literal text before the placeholder gets $var interpolation.
144        out.push_str(&crate::vars::interpolate(ctx, &rest[..open]));
145        let after = &rest[open + 2..];
146        match after.find("}}") {
147            Some(close) => {
148                let expr = after[..close].trim();
149                if let Some(var) = expr.strip_prefix('$') {
150                    if let Some(v) = crate::vars::resolve(ctx, var) {
151                        out.push_str(&v);
152                    }
153                } else if expr == "body" {
154                    out.push_str(&json_to_template_string(body));
155                } else if let Some(path) = expr.strip_prefix("body.") {
156                    if let Some(v) = json_path(body, path) {
157                        out.push_str(&json_to_template_string(v));
158                    }
159                }
160                // any other placeholder -> empty string
161                rest = &after[close + 2..];
162            }
163            None => {
164                // Unclosed placeholder (rejected at config load; kept literal
165                // here for robustness).
166                out.push_str(&crate::vars::interpolate(
167                    ctx,
168                    rest[open..].to_string().as_str(),
169                ));
170                rest = "";
171                break;
172            }
173        }
174    }
175    out.push_str(&crate::vars::interpolate(ctx, rest));
176    out
177}
178
179/// Parses a body as JSON; empty bodies become `null` so templates still
180/// render (with empty `{{body...}}` placeholders).
181fn parse_body(body: &Bytes) -> Result<serde_json::Value, String> {
182    if body.is_empty() {
183        return Ok(serde_json::Value::Null);
184    }
185    serde_json::from_slice(body).map_err(|e| format!("body is not valid JSON: {}", e))
186}
187
188impl BodyTransformerPlugin {
189    /// Builds the plugin from node config.
190    ///
191    /// Accepted keys (at least one of `request` / `response` is required):
192    /// - `request` (object): transform applied to the request body.
193    ///   - `template` (string, required): the output body; see the module
194    ///     docs for the `{{body.path}}` / `{{$var}}` placeholder subset.
195    ///   - `input_format` (string, default `json`): only `json` is accepted.
196    /// - `response` (object): same shape, applied to the response body.
197    ///
198    /// Rejected at config load: missing/empty templates, unbalanced `{{`,
199    /// any `input_format` other than `json`, and `template_is_base64: true`
200    /// (both documented deviations from APISIX).
201    ///
202    /// ```yaml
203    /// type: body-transformer
204    /// config:
205    ///   request:
206    ///     input_format: json
207    ///     template: '{"name":"{{body.user.name}}","trace":"{{$http_x_request_id}}"}'
208    /// ```
209    pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
210        let request = parse_transform(config, "request")?;
211        let response = parse_transform(config, "response")?;
212        if request.is_none() && response.is_none() {
213            return Err(
214                "body-transformer: at least one of 'request' or 'response' is required".to_string(),
215            );
216        }
217        Ok(Self { request, response })
218    }
219
220    /// Builds the `error`-port rejection for an undecodable body.
221    fn fail(&self, mut ctx: Context, status: u16, detail: String) -> PluginExecutionError {
222        ctx.response.status_code = status;
223        ctx.response.body = Bytes::from(
224            serde_json::json!({ "error": "body_decode_failed", "message": detail }).to_string(),
225        );
226        ctx.response.headers.insert(
227            "content-type".to_string(),
228            vec!["application/json".to_string()],
229        );
230        PluginExecutionError {
231            context: ctx,
232            error: GatewayError {
233                node_id: String::new(),
234                code: "BODY_DECODE_FAILED".to_string(),
235                message: detail,
236                metadata: HashMap::new(),
237            },
238        }
239    }
240}
241
242#[async_trait]
243impl Plugin for BodyTransformerPlugin {
244    fn plugin_type(&self) -> &str {
245        "body-transformer"
246    }
247
248    async fn execute(
249        &self,
250        mut ctx: Context,
251        _named_inputs: &HashMap<String, serde_json::Value>,
252    ) -> PluginResult {
253        if let Some(template) = &self.request {
254            let parsed = match parse_body(&ctx.request.body) {
255                Ok(v) => v,
256                Err(e) => return Err(self.fail(ctx, 400, format!("request {}", e))),
257            };
258            let rendered = render(&ctx, template, &parsed);
259            ctx.request.body = Bytes::from(rendered);
260            // Body-mutation convention: stale framing headers must go.
261            ctx.request.headers.remove("content-length");
262            ctx.request.headers.remove("content-encoding");
263        }
264
265        if let Some(template) = &self.response {
266            // Decode a compressed upstream body before parsing it.
267            let encoding = match ctx
268                .response
269                .headers
270                .get("content-encoding")
271                .and_then(|v| v.first())
272                .map(|v| ContentEncoding::parse(v))
273                .transpose()
274            {
275                Ok(enc) => enc.flatten(),
276                Err(e) => return Err(self.fail(ctx, 502, format!("response {}", e))),
277            };
278            let body = match encoding {
279                Some(enc) => match content_codec::decode(&enc, &ctx.response.body) {
280                    Ok(decoded) => decoded,
281                    Err(e) => return Err(self.fail(ctx, 502, format!("response {}", e))),
282                },
283                None => ctx.response.body.clone(),
284            };
285            let parsed = match parse_body(&body) {
286                Ok(v) => v,
287                Err(e) => return Err(self.fail(ctx, 502, format!("response {}", e))),
288            };
289            let rendered = render(&ctx, template, &parsed);
290            ctx.response.body = Bytes::from(rendered);
291            // Body left decoded: remove content-encoding along with the
292            // stale content-length (body-mutation convention).
293            ctx.response.headers.remove("content-length");
294            ctx.response.headers.remove("content-encoding");
295        }
296
297        Ok(PluginOutput {
298            context: ctx,
299            named_outputs: HashMap::new(),
300        })
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
308
309    fn test_context(req_body: &str, resp_body: &str) -> Context {
310        let mut headers = HashMap::new();
311        headers.insert("x-request-id".to_string(), vec!["req-42".to_string()]);
312        headers.insert(
313            "content-length".to_string(),
314            vec![req_body.len().to_string()],
315        );
316        let mut query = HashMap::new();
317        query.insert("page".to_string(), vec!["3".to_string()]);
318        let mut resp_headers = HashMap::new();
319        resp_headers.insert(
320            "content-length".to_string(),
321            vec![resp_body.len().to_string()],
322        );
323
324        Context {
325            request: GatewayRequest {
326                method: "POST".to_string(),
327                path: "/api/users".to_string(),
328                host: "localhost".to_string(),
329                scheme: "http".to_string(),
330                headers,
331                query_params: query,
332                body: Bytes::from(req_body.to_string()),
333                remote_addr: "127.0.0.1:12345".to_string(),
334                protocol: Protocol::Http1,
335            },
336            response: GatewayResponse {
337                status_code: 200,
338                headers: resp_headers,
339                body: Bytes::from(resp_body.to_string()),
340            },
341            message: HashMap::new(),
342            errors: Vec::new(),
343        }
344    }
345
346    fn request_plugin(template: &str) -> BodyTransformerPlugin {
347        let mut config = HashMap::new();
348        config.insert(
349            "request".to_string(),
350            serde_json::json!({ "template": template }),
351        );
352        BodyTransformerPlugin::from_config(&config).unwrap()
353    }
354
355    #[tokio::test]
356    async fn test_body_transformer_request_template() {
357        let p = request_plugin(
358            r#"{"name":"{{body.user.name}}","first_tag":"{{body.user.tags.0}}","count":{{body.count}},"uri":"$uri","trace":"{{$http_x_request_id}}"}"#,
359        );
360        let ctx = test_context(r#"{"user":{"name":"jack","tags":["a","b"]},"count":7}"#, "");
361        let out = p.execute(ctx, &HashMap::new()).await.unwrap();
362        let parsed: serde_json::Value = serde_json::from_slice(&out.context.request.body).unwrap();
363        assert_eq!(parsed["name"], "jack");
364        assert_eq!(parsed["first_tag"], "a");
365        assert_eq!(parsed["count"], 7);
366        assert_eq!(parsed["uri"], "/api/users");
367        assert_eq!(parsed["trace"], "req-42");
368        assert!(!out.context.request.headers.contains_key("content-length"));
369    }
370
371    #[tokio::test]
372    async fn test_body_transformer_missing_fields_render_empty() {
373        let p = request_plugin("[{{body.missing.deep}}][{{body.n}}][{{$arg_nope}}]");
374        let ctx = test_context(r#"{"n":null}"#, "");
375        let out = p.execute(ctx, &HashMap::new()).await.unwrap();
376        assert_eq!(out.context.request.body, Bytes::from("[][][]"));
377    }
378
379    #[tokio::test]
380    async fn test_body_transformer_whole_body_and_containers() {
381        let p = request_plugin(r#"{"wrapped":{{body}},"list":{{body.list}}}"#);
382        let ctx = test_context(r#"{"list":[1,2]}"#, "");
383        let out = p.execute(ctx, &HashMap::new()).await.unwrap();
384        let parsed: serde_json::Value = serde_json::from_slice(&out.context.request.body).unwrap();
385        assert_eq!(parsed["wrapped"], serde_json::json!({"list": [1, 2]}));
386        assert_eq!(parsed["list"], serde_json::json!([1, 2]));
387    }
388
389    #[tokio::test]
390    async fn test_body_transformer_empty_body_renders() {
391        let p = request_plugin(r#"{"q":"$arg_page","body_was":"{{body}}"}"#);
392        let ctx = test_context("", "");
393        let out = p.execute(ctx, &HashMap::new()).await.unwrap();
394        let parsed: serde_json::Value = serde_json::from_slice(&out.context.request.body).unwrap();
395        assert_eq!(parsed["q"], "3");
396        assert_eq!(parsed["body_was"], "");
397    }
398
399    #[tokio::test]
400    async fn test_body_transformer_invalid_request_body_errors() {
401        let p = request_plugin("{{body.a}}");
402        let ctx = test_context("not json", "");
403        let err = p.execute(ctx, &HashMap::new()).await.unwrap_err();
404        assert_eq!(err.error.code, "BODY_DECODE_FAILED");
405        assert_eq!(err.context.response.status_code, 400);
406    }
407
408    #[tokio::test]
409    async fn test_body_transformer_response_template() {
410        let mut config = HashMap::new();
411        config.insert(
412            "response".to_string(),
413            serde_json::json!({ "template": r#"{"status":"{{body.result}}","code":$status}"# }),
414        );
415        let p = BodyTransformerPlugin::from_config(&config).unwrap();
416        let ctx = test_context("", r#"{"result":"ok"}"#);
417        let out = p.execute(ctx, &HashMap::new()).await.unwrap();
418        let parsed: serde_json::Value = serde_json::from_slice(&out.context.response.body).unwrap();
419        assert_eq!(parsed["status"], "ok");
420        assert_eq!(parsed["code"], 200);
421        assert!(!out.context.response.headers.contains_key("content-length"));
422    }
423
424    #[tokio::test]
425    async fn test_body_transformer_response_gzip_decoded() {
426        let mut config = HashMap::new();
427        config.insert(
428            "response".to_string(),
429            serde_json::json!({ "template": "value={{body.v}}" }),
430        );
431        let p = BodyTransformerPlugin::from_config(&config).unwrap();
432        let mut ctx = test_context("", "");
433        ctx.response.body =
434            content_codec::encode(&ContentEncoding::Gzip, &Bytes::from(r#"{"v":9}"#), 6).unwrap();
435        ctx.response
436            .headers
437            .insert("content-encoding".to_string(), vec!["gzip".to_string()]);
438        let out = p.execute(ctx, &HashMap::new()).await.unwrap();
439        assert_eq!(out.context.response.body, Bytes::from("value=9"));
440        // decoded body left plain -> encoding header removed
441        assert!(!out
442            .context
443            .response
444            .headers
445            .contains_key("content-encoding"));
446    }
447
448    #[test]
449    fn test_body_transformer_config_rejections() {
450        let bad = [
451            // neither request nor response
452            serde_json::json!({}),
453            // missing template
454            serde_json::json!({ "request": {} }),
455            // unsupported input_format (documented deviation)
456            serde_json::json!({ "request": { "template": "x", "input_format": "xml" } }),
457            // base64 templates unsupported (documented deviation)
458            serde_json::json!({ "request": { "template": "eA==", "template_is_base64": true } }),
459            // unclosed placeholder
460            serde_json::json!({ "request": { "template": "{{body.a" } }),
461            // transform must be an object
462            serde_json::json!({ "request": "template" }),
463        ];
464        for case in bad {
465            let config: HashMap<String, serde_json::Value> =
466                serde_json::from_value(case.clone()).unwrap();
467            assert!(
468                BodyTransformerPlugin::from_config(&config).is_err(),
469                "should reject: {case}"
470            );
471        }
472    }
473}