Skip to main content

featherbit/plugins/native/
exit_transformer.rs

1//! The `exit-transformer` node — reshapes gateway-generated responses
2//! ("exits": auth rejections, rate-limit denials, upstream failures, ...)
3//! with a status-code remap and a body template. Reinterpreted subset of
4//! APISIX's `exit-transformer` plugin (response-phase: place after
5//! `upstream`, before `client`).
6//!
7//! **This is a deliberate reinterpretation, not a faithful port.** APISIX's
8//! plugin registers user-supplied *Lua functions* that receive
9//! `(code, body, headers)` and return replacements. Arbitrary scripted
10//! transformation belongs to featherbit's `script` node; this node keeps the
11//! declarative core of the idea:
12//! - `status_map` remaps exit status codes (e.g. hide a 502 as a 503), and
13//! - `body` renders a `$var` template (including `$status`) as the new body,
14//!
15//! applied only to responses the gateway itself generated (heuristic:
16//! `Context.errors` non-empty), or unconditionally with `always: true`.
17
18use 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
26/// Remaps `Context.response.status_code` via `status_map` and/or replaces
27/// the body with an interpolated `$var` template — but only when the
28/// response was generated by the gateway (`Context.errors` non-empty),
29/// unless `always` is set. This plugin never fails at execution time.
30pub struct ExitTransformerPlugin {
31    status_map: HashMap<u16, u16>,
32    body: Option<String>,
33    always: bool,
34}
35
36/// Validates one status code (map key or value): an integer within 100-599.
37fn 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    /// Builds the plugin from node config.
52    ///
53    /// Accepted keys (all optional):
54    /// - `status_map` (map of status → status, e.g. `"502": 503`): when the
55    ///   response status matches a key, it is replaced by the value. Keys
56    ///   are strings (YAML/JSON object keys), values integers; both must be
57    ///   within 100-599.
58    /// - `body` (string): replacement body template with `$var` / `${var}`
59    ///   interpolation — `$status` (the status *after* remapping), `$uri`,
60    ///   `$host`, `$http_<name>`, `$msg_<key>`, and every other variable
61    ///   from [`crate::vars`].
62    /// - `always` (bool, default `false`): apply to every response. When
63    ///   false, only responses generated by the gateway itself — heuristic:
64    ///   `Context.errors` is non-empty — are transformed; clean upstream
65    ///   responses pass through untouched.
66    ///
67    /// ```yaml
68    /// type: exit-transformer
69    /// config:
70    ///   status_map:
71    ///     "401": 403
72    ///     "502": 503
73    ///   body: '{"status": $status, "path": "$uri"}'
74    /// ```
75    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        // Gate: gateway-generated exits only, unless `always`.
122        let applies = self.always || !ctx.errors.is_empty();
123
124        if applies {
125            // Remap first so a $status in the body template reflects the
126            // final, client-visible status code.
127            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                // Body-mutation convention: recompute length; the rendered
135                // template is not encoded.
136                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        // $status reflects the remapped status.
206        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        // 502 from the upstream itself: no gateway errors → passthrough.
220        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        // No body template configured → body untouched.
241        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}