From c2324a594c2f335ee0efea0fc193e2081f33531c Mon Sep 17 00:00:00 2001
From: Ashish Tiwari <ashishjaitiwari15112000@gmail.com>
Date: Tue, 28 Jan 2025 20:26:43 +0530
Subject: [PATCH] feat: support _meta.pre_function to execute custom logic
 before execution of each phase (#11793)

---
 apisix/plugin.lua                 |  40 +++-
 apisix/plugins/example-plugin.lua |   8 +
 apisix/schema_def.lua             |  11 +-
 t/admin/plugins.t                 |   4 +-
 t/misc/pre-function.t             | 325 ++++++++++++++++++++++++++++++
 5 files changed, 380 insertions(+), 8 deletions(-)
 create mode 100644 t/misc/pre-function.t

diff --git a/apisix/plugin.lua b/apisix/plugin.lua
index b3dadcb49588..342bd9680e47 100644
--- a/apisix/plugin.lua
+++ b/apisix/plugin.lua
@@ -34,6 +34,9 @@ local type          = type
 local local_plugins = core.table.new(32, 0)
 local tostring      = tostring
 local error         = error
+-- make linter happy to avoid error: getting the Lua global "load"
+-- luacheck: globals load, ignore lua_load
+local lua_load          = load
 local is_http       = ngx.config.subsystem == "http"
 local local_plugins_hash    = core.table.new(0, 32)
 local stream_local_plugins  = core.table.new(32, 0)
@@ -49,6 +52,9 @@ local merged_stream_route = core.lrucache.new({
 local expr_lrucache = core.lrucache.new({
     ttl = 300, count = 512
 })
+local meta_pre_func_load_lrucache = core.lrucache.new({
+    ttl = 300, count = 512
+})
 local local_conf
 local check_plugin_metadata
 
@@ -906,10 +912,23 @@ local function check_single_plugin_schema(name, plugin_conf, schema_type, skip_d
                 .. name .. " err: " .. err
         end
 
-        if plugin_conf._meta and plugin_conf._meta.filter then
-            ok, err = expr.new(plugin_conf._meta.filter)
-            if not ok then
-                return nil, "failed to validate the 'vars' expression: " .. err
+        if plugin_conf._meta then
+            if plugin_conf._meta.filter then
+                ok, err = expr.new(plugin_conf._meta.filter)
+                if not ok then
+                    return nil, "failed to validate the 'vars' expression: " .. err
+                end
+            end
+
+            if plugin_conf._meta.pre_function then
+                local pre_function, err = meta_pre_func_load_lrucache(plugin_conf._meta.pre_function
+                                          , "",
+                                          lua_load,
+                                          plugin_conf._meta.pre_function, "meta pre_function")
+                if not pre_function then
+                    return nil, "failed to load _meta.pre_function in plugin " .. name .. ": "
+                                 .. err
+                end
             end
         end
     end
@@ -1130,6 +1149,17 @@ function _M.stream_plugin_checker(item, in_cp)
     return true
 end
 
+local function run_meta_pre_function(conf, api_ctx, name)
+    if conf._meta and conf._meta.pre_function then
+        local _, pre_function = pcall(meta_pre_func_load_lrucache(conf._meta.pre_function, "",
+                                lua_load,
+                                conf._meta.pre_function, "meta pre_function"))
+        local ok, err = pcall(pre_function, conf, api_ctx)
+        if not ok then
+            core.log.error("pre_function execution for plugin ", name, " failed: ", err)
+        end
+    end
+end
 
 function _M.run_plugin(phase, plugins, api_ctx)
     local plugin_run = false
@@ -1169,6 +1199,7 @@ function _M.run_plugin(phase, plugins, api_ctx)
                     goto CONTINUE
                 end
 
+                run_meta_pre_function(conf, api_ctx, plugins[i]["name"])
                 plugin_run = true
                 api_ctx._plugin_name = plugins[i]["name"]
                 local code, body = phase_func(conf, api_ctx)
@@ -1207,6 +1238,7 @@ function _M.run_plugin(phase, plugins, api_ctx)
         local conf = plugins[i + 1]
         if phase_func and meta_filter(api_ctx, plugins[i]["name"], conf) then
             plugin_run = true
+            run_meta_pre_function(conf, api_ctx, plugins[i]["name"])
             api_ctx._plugin_name = plugins[i]["name"]
             phase_func(conf, api_ctx)
             api_ctx._plugin_name = nil
diff --git a/apisix/plugins/example-plugin.lua b/apisix/plugins/example-plugin.lua
index 16086ddcd38e..767ccfae72a9 100644
--- a/apisix/plugins/example-plugin.lua
+++ b/apisix/plugins/example-plugin.lua
@@ -107,6 +107,10 @@ function _M.access(conf, ctx)
     return
 end
 
+function _M.header_filter(conf, ctx)
+    core.log.warn("plugin header_filter phase, conf: ", core.json.encode(conf))
+end
+
 
 function _M.body_filter(conf, ctx)
     core.log.warn("plugin body_filter phase, eof: ", ngx.arg[2],
@@ -119,6 +123,10 @@ function _M.delayed_body_filter(conf, ctx)
                   ", conf: ", core.json.encode(conf))
 end
 
+function _M.log(conf, ctx)
+    core.log.warn("plugin log phase, conf: ", core.json.encode(conf))
+end
+
 
 local function hello()
     local args = ngx.req.get_uri_args()
diff --git a/apisix/schema_def.lua b/apisix/schema_def.lua
index 4e4514678b06..fd773990d2c1 100644
--- a/apisix/schema_def.lua
+++ b/apisix/schema_def.lua
@@ -1032,8 +1032,15 @@ _M.plugin_injected_schema = {
                 description = "filter determines whether the plugin "..
                                 "needs to be executed at runtime",
                 type  = "array",
-            }
-        }
+            },
+            pre_function = {
+                description = "function to be executed in each phase " ..
+                              "before execution of plugins. The pre_function will have access " ..
+                              "to two arguments: `conf` and `ctx`.",
+                type = "string",
+            },
+        },
+        additionalProperties = false,
     }
 }
 
