I’m working with APISIX and using the serverless-pre-function plugin to execute custom Lua code during the request lifecycle. Currently, I have my Lua function embedded directly in the JSON configuration, but I’d like to externalize it into a separate Lua file for better maintainability and readability.
Here’s my current setup:
JSON Configuration
{
"plugins": {
"serverless-pre-function": {
"phase": "access",
"functions": [
"return function(conf, ctx) local jwt = require('resty.jwt'); local userinfo = ngx.req.get_headers()['x-userinfo']; if userinfo then local jwt_obj = jwt:verify(nil, userinfo); if jwt_obj.payload then local preferred_username = jwt_obj.payload.preferred_username; if preferred_username then ngx.req.set_header('X-User-Uid', preferred_username); else ngx.log(ngx.ERR, 'Claim preferred_username not found in JWT'); end; else ngx.log(ngx.ERR, 'Failed to decode JWT: ', jwt_obj.reason); end; else ngx.log(ngx.ERR, 'No x-userinfo header found'); end; end"
]
}
}
}
Goal I want to move the Lua function into a separate file (e.g., /usr/local/apisix/lua_scripts/extract_userinfo.lua) and reference it in the serverless-pre-function plugin configuration.
Attempted Solution I tried referencing the file like this:
{
"plugins": {
"serverless-pre-function": {
"phase": "access",
"functions": [
{
"file": "/usr/local/apisix/lua_scripts/extract_userinfo.lua"
}
]
}
}
}
However, this results in an error:
{"error_msg":"failed to check the configuration of plugin serverless-pre-function err: property \"functions\" validation failed: failed to validate item 1: wrong type: expected string, got table"}
config.yaml of apisix docker:
apisix:
node_listen: 9080
enable_admin: true
log_level: debug
lua_module_cache: true
lua_path: "/usr/local/apisix/lua_scripts/?.lua;;"
extra_lua_path: /usr/local/apisix/lua_scripts/?.lua
proxy:
ssl:
verify: true
trusted_ca_certificates: /usr/local/apisix/keycloak-cert.pem
plugins: # plugin list
- authz-keycloak
- basic-auth
- clickhouse-logger
- client-control
- consumer-restriction
- cors
- csrf
- datadog
- echo
- error-log-logger
- ext-plugin-post-req
- ext-plugin-post-resp
- ext-plugin-pre-req
- fault-injection
- file-logger
- forward-auth
- google-cloud-logging
- gzip
- hmac-auth
- http-logger
- ip-restriction
- jwt-auth
- kafka-logger
- kafka-proxy
- key-auth
- ldap-auth
- limit-conn
- limit-count
- limit-req
- loggly
- mocking
- opa
- openid-connect
- opentelemetry
- openwhisk
- prometheus
- proxy-cache
- proxy-control
- proxy-mirror
- proxy-rewrite
- public-api
- real-ip
- redirect
- referer-restriction
- request-id
- request-validation
- response-rewrite
- rocketmq-logger
- server-info
- serverless-post-function
- serverless-pre-function
- skywalking
- skywalking-logger
- sls-logger
- splunk-hec-logging
- syslog
- tcp-logger
- traffic-split
- ua-restriction
- udp-logger
- uri-blocker
- wolf-rbac
- zipkin
- elasticsearch-logger
- cas-auth
plugin_attr:
log-rotate:
enable_compression: false
max_kept: 3
max_size: 10240
deployment:
admin:
admin_key:
- name: "admin"
key: "admin123"
role: admin
allow_admin:
- 0.0.0.0/0
etcd:
host:
- "http://etcd:2379"
Questions How can I properly externalize Lua code in the serverless-pre-function plugin?
What is the correct way to reference an external Lua file in the configuration?
Are there any specific requirements for the Lua file (e.g., return value, structure)? is there additional config in docker-compose or config of apisix docker
Additional Context I’m running APISIX in a Docker container.
The Lua file is placed in /usr/local/apisix/lua_scripts/.
The Lua function works correctly when embedded directly in the JSON configuration.
Any guidance or examples would be greatly appreciated!
Thanks in advance!
You can use loadfile()
inside the serverless functions
config parameter. This function allows you to load the content of a file, which you can call and execute afterwards, in case it's a function.
Example:
"plugins": {
"serverless-pre-function": {
"phase": "access",
"functions": [
"return function(conf, ctx); ... local code = loadfile(\"/path/to/file.lua\"); ... code(); ... end"
]
}
}
There are at least 2 ways you can achieve that behaviour:
serverless.conf.functions
parameter, without changing the serverless
plugin source code;serverless
plugin or write a separate custom-serverless
plugin so that you can directly set the path in its parameters.NB: for the examples below I assume you have placed the file containing the functions at /usr/local/apisix/lua_scripts/extract_userinfo.lua
, with the following content:
return function(conf, ctx)
local jwt = require('resty.jwt')
local userinfo = ngx.req.get_headers()['x-userinfo']
if userinfo then
local jwt_obj = jwt:verify(nil, userinfo)
if jwt_obj.payload then
local preferred_username = jwt_obj.payload.preferred_username
if preferred_username then
ngx.req.set_header('X-User-Uid', preferred_username)
else
ngx.log(ngx.ERR, 'Claim preferred_username not found in JWT')
end
else
ngx.log(ngx.ERR, 'Failed to decode JWT: ', jwt_obj.reason)
end
else
ngx.log(ngx.ERR, 'No x-userinfo header found')
end
end
From serverless functions
parameter, you can return a function that loads the file content using Lua loadfile("/path/to/file")
, and execute it afterwards.
With this solution you don't have to change the source code of serverless
plugin.
curl http://127.0.0.1:9180/apisix/admin/routes/1 -H "X-API-KEY: $admin_key" -X PUT -d '
{
"uri": "/custom-serverless/*",
"plugins": {
"serverless-pre-function": {
"phase": "rewrite",
"functions" : [
"return function(conf, ctx); local core = require(\"apisix.core\"); local code, err = loadfile(\"/usr/local/apisix/lua_scripts/extract_userinfo.lua\"); if code then; local success, func = pcall(code); if success then; func(); end; end; end"
]
},
"proxy-rewrite": {
"regex_uri": [
"^/serverless/(.*)",
"/$1"
]
}
},
"upstream": {
"type": "roundrobin",
"nodes": {
"httpbin.org": 1
}
}
}'
File apisix.yaml
:
upstreams:
- id: ext-httpbin
nodes:
"httpbin.org": 1
routes:
- id: serverless
uri: /serverless/*
upstream_id: ext-httpbin
plugins:
serverless-pre-function:
# Uncomment to customize priority
# _meta:
# priority: 20000
phase: access
functions:
- |
return function(conf, ctx)
local core = require("apisix.core")
local code, err = loadfile("/usr/local/apisix/lua_scripts/extract_userinfo.lua")
if code then
local success, func = pcall(code)
if success then
func()
end
end
end
# This is used to correctly parse request URI for httpbin.org
proxy-rewrite:
regex_uri:
- ^/serverless/(.*)
- /$1
#END
Upon sending a request, you can notice that APISIX logs the JWT message:
curl localhost:9080/serverless/get
Log messages:
2025/01/23 10:58:32 [error] 36#36: *2890 [lua] test.lua:22: func(): No x-userinfo header found, client: 172.18.0.1, server: _, request: "GET /serverless/get HTTP/1.1", host: "localhost:9080"
172.18.0.1 - - [23/Jan/2025:10:58:32 +0000] localhost:9080 "GET /serverless/get HTTP/1.1" 200 299 0.376 "-" "curl/8.5.0" 52.203.38.8:80 200 0.345 "http://localhost:9080/get"
serverless
PluginI wrote a custom version of the serverless
plugin, which provides two configuration parameters:
functions
, an array of strings (already present);file_paths
, an array of strings representing the file paths from which you want to load the functions code;This plugin also performs a check on the configuration schema, verifying that at least functions
or file_paths
is present.
You can just copy and paste the following code in /usr/local/apisix/apisix/plugins/serverless/init.lua
:
--
-- Licensed to the Apache Software Foundation (ASF) under one or more
-- contributor license agreements. See the NOTICE file distributed with
-- this work for additional information regarding copyright ownership.
-- The ASF licenses this file to You under the Apache License, Version 2.0
-- (the "License"); you may not use this file except in compliance with
-- the License. You may obtain a copy of the License at
--
-- http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
--
local ipairs = ipairs
local pcall = pcall
local loadstring = loadstring
local require = require
local type = type
local phases = {
"rewrite", "access", "header_filter", "body_filter",
"log", "before_proxy"
}
return function(plugin_name, priority)
local core = require("apisix.core")
local lrucache = core.lrucache.new({
type = "plugin",
})
local schema = {
type = "object",
properties = {
phase = {
type = "string",
default = "access",
enum = phases,
},
functions = {
type = "array",
items = {type = "string"},
minItems = 1
},
file_paths = {
type = "array",
items = {type = "string"},
minItems = 1
},
},
oneOf = {
{ required = { "functions" } },
{ required = { "file_paths" } }
},
}
local _M = {
version = 0.1,
priority = priority,
name = plugin_name,
schema = schema,
}
local function load_funcs(functions)
local funcs = core.table.new(#functions, 0)
local index = 1
for _, func_str in ipairs(functions) do
local _, func = pcall(loadstring(func_str))
funcs[index] = func
index = index + 1
end
return funcs
end
local function load_funcs_from_files(file_paths)
local funcs = core.table.new(#file_paths, 0)
local index = 1
for _, file_content in ipairs(file_paths) do
local _, func = pcall(loadfile(file_content))
funcs[index] = func
index = index + 1
end
return funcs
end
local function call_funcs(phase, conf, ctx)
if phase ~= conf.phase then
return
end
if conf.functions then
local functions = core.lrucache.plugin_ctx(lrucache, ctx, nil,
load_funcs, conf.functions)
for _, func in ipairs(functions) do
local code, body = func(conf, ctx)
if code or body then
return code, body
end
end
end
if conf.file_paths then
local functions = core.lrucache.plugin_ctx(lrucache, ctx, nil,
load_funcs_from_files, conf.file_paths)
for _, func in ipairs(functions) do
local code, body = func(conf, ctx)
if code or body then
return code, body
end
end
end
end
function _M.check_schema(conf)
local ok, err = core.schema.check(schema, conf)
if not ok then
return false, err
end
if conf.functions then
local functions = conf.functions
for _, func_str in ipairs(functions) do
local func, err = loadstring(func_str)
if err then
return false, 'failed to loadstring: ' .. err
end
local ok, ret = pcall(func)
if not ok then
return false, 'pcall error: ' .. ret
end
if type(ret) ~= 'function' then
return false, 'only accept Lua function,'
.. ' the input code type is ' .. type(ret)
end
end
end
if conf.file_paths then
local functions = conf.file_paths
for _, func_str in ipairs(functions) do
local func, err = loadfile(func_str)
if err then
return false, 'failed to loadstring: ' .. err
end
local ok, ret = pcall(func)
if not ok then
return false, 'pcall error: ' .. ret
end
if type(ret) ~= 'function' then
return false, 'only accept Lua function,'
.. ' the input code type is ' .. type(ret)
end
end
end
return true
end
for _, phase in ipairs(phases) do
_M[phase] = function (conf, ctx)
return call_funcs(phase, conf, ctx)
end
end
return _M
end
curl http://127.0.0.1:9180/apisix/admin/routes/1 -H "X-API-KEY: $admin_key" -X PUT -d '
{
"uri": "/custom-serverless/*",
"plugins": {
"serverless-pre-function": {
"phase": "rewrite",
"file_paths" : [
"/usr/local/apisix/lua_scripts/extract_userinfo.lua"
]
},
"proxy-rewrite": {
"regex_uri": [
"^/serverless/(.*)",
"/$1"
]
}
},
"upstream": {
"type": "roundrobin",
"nodes": {
"httpbin.org": 1
}
}
}'
File apisix.yaml
:
upstreams:
- id: ext-httpbin
nodes:
"httpbin.org": 1
routes:
- id: custom-serverless
uri: /custom-serverless/*
upstream_id: ext-httpbin
plugins:
serverless-pre-function:
# Uncomment to customize priority
# _meta:
# priority: 20000
phase: access
file_paths:
- "/usr/local/apisix/lua_scripts/extract_userinfo.lua"
# This is used to correctly parse request URI for httpbin.org
proxy-rewrite:
regex_uri:
- ^/custom-serverless/(.*)
- /$1
#END
Upon sending a request, you can notice the log message:
curl localhost:9080/custom-serverless/get
Log messages:
2025/01/23 10:26:56 [error] 63#63: *78379 [lua] test.lua:22: func(): No x-userinfo header found, client: 172.18.0.1, server: _, request: "GET /custom-serverless/get HTTP/1.1", host: "localhost:9080"
172.18.0.1 - - [23/Jan/2025:10:26:56 +0000] localhost:9080 "GET /custom-serverless/get HTTP/1.1" 200 299 0.556 "-" "curl/8.5.0" 50.19.58.113:80 200 0.544 "http://localhost:9080/get"
Notice that you can also make APISIX respond without proxying the request to the upstream:
ngx.say("Hello world")
ngx.exit(200)
Example:
curl localhost:9080/serverless/get
Hello world