featherbit/plugins/native/
exit_transformer.rs1use async_trait::async_trait;
19use bytes::Bytes;
20use std::collections::HashMap;
21
22use crate::context::Context;
23use crate::plugins::{Plugin, PluginOutput, PluginResult};
24use crate::vars;
25
26pub struct ExitTransformerPlugin {
31 status_map: HashMap<u16, u16>,
32 body: Option<String>,
33 always: bool,
34}
35
36fn parse_status(what: &str, raw: &str, value: Option<u64>) -> Result<u16, String> {
38 let n = value
39 .or_else(|| raw.parse().ok())
40 .ok_or_else(|| format!("status_map {} '{}' must be an integer", what, raw))?;
41 if !(100..=599).contains(&n) {
42 return Err(format!(
43 "status_map {} '{}' must be within 100-599",
44 what, raw
45 ));
46 }
47 Ok(n as u16)
48}
49
50impl ExitTransformerPlugin {
51 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
76 let mut status_map = HashMap::new();
77 if let Some(raw) = config.get("status_map") {
78 let obj = raw
79 .as_object()
80 .ok_or("status_map must be a map of status -> status".to_string())?;
81 for (key, value) in obj {
82 let from = parse_status("key", key, None)?;
83 let to = parse_status("value", &value.to_string(), value.as_u64())?;
84 status_map.insert(from, to);
85 }
86 }
87
88 let body = match config.get("body") {
89 None => None,
90 Some(v) => Some(
91 v.as_str()
92 .ok_or("body must be a string".to_string())?
93 .to_string(),
94 ),
95 };
96
97 let always = config
98 .get("always")
99 .and_then(|v| v.as_bool())
100 .unwrap_or(false);
101
102 Ok(Self {
103 status_map,
104 body,
105 always,
106 })
107 }
108}
109
110#[async_trait]
111impl Plugin for ExitTransformerPlugin {
112 fn plugin_type(&self) -> &str {
113 "exit-transformer"
114 }
115
116 async fn execute(
117 &self,
118 mut ctx: Context,
119 _named_inputs: &HashMap<String, serde_json::Value>,
120 ) -> PluginResult {
121 let applies = self.always || !ctx.errors.is_empty();
123
124 if applies {
125 if let Some(&new_status) = self.status_map.get(&ctx.response.status_code) {
128 ctx.response.status_code = new_status;
129 }
130
131 if let Some(template) = &self.body {
132 let body = vars::interpolate(&ctx, template);
133 ctx.response.body = Bytes::from(body);
134 ctx.response.headers.remove("content-length");
137 ctx.response.headers.remove("content-encoding");
138 }
139 }
140
141 Ok(PluginOutput {
142 context: ctx,
143 named_outputs: HashMap::new(),
144 })
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use crate::context::{GatewayError, GatewayRequest, GatewayResponse, Protocol};
152
153 fn test_context(status: u16, gateway_generated: bool) -> Context {
154 let mut response_headers = HashMap::new();
155 response_headers.insert("content-length".to_string(), vec!["8".to_string()]);
156 let errors = if gateway_generated {
157 vec![GatewayError {
158 node_id: "auth-1".to_string(),
159 code: "UNAUTHORIZED".to_string(),
160 message: "missing credentials".to_string(),
161 metadata: HashMap::new(),
162 }]
163 } else {
164 Vec::new()
165 };
166 Context {
167 request: GatewayRequest {
168 method: "GET".to_string(),
169 path: "/api/users".to_string(),
170 host: "localhost".to_string(),
171 scheme: "http".to_string(),
172 headers: HashMap::new(),
173 query_params: HashMap::new(),
174 body: Bytes::new(),
175 remote_addr: "127.0.0.1:12345".to_string(),
176 protocol: Protocol::Http1,
177 },
178 response: GatewayResponse {
179 status_code: status,
180 headers: response_headers,
181 body: Bytes::from_static(b"original"),
182 },
183 message: HashMap::new(),
184 errors,
185 }
186 }
187
188 fn plugin(config: serde_json::Value) -> ExitTransformerPlugin {
189 let map: HashMap<String, serde_json::Value> =
190 serde_json::from_value(config).expect("test config must be an object");
191 ExitTransformerPlugin::from_config(&map).expect("config should be valid")
192 }
193
194 #[tokio::test]
195 async fn test_exit_transformer_remaps_status_and_body() {
196 let p = plugin(serde_json::json!({
197 "status_map": { "502": 503 },
198 "body": "{\"status\": $status, \"path\": \"$uri\"}"
199 }));
200 let out = p
201 .execute(test_context(502, true), &HashMap::new())
202 .await
203 .unwrap();
204 assert_eq!(out.context.response.status_code, 503);
205 assert_eq!(
207 out.context.response.body.as_ref(),
208 b"{\"status\": 503, \"path\": \"/api/users\"}"
209 );
210 assert!(!out.context.response.headers.contains_key("content-length"));
211 }
212
213 #[tokio::test]
214 async fn test_exit_transformer_skips_upstream_responses() {
215 let p = plugin(serde_json::json!({
216 "status_map": { "502": 503 },
217 "body": "transformed"
218 }));
219 let out = p
221 .execute(test_context(502, false), &HashMap::new())
222 .await
223 .unwrap();
224 assert_eq!(out.context.response.status_code, 502);
225 assert_eq!(out.context.response.body.as_ref(), b"original");
226 assert!(out.context.response.headers.contains_key("content-length"));
227 }
228
229 #[tokio::test]
230 async fn test_exit_transformer_always_applies_unconditionally() {
231 let p = plugin(serde_json::json!({
232 "status_map": { "502": 503 },
233 "always": true
234 }));
235 let out = p
236 .execute(test_context(502, false), &HashMap::new())
237 .await
238 .unwrap();
239 assert_eq!(out.context.response.status_code, 503);
240 assert_eq!(out.context.response.body.as_ref(), b"original");
242 assert!(out.context.response.headers.contains_key("content-length"));
243 }
244
245 #[tokio::test]
246 async fn test_exit_transformer_unmapped_status_kept() {
247 let p = plugin(serde_json::json!({
248 "status_map": { "502": 503 }
249 }));
250 let out = p
251 .execute(test_context(401, true), &HashMap::new())
252 .await
253 .unwrap();
254 assert_eq!(out.context.response.status_code, 401);
255 }
256
257 #[test]
258 fn test_exit_transformer_config_validation() {
259 for bad in [
260 serde_json::json!({ "status_map": ["not", "a", "map"] }),
261 serde_json::json!({ "status_map": { "abc": 503 } }),
262 serde_json::json!({ "status_map": { "99": 503 } }),
263 serde_json::json!({ "status_map": { "502": 600 } }),
264 serde_json::json!({ "status_map": { "502": "not a number" } }),
265 serde_json::json!({ "body": 42 }),
266 ] {
267 let map: HashMap<String, serde_json::Value> =
268 serde_json::from_value(bad.clone()).unwrap();
269 assert!(
270 ExitTransformerPlugin::from_config(&map).is_err(),
271 "should reject: {bad}"
272 );
273 }
274 }
275}