diff --git a/t/admin/plugins.t b/t/admin/plugins.t
index 713d59d4cf41..6c574c2a4673 100644
--- a/t/admin/plugins.t
+++ b/t/admin/plugins.t
@@ -281,7 +281,7 @@ plugins:
         }
     }
 --- response_body eval
-qr/\{"metadata_schema":\{"properties":\{"ikey":\{"minimum":0,"type":"number"\},"skey":\{"type":"string"\}\},"required":\["ikey","skey"\],"type":"object"\},"priority":0,"schema":\{"\$comment":"this is a mark for our injected plugin schema","properties":\{"_meta":\{"properties":\{"disable":\{"type":"boolean"\},"error_response":\{"oneOf":\[\{"type":"string"\},\{"type":"object"\}\]\},"filter":\{"description":"filter determines whether the plugin needs to be executed at runtime","type":"array"\},"priority":\{"description":"priority of plugins by customized order","type":"integer"\}\},"type":"object"\},"i":\{"minimum":0,"type":"number"\},"ip":\{"type":"string"\},"port":\{"type":"integer"\},"s":\{"type":"string"\},"t":\{"minItems":1,"type":"array"\}\},"required":\["i"\],"type":"object"\},"version":0.1\}/
+qr/\{"metadata_schema":\{"properties":\{"ikey":\{"minimum":0,"type":"number"\},"skey":\{"type":"string"\}\},"required":\["ikey","skey"\],"type":"object"\},"priority":0,"schema":\{"\$comment":"this is a mark for our injected plugin schema","properties":\{"_meta":\{"additionalProperties":false,"properties":\{"disable":\{"type":"boolean"\},"error_response":\{"oneOf":\[\{"type":"string"\},\{"type":"object"\}\]\},"filter":\{"description":"filter determines whether the plugin needs to be executed at runtime","type":"array"\},"pre_function":\{"description":"function to be executed in each phase before execution of plugins. The pre_function will have access to two arguments: `conf` and `ctx`.","type":"string"\},"priority":\{"description":"priority of plugins by customized order","type":"integer"\}\},"type":"object"\},"i":\{"minimum":0,"type":"number"\},"ip":\{"type":"string"\},"port":\{"type":"integer"\},"s":\{"type":"string"\},"t":\{"minItems":1,"type":"array"\}\},"required":\["i"\],"type":"object"\},"version":0.1\}/
 
 
 
