featherbit/plugins/native/serverless_pre_function.rs
1//! The `serverless-pre-function` node — runs one or more inline Lua functions
2//! against the `Context`, threading it through each in sequence.
3//!
4//! Port of APISIX's `serverless-pre-function` plugin. In APISIX the two
5//! serverless plugins (`serverless-pre-function` / `serverless-post-function`)
6//! are identical except for the phase they run in; here they share all logic
7//! ([`ServerlessRunner`]) and differ only in their registered node-type name.
8//!
9//! ## Deviations from APISIX
10//!
11//! - **Function contract.** APISIX functions are `return function(conf, ctx)
12//! ... end` chunks invoked with `(conf, ctx)`. featherbit reuses the
13//! `script` plugin's Lua runtime, so each function is a script that defines a
14//! global `function execute(ctx) ... return ctx end`, receiving and returning
15//! the marshalled Context table (see the `script` plugin docs for the table
16//! shape). This is the same contract as the `script` node.
17//! - **Phase by graph position.** APISIX's `phase` field selects a request
18//! lifecycle phase. featherbit expresses phase through *placement in the
19//! policy graph*: a `serverless-pre-function` node is wired before the
20//! `upstream` node, a `serverless-post-function` node after it. The `phase`
21//! config key is accepted for compatibility but is inert.
22
23use async_trait::async_trait;
24use std::collections::HashMap;
25use std::path::PathBuf;
26
27use crate::context::Context;
28use crate::plugins::script::lua_runtime::LuaRuntime;
29use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
30
31/// Shared engine behind both serverless nodes: a compiled list of Lua
32/// functions run in order against the Context.
33///
34/// Each entry is a fully validated [`LuaRuntime`] (compiled at config load, so
35/// bad Lua fails policy compilation, not a live request). At request time the
36/// Context is threaded through every function: the table returned by one
37/// function's `execute` becomes the input to the next. If any function errors,
38/// that error is propagated (routing the Context through the node's `error`
39/// port); otherwise the final Context flows through the `success` port.
40pub struct ServerlessRunner {
41 functions: Vec<LuaRuntime>,
42 plugin_type: &'static str,
43}
44
45impl ServerlessRunner {
46 /// Builds the runner from node config, compiling every function up front.
47 ///
48 /// Accepted keys:
49 /// - `functions` (array of strings, **required**, ≥1): each string is Lua
50 /// source defining a global `execute(ctx)` function. Each is compiled and
51 /// validated here; an empty array, a non-string entry, a Lua syntax
52 /// error, or a missing `execute` all fail at config load.
53 /// - `phase` (string, optional): accepted for APISIX compatibility but
54 /// **inert** — placement in the graph determines phase.
55 /// - `timeout_ms` (integer, default `5000`): per-function execution timeout
56 /// passed to the Lua runtime (stored, not yet enforced by the VM).
57 pub fn from_config(
58 config: &HashMap<String, serde_json::Value>,
59 plugin_type: &'static str,
60 ) -> Result<Self, String> {
61 let raw = config
62 .get("functions")
63 .ok_or_else(|| format!("{}: 'functions' is required", plugin_type))?;
64
65 let arr = raw.as_array().ok_or_else(|| {
66 format!(
67 "{}: 'functions' must be an array of Lua strings",
68 plugin_type
69 )
70 })?;
71
72 if arr.is_empty() {
73 return Err(format!(
74 "{}: 'functions' must contain at least one function",
75 plugin_type
76 ));
77 }
78
79 let timeout_ms = config
80 .get("timeout_ms")
81 .and_then(|v| v.as_u64())
82 .unwrap_or(5000);
83
84 let modules_path: Option<PathBuf> = config
85 .get("modules_path")
86 .and_then(|v| v.as_str())
87 .map(PathBuf::from);
88
89 let mut functions = Vec::with_capacity(arr.len());
90 for (i, item) in arr.iter().enumerate() {
91 let src = item.as_str().ok_or_else(|| {
92 format!(
93 "{}: functions[{}] must be a Lua source string",
94 plugin_type, i
95 )
96 })?;
97 let rt = LuaRuntime::new(src, timeout_ms, modules_path.clone()).map_err(|e| {
98 format!("{}: functions[{}] failed to compile: {}", plugin_type, i, e)
99 })?;
100 functions.push(rt);
101 }
102
103 Ok(Self {
104 functions,
105 plugin_type,
106 })
107 }
108
109 /// Runs each function in order, threading the Context. Propagates the first
110 /// error encountered.
111 ///
112 /// Neither `serverless-pre-function` nor `serverless-post-function`
113 /// declares a `respond` port (or any outcome port) — they use the
114 /// default success/error pair. Each function therefore runs through
115 /// [`LuaRuntime::execute_without_port`], which rejects any second return
116 /// value other than `"success"` (e.g. `return ctx, "respond"`, or any
117 /// other name) as `LUA_BAD_PORT` rather than silently dropping it:
118 /// silently dropping it would let someone copy the `script` node's
119 /// respond idiom into a serverless function and get neither a response
120 /// nor an error. The context reported on the error is the one that went
121 /// *into* that function's `execute` call (not the mutated table it
122 /// returned), matching the `script` node's rule that a function which
123 /// did not finish making a decision keeps nothing it wrote.
124 pub fn run(&self, mut ctx: Context) -> Result<Context, PluginExecutionError> {
125 for func in &self.functions {
126 ctx = func.execute_without_port(ctx)?;
127 }
128 Ok(ctx)
129 }
130}
131
132/// The `serverless-pre-function` node. Runs its Lua functions before the
133/// upstream call (by convention of its graph placement).
134pub struct ServerlessPreFunctionPlugin {
135 runner: ServerlessRunner,
136}
137
138impl ServerlessPreFunctionPlugin {
139 /// Builds the plugin from node config.
140 ///
141 /// ```yaml
142 /// type: serverless-pre-function
143 /// config:
144 /// phase: access # accepted for compatibility; inert
145 /// timeout_ms: 2000
146 /// functions:
147 /// - |
148 /// function execute(ctx)
149 /// ctx.request.headers["x-serverless"] = {"pre"}
150 /// return ctx
151 /// end
152 /// ```
153 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
154 Ok(Self {
155 runner: ServerlessRunner::from_config(config, "serverless-pre-function")?,
156 })
157 }
158}
159
160#[async_trait]
161impl Plugin for ServerlessPreFunctionPlugin {
162 fn plugin_type(&self) -> &str {
163 self.runner.plugin_type
164 }
165
166 async fn execute(&self, ctx: Context) -> PluginResult {
167 let ctx = self.runner.run(ctx)?;
168 Ok(PluginOutput::success(ctx))
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
176
177 fn test_context() -> Context {
178 Context {
179 request: GatewayRequest {
180 method: "GET".to_string(),
181 path: "/test".to_string(),
182 host: "localhost".to_string(),
183 scheme: "http".to_string(),
184 headers: HashMap::new(),
185 query_params: HashMap::new(),
186 body: bytes::Bytes::new(),
187 remote_addr: "127.0.0.1:1234".to_string(),
188 protocol: Protocol::Http1,
189 },
190 response: GatewayResponse {
191 status_code: 0,
192 headers: HashMap::new(),
193 body: bytes::Bytes::new(),
194 stream: None,
195 },
196 message: HashMap::new(),
197 errors: Vec::new(),
198 }
199 }
200
201 fn cfg(functions: serde_json::Value) -> HashMap<String, serde_json::Value> {
202 let mut c = HashMap::new();
203 c.insert("functions".to_string(), functions);
204 c
205 }
206
207 #[tokio::test]
208 async fn test_serverless_pre_function_mutates_and_threads() {
209 // Two functions: first sets a header, second reads it into message.
210 let p = ServerlessPreFunctionPlugin::from_config(&cfg(serde_json::json!([
211 "function execute(ctx)\n ctx.request.headers[\"x-step\"] = {\"one\"}\n return ctx\nend",
212 "function execute(ctx)\n ctx.message.seen = ctx.request.headers[\"x-step\"][1]\n return ctx\nend"
213 ])))
214 .unwrap();
215
216 let out = p.execute(test_context()).await.unwrap();
217 assert_eq!(
218 out.context.request.headers.get("x-step"),
219 Some(&vec!["one".to_string()])
220 );
221 // ordering: second function observed the first's mutation
222 assert_eq!(
223 out.context.message.get("seen"),
224 Some(&serde_json::json!("one"))
225 );
226 }
227
228 #[tokio::test]
229 async fn test_serverless_pre_function_order() {
230 // Each function appends to a message array; order must be preserved.
231 let p = ServerlessPreFunctionPlugin::from_config(&cfg(serde_json::json!([
232 "function execute(ctx)\n ctx.message.trail = {\"a\"}\n return ctx\nend",
233 "function execute(ctx)\n local t = ctx.message.trail\n t[#t+1] = \"b\"\n ctx.message.trail = t\n return ctx\nend"
234 ])))
235 .unwrap();
236
237 let out = p.execute(test_context()).await.unwrap();
238 assert_eq!(
239 out.context.message.get("trail"),
240 Some(&serde_json::json!(["a", "b"]))
241 );
242 }
243
244 #[test]
245 fn test_serverless_pre_function_compile_error_fails_config() {
246 // Syntax error in Lua fails from_config.
247 let err = ServerlessPreFunctionPlugin::from_config(&cfg(serde_json::json!([
248 "function execute(ctx) this is not lua"
249 ])));
250 assert!(err.is_err());
251
252 // Missing execute function fails too.
253 let err =
254 ServerlessPreFunctionPlugin::from_config(&cfg(serde_json::json!(["local x = 1"])));
255 assert!(err.is_err());
256 }
257
258 #[test]
259 fn test_serverless_pre_function_empty_functions_rejected() {
260 assert!(
261 ServerlessPreFunctionPlugin::from_config(&cfg(serde_json::json!([])).clone()).is_err()
262 );
263 assert!(ServerlessPreFunctionPlugin::from_config(&HashMap::new()).is_err());
264 // non-string entry
265 assert!(ServerlessPreFunctionPlugin::from_config(&cfg(serde_json::json!([123]))).is_err());
266 }
267
268 #[tokio::test]
269 async fn test_serverless_pre_function_error_propagates() {
270 let p = ServerlessPreFunctionPlugin::from_config(&cfg(serde_json::json!([
271 "function execute(ctx)\n error(\"boom\")\n return ctx\nend"
272 ])))
273 .unwrap();
274 let err = p.execute(test_context()).await.unwrap_err();
275 assert_eq!(err.error.code, "LUA_EXECUTION_ERROR");
276 }
277
278 /// Neither serverless node declares a `respond` port: a function that
279 /// names one (the `script` node's idiom) must fail loudly, not be
280 /// silently dropped -- silently dropping it would give a script author
281 /// who copied that idiom into a serverless function no response and no
282 /// error. The mutated header must not survive onto the error context,
283 /// same rule as the `script` node's own bad-port case.
284 #[tokio::test]
285 async fn test_serverless_pre_function_named_port_is_lua_bad_port() {
286 let p = ServerlessPreFunctionPlugin::from_config(&cfg(serde_json::json!([
287 "function execute(ctx)\n ctx.request.headers[\"x-mutated\"] = {\"yes\"}\n return ctx, \"respond\"\nend"
288 ])))
289 .unwrap();
290 let err = p.execute(test_context()).await.unwrap_err();
291 assert_eq!(err.error.code, "LUA_BAD_PORT");
292 assert!(
293 !err.context.request.headers.contains_key("x-mutated"),
294 "the mutated table must be discarded on a bad port"
295 );
296 }
297
298 /// Naming success explicitly is accepted -- it's the same as a bare
299 /// `return ctx` -- and the request continues normally.
300 #[tokio::test]
301 async fn test_serverless_pre_function_explicit_success_port_continues() {
302 let p = ServerlessPreFunctionPlugin::from_config(&cfg(serde_json::json!([
303 "function execute(ctx)\n ctx.message.seen = true\n return ctx, \"success\"\nend"
304 ])))
305 .unwrap();
306 let out = p.execute(test_context()).await.unwrap();
307 assert_eq!(
308 out.context.message.get("seen"),
309 Some(&serde_json::json!(true))
310 );
311 }
312
313 /// A port name that is neither `"respond"` nor `"success"` is rejected
314 /// one layer down, inside the shared Lua runtime's
315 /// `execute_without_port` -- the message must say this node type has no
316 /// outcome port at all, not misdirect the author toward `respond`,
317 /// which these nodes do not have.
318 #[tokio::test]
319 async fn test_serverless_pre_function_bogus_port_is_lua_bad_port() {
320 let p = ServerlessPreFunctionPlugin::from_config(&cfg(serde_json::json!([
321 "function execute(ctx)\n return ctx, \"bogus\"\nend"
322 ])))
323 .unwrap();
324 let err = p.execute(test_context()).await.unwrap_err();
325 assert_eq!(err.error.code, "LUA_BAD_PORT");
326 assert!(
327 err.error.message.contains("declares no outcome port"),
328 "{}",
329 err.error.message
330 );
331 }
332}