This is an automated email from the ASF dual-hosted git repository.
ashishtiwari pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git
The following commit(s) were added to refs/heads/master by this push:
new 152077c22 feat: support ctx.var.post_arg for vars based route matching
on request body (#12388)
152077c22 is described below
commit 152077c227ec2e07c2023c3ddef20bb83db93b35
Author: Ashish Tiwari <[email protected]>
AuthorDate: Tue Jul 1 14:20:27 2025 +0530
feat: support ctx.var.post_arg for vars based route matching on request
body (#12388)
---
apisix-master-0.rockspec | 1 +
apisix/admin/routes.lua | 33 +++++
apisix/core/ctx.lua | 72 ++++++++++
docs/en/latest/router-radixtree.md | 76 ++++++++++
t/admin/routes_request_body.t | 274 +++++++++++++++++++++++++++++++++++++
5 files changed, 456 insertions(+)
diff --git a/apisix-master-0.rockspec b/apisix-master-0.rockspec
index c9432cc32..82ca9d8bb 100644
--- a/apisix-master-0.rockspec
+++ b/apisix-master-0.rockspec
@@ -81,6 +81,7 @@ dependencies = {
"lua-resty-t1k = 1.1.5",
"brotli-ffi = 0.3-1",
"lua-ffi-zlib = 0.6-0",
+ "jsonpath = 1.0-1",
"api7-lua-resty-aws == 2.0.2-1",
"multipart = 0.5.9-1",
}
diff --git a/apisix/admin/routes.lua b/apisix/admin/routes.lua
index 20187a4be..e13bb23e8 100644
--- a/apisix/admin/routes.lua
+++ b/apisix/admin/routes.lua
@@ -21,6 +21,33 @@ local resource = require("apisix.admin.resource")
local schema_plugin = require("apisix.admin.plugins").check_schema
local type = type
local loadstring = loadstring
+local ipairs = ipairs
+local jp = require("jsonpath")
+
+local function validate_post_arg(node)
+ if type(node) ~= "table" then
+ return true
+ end
+
+ -- Handle post_arg conditions
+ if #node >= 3 and type(node[1]) == "string" and
node[1]:find("^post_arg%.") then
+ local key = node[1]
+ local json_path = "$." .. key:sub(11) -- Remove "post_arg." prefix
+ local _, err = jp.parse(json_path)
+ if err then
+ return false, err
+ end
+ return true
+ end
+
+ for _, child in ipairs(node) do
+ local ok, err = validate_post_arg(child)
+ if not ok then
+ return false, err
+ end
+ end
+ return true
+end
local function check_conf(id, conf, need_id, schema)
@@ -111,6 +138,12 @@ local function check_conf(id, conf, need_id, schema)
end
end
+ ok, err = validate_post_arg(conf.vars)
+ if not ok then
+ return nil, {error_msg = "failed to validate the 'vars'
expression: " ..
+ err}
+ end
+
if conf.filter_func then
local func, err = loadstring("return " .. conf.filter_func)
if not func then
diff --git a/apisix/core/ctx.lua b/apisix/core/ctx.lua
index e40ff8bb7..c6f66fbcc 100644
--- a/apisix/core/ctx.lua
+++ b/apisix/core/ctx.lua
@@ -29,7 +29,10 @@ local tablepool = require("tablepool")
local get_var = require("resty.ngxvar").fetch
local get_request = require("resty.ngxvar").request
local ck = require "resty.cookie"
+local multipart = require("multipart")
+local util = require("apisix.cli.util")
local gq_parse = require("graphql").parse
+local jp = require("jsonpath")
local setmetatable = setmetatable
local sub_str = string.sub
local ngx = ngx
@@ -167,6 +170,47 @@ local function get_parsed_graphql()
end
+local CONTENT_TYPE_JSON = "application/json"
+local CONTENT_TYPE_FORM_URLENCODED = "application/x-www-form-urlencoded"
+local CONTENT_TYPE_MULTIPART_FORM = "multipart/form-data"
+
+local function get_parsed_request_body(ctx)
+ local ct_header = request.header(ctx, "Content-Type") or ""
+
+ if core_str.find(ct_header, CONTENT_TYPE_JSON) then
+ local request_table, err = request.get_json_request_body_table()
+ if not request_table then
+ return nil, "failed to parse JSON body: " .. err
+ end
+ return request_table
+ end
+
+ if core_str.find(ct_header, CONTENT_TYPE_FORM_URLENCODED) then
+ local args, err = request.get_post_args()
+ if not args then
+ return nil, "failed to parse form data: " .. (err or "unknown
error")
+ end
+ return args
+ end
+
+ if core_str.find(ct_header, CONTENT_TYPE_MULTIPART_FORM) then
+ local body = request.get_body()
+ local res = multipart(body, ct_header)
+ if not res then
+ return nil, "failed to parse multipart form data"
+ end
+ return res:get_all()
+ end
+
+ local err = "unsupported content-type in header: " .. ct_header ..
+ ", supported types are: " ..
+ CONTENT_TYPE_JSON .. ", " ..
+ CONTENT_TYPE_FORM_URLENCODED .. ", " ..
+ CONTENT_TYPE_MULTIPART_FORM
+ return nil, err
+end
+
+
do
local var_methods = {
method = ngx.req.get_method,
@@ -292,6 +336,34 @@ do
-- trim the "graphql_" prefix
local arg_key = sub_str(key, 9)
val = get_parsed_graphql()[arg_key]
+ elseif core_str.has_prefix(key, "post_arg.") then
+ -- trim the "post_arg." prefix (10 characters)
+ local arg_key = sub_str(key, 10)
+ local parsed_body, err = get_parsed_request_body(t._ctx)
+ if not parsed_body then
+ log.warn("failed to fetch post args value by key: ",
arg_key, " error: ", err)
+ return nil
+ end
+ if arg_key:find("[%[%*]") or arg_key:find("..", 1, true) then
+ arg_key = "$." .. arg_key
+ local results = jp.query(parsed_body, arg_key)
+ if #results == 0 then
+ val = nil
+ else
+ val = results
+ end
+ else
+ local parts = util.split(arg_key, "(.)")
+ local current = parsed_body
+ for _, part in ipairs(parts) do
+ if type(current) ~= "table" then
+ current = nil
+ break
+ end
+ current = current[part]
+ end
+ val = current
+ end
else
local getter = apisix_var_names[key]
diff --git a/docs/en/latest/router-radixtree.md
b/docs/en/latest/router-radixtree.md
index b6e42e665..c375360ef 100644
--- a/docs/en/latest/router-radixtree.md
+++ b/docs/en/latest/router-radixtree.md
@@ -337,3 +337,79 @@ graphql:
```
If you need to pass a GraphQL body which is larger than the limitation, you
can increase the value in `conf/config.yaml`.
+
+### How to filter route by POST request JSON body?
+
+APISIX supports filtering route by POST form attributes with `Content-Type` =
`application/json`.
+
+We can define the following route:
+
+```shell
+$ curl http://127.0.0.1:9180/apisix/admin/routes/1 -H "X-API-KEY: $admin_key"
-X PUT -i -d '
+{
+ "methods": ["POST"],
+ "uri": "/_post",
+ "vars": [
+ ["post_arg.name", "==", "xyz"]
+ ],
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1980": 1
+ }
+ }
+}'
+```
+
+It will match the following POST request
+
+```shell
+curl -X POST http://127.0.0.1:9180/_post \
+ -H "Content-Type: application/json" \
+ -d '{"name":"xyz"}'
+```
+
+We can also filter by complex queries like the example below:
+
+```shell
+$ curl http://127.0.0.1:9180/apisix/admin/routes/1 -H "X-API-KEY: $admin_key"
-X PUT -i -d '
+{
+ "methods": ["POST"],
+ "uri": "/_post",
+ "vars": [
+ ["post_arg.messages[*].content[*].type","has","image_url"]
+ ],
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1980": 1
+ }
+ }
+}'
+```
+
+It will match the following POST request
+
+```shell
+curl -X POST http://127.0.0.1:9180/_post \
+ -H "Content-Type: application/json" \
+ -d '{
+ "model": "deepseek",
+ "messages": [
+ {
+ "role": "system",
+ "content": [
+ {
+ "text": "You are a mathematician",
+ "type": "text"
+ },
+ {
+ "text": "You are a mathematician",
+ "type": "image_url"
+ }
+ ]
+ }
+ ]
+}'
+
+```
diff --git a/t/admin/routes_request_body.t b/t/admin/routes_request_body.t
new file mode 100644
index 000000000..4c4cb7119
--- /dev/null
+++ b/t/admin/routes_request_body.t
@@ -0,0 +1,274 @@
+#
+# 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';
+
+log_level("info");
+repeat_each(1);
+no_long_string();
+no_root_location();
+
+add_block_preprocessor(sub {
+ my ($block) = @_;
+ if (!defined $block->request) {
+ $block->set_value("request", "GET /t");
+ }
+});
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: set route in request body vars
+--- 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,
+ [[{
+ "uri": "/hello",
+ "vars": [
+ [
+ ["post_arg.model","==", "deepseek"]
+ ]
+ ],
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "httpbin.org:80": 1
+ }
+ }
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ local code, body = t('/apisix/admin/routes/2',
+ ngx.HTTP_PUT,
+ [[{
+ "uri": "/hello",
+ "vars": [
+ [
+ ["post_arg.model","==","openai"]
+ ]
+ ],
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1980": 1
+ }
+ }
+ }]]
+ )
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 2: send request with model == deepseek
+--- request
+POST /hello
+{ "model":"deepseek", "messages": [ { "role": "system", "content": "You are a
mathematician" }] }
+--- more_headers
+Content-Type: application/json
+--- error_code: 404
+
+
+
+=== TEST 3: send request with model == openai and content-type ==
application/json
+--- request
+POST /hello
+{ "model":"openai", "messages": [ { "role": "system", "content": "You are a
mathematician" }] }
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+
+
+
+=== TEST 4: send request with model == openai and content-type ==
application/x-www-form-urlencoded
+--- request
+POST /hello
+model=openai&messages[0][role]=system&messages[0][content]=You%20are%20a%20mathematician
+--- more_headers
+Content-Type: application/x-www-form-urlencoded
+--- error_code: 200
+
+
+
+=== TEST 5: multipart/form-data with model=openai
+--- request
+POST /hello
+--testboundary
+Content-Disposition: form-data; name="model"
+
+openai
+--testboundary--
+--- more_headers
+Content-Type: multipart/form-data; boundary=testboundary
+--- error_code: 200
+
+
+
+=== TEST 6: no match without content type
+--- request
+POST /hello
+--testboundary
+Content-Disposition: form-data; name="model"
+
+openai
+--testboundary--
+--- error_code: 404
+--- error_log
+unsupported content-type in header:
+
+
+
+=== TEST 7: use array in request body vars
+--- 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,
+ [[{
+ "uri": "/hello",
+ "vars": [
+ [
+
["post_arg.messages[*].content[*].type","has","image_url"]
+ ]
+ ],
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1980": 1
+ }
+ }
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 8: send request with type not image_url
+--- request
+POST /hello
+{ "model":"deepseek", "messages": [ { "role": "system", "content":
[{"text":"You are a mathematician","type":"text"}] }] }
+--- more_headers
+Content-Type: application/json
+--- error_code: 404
+
+
+
+=== TEST 9: send request with type has image_url
+--- request
+POST /hello
+{ "model":"deepseek", "messages": [ { "role": "system", "content":
[{"text":"You are a mathematician","type":"text"},{"text":"You are a
mathematician","type":"image_url"}] }] }
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+
+
+
+=== TEST 10: use invalid jsonpath input
+--- 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,
+ [[{
+ "uri": "/hello",
+ "vars": [
+ [
+
["post_arg.messages[.content[*].type","has","image_url"]
+ ]
+ ],
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1980": 1
+ }
+ }
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body eval
+qr/.*failed to validate the 'vars' expression: invalid expression.*/
+--- error_code: 400
+
+
+
+=== TEST 11: use non array in request body vars
+--- 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,
+ [[{
+ "uri": "/hello",
+ "vars": [
+ [
+ ["post_arg.model.name","==","deepseek"]
+ ]
+ ],
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1980": 1
+ }
+ }
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 12: send request
+--- request
+POST /hello
+{ "model":{"name": "deepseek"}, "messages": [ { "role": "system", "content":
[{"text":"You are a mathematician","type":"text"},{"text":"You are a
mathematician","type":"image_url"}] }] }
+--- more_headers
+Content-Type: application/json
+--- error_code: 200