@@ -382,7 +382,7 @@ qr/\{"encrypt_fields":\["password"\],"properties":\{"password":\{"type":"string"
         }
     }
 --- response_body
-{"priority":1003,"schema":{"$comment":"this is a mark for our injected plugin schema","properties":{"_meta":{"properties":{"disable":{"type":"boolean"},"error_response":{"oneOf":[{"type":"string"},{"type":"object"}]},"filter":{"description":"filter determines whether the plugin needs to be executed at runtime","type":"array"},"priority":{"description":"priority of plugins by customized order","type":"integer"}},"type":"object"},"burst":{"minimum":0,"type":"integer"},"conn":{"exclusiveMinimum":0,"type":"integer"},"default_conn_delay":{"exclusiveMinimum":0,"type":"number"},"key":{"type":"string"},"key_type":{"default":"var","enum":["var","var_combination"],"type":"string"},"only_use_default_delay":{"default":false,"type":"boolean"}},"required":["conn","burst","default_conn_delay","key"],"type":"object"},"version":0.1}
+{"priority":1003,"schema":{"$comment":"this is a mark for our injected plugin schema","properties":{"_meta":{"additionalProperties":false,"properties":{"disable":{"type":"boolean"},"error_response":{"oneOf":[{"type":"string"},{"type":"object"}]},"filter":{"description":"filter determines whether the plugin needs to be executed at runtime","type":"array"},"pre_function":{"description":"function to be executed in each phase before execution of plugins. The pre_function will have access to two arguments: `conf` and `ctx`.","type":"string"},"priority":{"description":"priority of plugins by customized order","type":"integer"}},"type":"object"},"burst":{"minimum":0,"type":"integer"},"conn":{"exclusiveMinimum":0,"type":"integer"},"default_conn_delay":{"exclusiveMinimum":0,"type":"number"},"key":{"type":"string"},"key_type":{"default":"var","enum":["var","var_combination"],"type":"string"},"only_use_default_delay":{"default":false,"type":"boolean"}},"required":["conn","burst","default_conn_delay","key"],"type":"object"},"version":0.1}
 
 
 
diff --git a/t/misc/pre-function.t b/t/misc/pre-function.t
new file mode 100644
index 000000000000..316c93ccbd8c
--- /dev/null
+++ b/t/misc/pre-function.t
@@ -0,0 +1,325 @@
+#
+# 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.
+#
+use t::APISIX 'no_plan';
+
+repeat_each(1);
+no_long_string();
+no_root_location();
+log_level("info");
+
+$ENV{TEST_NGINX_HTML_DIR} ||= html_dir();
+
+add_block_preprocessor(sub {
+    my ($block) = @_;
+
+    if (!$block->request) {
+        $block->set_value("request", "GET /t");
+    }
+});
+
+run_tests;
+
+__DATA__
+
+=== TEST 1: invalid pre_function
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "methods": ["GET"],
+                        "plugins": {
+                            "limit-count": {
+                                "count": 2,
+                                "time_window": 60,
+                                "rejected_code": 503,
+                                "key": "remote_addr",
+                                "_meta": {
+                                    "pre_function": "not a function"
+                                }
+                            }
+                        },
+                        "upstream": {
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            },
+                            "type": "roundrobin"
+                        },
+                        "uri": "/hello"
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.print(body)
+        }
+    }
+--- error_code: 400
+--- response_body
+{"error_msg":"failed to load _meta.pre_function in plugin limit-count: [string \"meta pre_function\"]:1: unexpected symbol near 'not'"}
+
+
+
+=== TEST 2: attempt setting pre_function in _meta with a typo in `pre_function`
+# this is to test the case where user (or CP) would attempt configuring pre_function
+# using incorrect field name, this validation is achieved by setting `additionalProperties = false`
+# in schema_def.lua
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "methods": ["GET"],
+                        "plugins": {
+                            "limit-count": {
+                                "count": 2,
+                                "time_window": 60,
+                                "rejected_code": 503,
+                                "key": "remote_addr",
+                                "_meta": {
+                                    "prefunction": "not a function"
+                                }
+                            }
+                        },
+                        "upstream": {
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            },
+                            "type": "roundrobin"
+                        },
+                        "uri": "/hello"
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.print(body)
+        }
+    }
+--- error_code: 400
+--- response_body
+{"error_msg":"failed to check the configuration of plugin limit-count err: property \"_meta\" validation failed: additional properties forbidden, found prefunction"}
+
+
+
+=== TEST 3: pre_function with error in code
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "methods": ["GET"],
+                        "plugins": {
+                            "limit-count": {
+                                "count": 2,
+                                "time_window": 60,
+                                "rejected_code": 503,
+                                "key": "remote_addr",
+                                "_meta": {
+                                    "pre_function": "return function() print(invalid.index) end"
+                                }
+                            }
+                        },
+                        "upstream": {
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            },
+                            "type": "roundrobin"
+                        },
+                        "uri": "/hello"
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- error_code: 200
+--- response_body
+passed
+
+
+
+=== TEST 4: sending request will execute erroneous code and print error log
+--- request
+GET /hello
+--- error_log
+pre_function execution for plugin limit-count failed: [string "meta pre_function"]:1: attempt to index global 'invalid' (a nil value),
+
+
+
+=== TEST 5: test pre_function sanity: correct function
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                        "methods": ["GET"],
+                        "plugins": {
+                            "limit-count": {
+                                "count": 2,
+                                "time_window": 60,
+                                "rejected_code": 503,
+                                "key": "remote_addr",
+                                "_meta": {
+                                    "pre_function": "return function(conf, ctx) ngx.log(ngx.WARN, 'hello ', ngx.req.get_headers()[\"User-Agent\"]) end"
+                                }
+                            }
+                        },
+                        "upstream": {
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            },
+                            "type": "roundrobin"
+                        },
+                        "uri": "/hello"
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- error_code: 200
+--- response_body
+passed
+
+
+
+=== TEST 6: request
+--- request
+GET /hello
+--- more_headers
+User-Agent: test-nginx
+--- error_log
+hello test-nginx
+
+
+
+=== TEST 7: pre_function is executed in all phases
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                    "plugins": {
+                        "example-plugin": {
+                            "i": 11,
+                            "_meta": {
+                                "pre_function": "return function(conf, ctx) ngx.log(ngx.WARN, 'hello: ', ngx.get_phase()) end"
+                            }
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/hello"
+                 }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- error_code: 200
+--- response_body
+passed
+
+
+
+=== TEST 8: request
+--- request
+GET /hello
+--- error_log
+hello: access
+hello: header_filter
+hello: body_filter
+hello: log
+
+
+
+=== TEST 9: test pre-function with proxy-rewrite, (rewrite phase)
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local code, body = t('/apisix/admin/routes/1',
+                 ngx.HTTP_PUT,
+                 [[{
+                    "plugins": {
+                        "proxy-rewrite": {
+                            "uri": "/uri",
+                            "headers": {
+                                "x-api": "$example_var_name"
+                            },
+                            "_meta": {
+                                "pre_function": "return function(conf, ctx) local core = require \"apisix.core\" core.ctx.register_var(\"example_var_name\", function(ctx) return \"example_var_value\" end) end"
+                            }
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/hello"
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 10: hit route(header supports nginx variables)
+--- request
+GET /hello
+--- response_body
+uri: /uri
+host: localhost
+x-api: example_var_value
+x-real-ip: 127.0.0.1