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 <ashishjaitiwari15112...@gmail.com> 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