luaapache-apisix

How to Externalize Lua Code in APISIX serverless-pre-function Plugin?


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!


Solution

  • TL;DR

    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"
        ]
      }
    }
    

    Long Answer

    Possible Solutions

    There are at least 2 ways you can achieve that behaviour:

    1. load and call the function's code from within serverless.conf.functions parameter, without changing the serverless plugin source code;
    2. edit 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
    

    1. Load the File Content Inside Serverless Functions

    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.

    Example

    APISIX Traditional Mode
    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
        }
      }
    }'
    
    APISIX Standalone

    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
    
    Sending a Request

    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"
    

    2. Customize the serverless Plugin

    I wrote a custom version of the serverless plugin, which provides two configuration parameters:

    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
    

    Example Usage

    APISIX Traditional Mode
    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
        }
      }
    }'
    
    APISIX Standalone Mode

    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
    
    Sending a Request

    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"
    

    Extra

    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