featherbit/plugins/native/
echo.rs1use 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
17pub struct EchoPlugin {
27 body: Option<String>,
29 before_body: Option<String>,
31 after_body: Option<String>,
33 headers: HashMap<String, String>,
35}
36
37fn 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 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 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 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 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 let current: Bytes = match &self.body {
150 Some(body) => Bytes::from(body.clone()),
152 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(¤t);
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 ctx.response.headers.remove("content-length");
184 ctx.response.headers.remove("content-encoding");
185
186 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 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 assert!(EchoPlugin::from_config(&config(serde_json::json!({"body": 5}))).is_err());
244
245 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 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 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 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 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}