Skip to main content

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}