Skip to main content

featherbit/plugins/native/
exit_transformer.rs

1//! The `exit-transformer` node — reshapes gateway-generated responses
2//! ("exits") with a status-code remap and a body template. Reinterpreted
3//! subset of APISIX's `exit-transformer` plugin (response-phase: place after
4//! `upstream`, before `client`).
5//!
6//! ## Which exits it applies to
7//!
8//! By default the node acts only on responses whose context carries an
9//! **error record** (`Context.errors` non-empty) — a failed upstream, a failed
10//! auth callout, an `error-handler`-rendered body. A deliberate rejection that
11//! left its node on an *outcome* port (`denied`, `limited`, `broken`, `abort`,
12//! `redirect`, ...) carries **no** error record and is therefore **not**
13//! transformed unless `always: true` is set.
14//!
15//! So to reshape denials and throttles, set `always: true` and put the node on
16//! the path the outcome port takes:
17//!
18//! ```yaml
19//! nodes:
20//!   - id: shape-exits
21//!     type: exit-transformer
22//!     config:
23//!       always: true                 # required: a denial carries no error record
24//!       status_map: { "401": 403 }
25//!       body: '{"status": $status, "path": "$uri"}'
26//! edges:
27//!   - from: auth.denied
28//!     to: shape-exits.in             # not straight to client
29//!   - from: shape-exits.success
30//!     to: client.in
31//! ```
32//!
33//! With `always: true` the node transforms *every* response reaching it,
34//! clean upstream replies included — so give it its own branch as above
35//! rather than putting it on the main success path.
36//!
37//! **This is a deliberate reinterpretation, not a faithful port.** APISIX's
38//! plugin registers user-supplied *Lua functions* that receive
39//! `(code, body, headers)` and return replacements. Arbitrary scripted
40//! transformation belongs to featherbit's `script` node; this node keeps the
41//! declarative core of the idea:
42//! - `status_map` remaps exit status codes (e.g. hide a 502 as a 503), and
43//! - `body` renders a `{{namespace.path}}` template (plus legacy `$var`
44//!   interpolation, including `$status`) as the new body,
45//!
46//! applied only to responses the gateway itself generated (heuristic:
47//! `Context.errors` non-empty), or unconditionally with `always: true`.
48
49use async_trait::async_trait;
50use bytes::Bytes;
51use std::collections::HashMap;
52
53use crate::context::Context;
54use crate::plugins::{Plugin, PluginOutput, PluginResult};
55use crate::vars::template::Template;
56
57/// Remaps `Context.response.status_code` via `status_map` and/or replaces
58/// the body with an interpolated `{{namespace.path}}` (plus legacy `$var`)
59/// template — but only when the response was generated by the gateway
60/// (`Context.errors` non-empty), unless `always` is set. This plugin never
61/// fails at execution time.
62pub struct ExitTransformerPlugin {
63    status_map: HashMap<u16, u16>,
64    /// Body template: supports `{{namespace.path}}` references and legacy
65    /// `$var` interpolation (see [`Template::render_with_legacy`]).
66    body: Option<Template>,
67    always: bool,
68}
69
70/// Validates one status code (map key or value): an integer within 100-599.
71fn parse_status(what: &str, raw: &str, value: Option<u64>) -> Result<u16, String> {
72    let n = value
73        .or_else(|| raw.parse().ok())
74        .ok_or_else(|| format!("status_map {} '{}' must be an integer", what, raw))?;
75    if !(100..=599).contains(&n) {
76        return Err(format!(
77            "status_map {} '{}' must be within 100-599",
78            what, raw
79        ));
80    }
81    Ok(n as u16)
82}
83
84impl ExitTransformerPlugin {
85    /// Builds the plugin from node config.
86    ///
87    /// Accepted keys (all optional):
88    /// - `status_map` (map of status → status, e.g. `"502": 503`): when the
89    ///   response status matches a key, it is replaced by the value. Keys
90    ///   are strings (YAML/JSON object keys), values integers; both must be
91    ///   within 100-599.
92    /// - `body` (string): replacement body template. Supports
93    ///   `{{namespace.path}}` references plus legacy `$var` / `${var}`
94    ///   interpolation — `$status` (the status *after* remapping), `$uri`,
95    ///   `$host`, `$http_<name>`, `$msg_<key>`, and every other variable
96    ///   from [`crate::vars`].
97    /// - `always` (bool, default `false`): apply to every response. When
98    ///   false, only responses generated by the gateway itself — heuristic:
99    ///   `Context.errors` is non-empty — are transformed; clean upstream
100    ///   responses pass through untouched.
101    ///
102    /// ```yaml
103    /// type: exit-transformer
104    /// config:
105    ///   status_map:
106    ///     "401": 403
107    ///     "502": 503
108    ///   body: '{"status": $status, "path": "$uri"}'
109    /// ```
110    pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
111        let mut status_map = HashMap::new();
112        if let Some(raw) = config.get("status_map") {
113            let obj = raw
114                .as_object()
115                .ok_or("status_map must be a map of status -> status".to_string())?;
116            for (key, value) in obj {
117                let from = parse_status("key", key, None)?;
118                let to = parse_status("value", &value.to_string(), value.as_u64())?;
119                status_map.insert(from, to);
120            }
121        }
122
123        let body = match config.get("body") {
124            None => None,
125            Some(v) => {
126                let s = v.as_str().ok_or("body must be a string".to_string())?;
127                // Discard warnings here — the compile-time walk (a later
128                // task) reports well-formed-but-unknown references;
129                // execution must not.
130                Some(Template::parse(s).0)
131            }
132        };
133
134        let always = config
135            .get("always")
136            .and_then(|v| v.as_bool())
137            .unwrap_or(false);
138
139        Ok(Self {
140            status_map,
141            body,
142            always,
143        })
144    }
145}
146
147#[async_trait]
148impl Plugin for ExitTransformerPlugin {
149    fn plugin_type(&self) -> &str {
150        "exit-transformer"
151    }
152
153    async fn execute(&self, mut ctx: Context) -> PluginResult {
154        // Gate: gateway-generated exits only, unless `always`.
155        let applies = self.always || !ctx.errors.is_empty();
156
157        if applies {
158            // Remap first so a $status in the body template reflects the
159            // final, client-visible status code.
160            if let Some(&new_status) = self.status_map.get(&ctx.response.status_code) {
161                ctx.response.status_code = new_status;
162            }
163
164            if let Some(template) = &self.body {
165                let body = template.render_with_legacy(&ctx);
166                ctx.response.body = Bytes::from(body);
167                // Body-mutation convention: recompute length; the rendered
168                // template is not encoded.
169                ctx.response.headers.remove("content-length");
170                ctx.response.headers.remove("content-encoding");
171            }
172        }
173
174        Ok(PluginOutput::success(ctx))
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::context::{GatewayError, GatewayRequest, GatewayResponse, Protocol};
182
183    fn test_context(status: u16, gateway_generated: bool) -> Context {
184        let mut response_headers = HashMap::new();
185        response_headers.insert("content-length".to_string(), vec!["8".to_string()]);
186        let errors = if gateway_generated {
187            vec![GatewayError {
188                node_id: "auth-1".to_string(),
189                code: "UNAUTHORIZED".to_string(),
190                message: "missing credentials".to_string(),
191                metadata: HashMap::new(),
192            }]
193        } else {
194            Vec::new()
195        };
196        Context {
197            request: GatewayRequest {
198                method: "GET".to_string(),
199                path: "/api/users".to_string(),
200                host: "localhost".to_string(),
201                scheme: "http".to_string(),
202                headers: HashMap::new(),
203                query_params: HashMap::new(),
204                body: Bytes::new(),
205                remote_addr: "127.0.0.1:12345".to_string(),
206                protocol: Protocol::Http1,
207            },
208            response: GatewayResponse {
209                status_code: status,
210                headers: response_headers,
211                body: Bytes::from_static(b"original"),
212                stream: None,
213            },
214            message: HashMap::new(),
215            errors,
216        }
217    }
218
219    fn plugin(config: serde_json::Value) -> ExitTransformerPlugin {
220        let map: HashMap<String, serde_json::Value> =
221            serde_json::from_value(config).expect("test config must be an object");
222        ExitTransformerPlugin::from_config(&map).expect("config should be valid")
223    }
224
225    #[tokio::test]
226    async fn test_exit_transformer_remaps_status_and_body() {
227        let p = plugin(serde_json::json!({
228            "status_map": { "502": 503 },
229            "body": "{\"status\": $status, \"path\": \"$uri\"}"
230        }));
231        let out = p.execute(test_context(502, true)).await.unwrap();
232        assert_eq!(out.context.response.status_code, 503);
233        // $status reflects the remapped status.
234        assert_eq!(
235            out.context.response.body.as_ref(),
236            b"{\"status\": 503, \"path\": \"/api/users\"}"
237        );
238        assert!(!out.context.response.headers.contains_key("content-length"));
239    }
240
241    #[tokio::test]
242    async fn test_exit_transformer_skips_upstream_responses() {
243        let p = plugin(serde_json::json!({
244            "status_map": { "502": 503 },
245            "body": "transformed"
246        }));
247        // 502 from the upstream itself: no gateway errors → passthrough.
248        let out = p.execute(test_context(502, false)).await.unwrap();
249        assert_eq!(out.context.response.status_code, 502);
250        assert_eq!(out.context.response.body.as_ref(), b"original");
251        assert!(out.context.response.headers.contains_key("content-length"));
252    }
253
254    #[tokio::test]
255    async fn test_exit_transformer_always_applies_unconditionally() {
256        let p = plugin(serde_json::json!({
257            "status_map": { "502": 503 },
258            "always": true
259        }));
260        let out = p.execute(test_context(502, false)).await.unwrap();
261        assert_eq!(out.context.response.status_code, 503);
262        // No body template configured → body untouched.
263        assert_eq!(out.context.response.body.as_ref(), b"original");
264        assert!(out.context.response.headers.contains_key("content-length"));
265    }
266
267    #[tokio::test]
268    async fn test_exit_transformer_unmapped_status_kept() {
269        let p = plugin(serde_json::json!({
270            "status_map": { "502": 503 }
271        }));
272        let out = p.execute(test_context(401, true)).await.unwrap();
273        assert_eq!(out.context.response.status_code, 401);
274    }
275
276    #[test]
277    fn test_exit_transformer_config_validation() {
278        for bad in [
279            serde_json::json!({ "status_map": ["not", "a", "map"] }),
280            serde_json::json!({ "status_map": { "abc": 503 } }),
281            serde_json::json!({ "status_map": { "99": 503 } }),
282            serde_json::json!({ "status_map": { "502": 600 } }),
283            serde_json::json!({ "status_map": { "502": "not a number" } }),
284            serde_json::json!({ "body": 42 }),
285        ] {
286            let map: HashMap<String, serde_json::Value> =
287                serde_json::from_value(bad.clone()).unwrap();
288            assert!(
289                ExitTransformerPlugin::from_config(&map).is_err(),
290                "should reject: {bad}"
291            );
292        }
293    }
294}