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(&self, mut ctx: Context) -> PluginResult {
249 if let Some(template) = &self.request {
250 let parsed = match parse_body(&ctx.request.body) {
251 Ok(v) => v,
252 Err(e) => return Err(self.fail(ctx, 400, format!("request {}", e))),
253 };
254 let rendered = render(&ctx, template, &parsed);
255 ctx.request.body = Bytes::from(rendered);
256 ctx.request.headers.remove("content-length");
258 ctx.request.headers.remove("content-encoding");
259 }
260
261 if let Some(template) = &self.response {
262 let encoding = match ctx
264 .response
265 .headers
266 .get("content-encoding")
267 .and_then(|v| v.first())
268 .map(|v| ContentEncoding::parse(v))
269 .transpose()
270 {
271 Ok(enc) => enc.flatten(),
272 Err(e) => return Err(self.fail(ctx, 502, format!("response {}", e))),
273 };
274 let body = match encoding {
275 Some(enc) => match content_codec::decode(&enc, &ctx.response.body) {
276 Ok(decoded) => decoded,
277 Err(e) => return Err(self.fail(ctx, 502, format!("response {}", e))),
278 },
279 None => ctx.response.body.clone(),
280 };
281 let parsed = match parse_body(&body) {
282 Ok(v) => v,
283 Err(e) => return Err(self.fail(ctx, 502, format!("response {}", e))),
284 };
285 let rendered = render(&ctx, template, &parsed);
286 ctx.response.body = Bytes::from(rendered);
287 ctx.response.headers.remove("content-length");
290 ctx.response.headers.remove("content-encoding");
291 }
292
293 Ok(PluginOutput::success(ctx))
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
301
302 fn test_context(req_body: &str, resp_body: &str) -> Context {
303 let mut headers = HashMap::new();
304 headers.insert("x-request-id".to_string(), vec!["req-42".to_string()]);
305 headers.insert(
306 "content-length".to_string(),
307 vec![req_body.len().to_string()],
308 );
309 let mut query = HashMap::new();
310 query.insert("page".to_string(), vec!["3".to_string()]);
311 let mut resp_headers = HashMap::new();
312 resp_headers.insert(
313 "content-length".to_string(),
314 vec![resp_body.len().to_string()],
315 );
316
317 Context {
318 request: GatewayRequest {
319 method: "POST".to_string(),
320 path: "/api/users".to_string(),
321 host: "localhost".to_string(),
322 scheme: "http".to_string(),
323 headers,
324 query_params: query,
325 body: Bytes::from(req_body.to_string()),
326 remote_addr: "127.0.0.1:12345".to_string(),
327 protocol: Protocol::Http1,
328 },
329 response: GatewayResponse {
330 status_code: 200,
331 headers: resp_headers,
332 body: Bytes::from(resp_body.to_string()),
333 stream: None,
334 },
335 message: HashMap::new(),
336 errors: Vec::new(),
337 }
338 }
339
340 fn request_plugin(template: &str) -> BodyTransformerPlugin {
341 let mut config = HashMap::new();
342 config.insert(
343 "request".to_string(),
344 serde_json::json!({ "template": template }),
345 );
346 BodyTransformerPlugin::from_config(&config).unwrap()
347 }
348
349 #[tokio::test]
350 async fn test_body_transformer_request_template() {
351 let p = request_plugin(
352 r#"{"name":"{{body.user.name}}","first_tag":"{{body.user.tags.0}}","count":{{body.count}},"uri":"$uri","trace":"{{$http_x_request_id}}"}"#,
353 );
354 let ctx = test_context(r#"{"user":{"name":"jack","tags":["a","b"]},"count":7}"#, "");
355 let out = p.execute(ctx).await.unwrap();
356 let parsed: serde_json::Value = serde_json::from_slice(&out.context.request.body).unwrap();
357 assert_eq!(parsed["name"], "jack");
358 assert_eq!(parsed["first_tag"], "a");
359 assert_eq!(parsed["count"], 7);
360 assert_eq!(parsed["uri"], "/api/users");
361 assert_eq!(parsed["trace"], "req-42");
362 assert!(!out.context.request.headers.contains_key("content-length"));
363 }
364
365 #[tokio::test]
366 async fn test_body_transformer_missing_fields_render_empty() {
367 let p = request_plugin("[{{body.missing.deep}}][{{body.n}}][{{$arg_nope}}]");
368 let ctx = test_context(r#"{"n":null}"#, "");
369 let out = p.execute(ctx).await.unwrap();
370 assert_eq!(out.context.request.body, Bytes::from("[][][]"));
371 }
372
373 #[tokio::test]
374 async fn test_body_transformer_whole_body_and_containers() {
375 let p = request_plugin(r#"{"wrapped":{{body}},"list":{{body.list}}}"#);
376 let ctx = test_context(r#"{"list":[1,2]}"#, "");
377 let out = p.execute(ctx).await.unwrap();
378 let parsed: serde_json::Value = serde_json::from_slice(&out.context.request.body).unwrap();
379 assert_eq!(parsed["wrapped"], serde_json::json!({"list": [1, 2]}));
380 assert_eq!(parsed["list"], serde_json::json!([1, 2]));
381 }
382
383 #[tokio::test]
384 async fn test_body_transformer_empty_body_renders() {
385 let p = request_plugin(r#"{"q":"$arg_page","body_was":"{{body}}"}"#);
386 let ctx = test_context("", "");
387 let out = p.execute(ctx).await.unwrap();
388 let parsed: serde_json::Value = serde_json::from_slice(&out.context.request.body).unwrap();
389 assert_eq!(parsed["q"], "3");
390 assert_eq!(parsed["body_was"], "");
391 }
392
393 #[tokio::test]
394 async fn test_body_transformer_invalid_request_body_errors() {
395 let p = request_plugin("{{body.a}}");
396 let ctx = test_context("not json", "");
397 let err = p.execute(ctx).await.unwrap_err();
398 assert_eq!(err.error.code, "BODY_DECODE_FAILED");
399 assert_eq!(err.context.response.status_code, 400);
400 }
401
402 #[tokio::test]
403 async fn test_body_transformer_response_template() {
404 let mut config = HashMap::new();
405 config.insert(
406 "response".to_string(),
407 serde_json::json!({ "template": r#"{"status":"{{body.result}}","code":$status}"# }),
408 );
409 let p = BodyTransformerPlugin::from_config(&config).unwrap();
410 let ctx = test_context("", r#"{"result":"ok"}"#);
411 let out = p.execute(ctx).await.unwrap();
412 let parsed: serde_json::Value = serde_json::from_slice(&out.context.response.body).unwrap();
413 assert_eq!(parsed["status"], "ok");
414 assert_eq!(parsed["code"], 200);
415 assert!(!out.context.response.headers.contains_key("content-length"));
416 }
417
418 #[tokio::test]
419 async fn test_body_transformer_response_gzip_decoded() {
420 let mut config = HashMap::new();
421 config.insert(
422 "response".to_string(),
423 serde_json::json!({ "template": "value={{body.v}}" }),
424 );
425 let p = BodyTransformerPlugin::from_config(&config).unwrap();
426 let mut ctx = test_context("", "");
427 ctx.response.body =
428 content_codec::encode(&ContentEncoding::Gzip, &Bytes::from(r#"{"v":9}"#), 6).unwrap();
429 ctx.response
430 .headers
431 .insert("content-encoding".to_string(), vec!["gzip".to_string()]);
432 let out = p.execute(ctx).await.unwrap();
433 assert_eq!(out.context.response.body, Bytes::from("value=9"));
434 assert!(!out
436 .context
437 .response
438 .headers
439 .contains_key("content-encoding"));
440 }
441
442 #[test]
443 fn test_body_transformer_config_rejections() {
444 let bad = [
445 serde_json::json!({}),
447 serde_json::json!({ "request": {} }),
449 serde_json::json!({ "request": { "template": "x", "input_format": "xml" } }),
451 serde_json::json!({ "request": { "template": "eA==", "template_is_base64": true } }),
453 serde_json::json!({ "request": { "template": "{{body.a" } }),
455 serde_json::json!({ "request": "template" }),
457 ];
458 for case in bad {
459 let config: HashMap<String, serde_json::Value> =
460 serde_json::from_value(case.clone()).unwrap();
461 assert!(
462 BodyTransformerPlugin::from_config(&config).is_err(),
463 "should reject: {case}"
464 );
465 }
466 }
467}