1use 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
35pub struct BodyTransformerPlugin {
50 request: Option<String>,
51 response: Option<String>,
52}
53
54fn 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 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
107fn 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
122fn 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
134fn 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 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 rest = &after[close + 2..];
162 }
163 None => {
164 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
179fn 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 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 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 ctx.request.headers.remove("content-length");
262 ctx.request.headers.remove("content-encoding");
263 }
264
265 if let Some(template) = &self.response {
266 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 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 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 serde_json::json!({}),
453 serde_json::json!({ "request": {} }),
455 serde_json::json!({ "request": { "template": "x", "input_format": "xml" } }),
457 serde_json::json!({ "request": { "template": "eA==", "template_is_base64": true } }),
459 serde_json::json!({ "request": { "template": "{{body.a" } }),
461 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}