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}