This is an automated email from the ASF dual-hosted git repository.

AlinsRan 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 c57ee9cac perf(core): cache parsed JSON request body to avoid 
redundant decoding (#13377)
c57ee9cac is described below

commit c57ee9cac01a01ca635e56ea141eab5f2c711de8
Author: AlinsRan <[email protected]>
AuthorDate: Mon May 18 11:50:52 2026 +0800

    perf(core): cache parsed JSON request body to avoid redundant decoding 
(#13377)
---
 apisix/core/ctx.lua           | 60 +-----------------------------
 apisix/core/request.lua       | 86 +++++++++++++++++++++++++++++++++++++++----
 apisix/patch.lua              | 11 +++---
 t/admin/routes_request_body.t |  2 +-
 t/core/request.t              | 52 ++++++++++++++++++++++++++
 5 files changed, 139 insertions(+), 72 deletions(-)

diff --git a/apisix/core/ctx.lua b/apisix/core/ctx.lua
index dda4ccddc..92a57629d 100644
--- a/apisix/core/ctx.lua
+++ b/apisix/core/ctx.lua
@@ -29,7 +29,6 @@ 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")
@@ -170,63 +169,6 @@ 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 PARSED_BODY_CACHE_KEY = "_post_arg_request_body"
-
-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
-
--- Wrapper that caches the parsed body in ctx for the lifetime of the request.
--- Errors are intentionally not cached: plugins may call 
ngx.req.set_body_data()
--- in a later phase, so a transient read failure should not be frozen.
-local function get_parsed_request_body(ctx)
-    if ctx[PARSED_BODY_CACHE_KEY] ~= nil then
-        log.debug("reuse parsed request body from ctx cache")
-        return ctx[PARSED_BODY_CACHE_KEY]
-    end
-
-    local result, err = _get_parsed_request_body(ctx)
-    if result then
-        ctx[PARSED_BODY_CACHE_KEY] = result
-    end
-    return result, err
-end
 
 
 do
@@ -370,7 +312,7 @@ do
             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)
+                local parsed_body, err = request.get_request_body_table(t._ctx)
                 if not parsed_body then
                     log.warn("failed to fetch post args value by key: ", 
arg_key, " error: ", err)
                     return nil
diff --git a/apisix/core/request.lua b/apisix/core/request.lua
index f49f648ab..ef5bcb6bb 100644
--- a/apisix/core/request.lua
+++ b/apisix/core/request.lua
@@ -23,6 +23,8 @@ local lfs = require("lfs")
 local log = require("apisix.core.log")
 local json = require("apisix.core.json")
 local io = require("apisix.core.io")
+local multipart = require("multipart")
+local core_str = require("apisix.core.string")
 local req_add_header
 if ngx.config.subsystem == "http" then
     local ngx_req = require "ngx.req"
@@ -46,6 +48,10 @@ local req_set_uri_args = ngx.req.set_uri_args
 local table_insert = table.insert
 local req_set_header = ngx.req.set_header
 
+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 _M = {}
 
@@ -335,17 +341,83 @@ function _M.get_body(max_size, ctx)
 end
 
 
-function _M.get_json_request_body_table()
-    local body, err = _M.get_body()
-    if not body then
-        return nil, { message = "could not get body: " .. (err or "request 
body is empty") }
+-- get_request_body_table parses the request body according to its Content-Type
+-- and caches the result in ctx._request_body_table for the lifetime of the 
request.
+-- Supported types: application/json, application/x-www-form-urlencoded,
+-- multipart/form-data.
+--
+-- When content_type is given (e.g. CONTENT_TYPE_JSON), it takes precedence 
over
+-- the request Content-Type header. A cache hit is only reused when
+-- ctx._request_body_type matches, preventing type confusion between callers.
+local function get_request_body_table(ctx, content_type)
+    if not ctx then
+        ctx = ngx.ctx.api_ctx
+    end
+    if ctx._request_body_table ~= nil then
+        if content_type and ctx._request_body_type ~= content_type then
+            return nil, "request body type mismatch: cached type is " ..
+                        (ctx._request_body_type or "unknown") ..
+                        ", expected " .. content_type
+        end
+        log.debug("reuse parsed request body from ctx cache")
+        return ctx._request_body_table
     end
 
-    local body_tab, err = json.decode(body)
-    if not body_tab then
-        return nil, { message = "could not parse JSON request body: " .. (err 
or "invalid JSON") }
+    local ct = content_type or _M.header(ctx, "Content-Type") or ""
+    local result, err, detected_type
+
+    if core_str.find(ct, CONTENT_TYPE_JSON) then
+        local body, body_err = _M.get_body()
+        if not body then
+            return nil, "could not get body: " .. (body_err or "request body 
is empty")
+        end
+        result, err = json.decode(body)
+        if not result then
+            return nil, "could not parse JSON request body: " .. (err or 
"invalid JSON")
+        end
+        detected_type = CONTENT_TYPE_JSON
+
+    elseif core_str.find(ct, CONTENT_TYPE_FORM_URLENCODED) then
+        result, err = _M.get_post_args()
+        if not result then
+            return nil, "failed to parse form data: " .. (err or "unknown 
error")
+        end
+        detected_type = CONTENT_TYPE_FORM_URLENCODED
+
+    elseif core_str.find(ct, CONTENT_TYPE_MULTIPART_FORM) then
+        local body, body_err = _M.get_body()
+        if not body then
+            return nil, "could not get body: " .. (body_err or "request body 
is empty")
+        end
+
+        local res = multipart(body, ct)
+        if not res then
+            return nil, "failed to parse multipart form data"
+        end
+        result = res:get_all()
+        detected_type = CONTENT_TYPE_MULTIPART_FORM
+
+    else
+        return nil, "unsupported content-type: " .. ct ..
+                    ", supported types are: " ..
+                    CONTENT_TYPE_JSON .. ", " ..
+                    CONTENT_TYPE_FORM_URLENCODED .. ", " ..
+                    CONTENT_TYPE_MULTIPART_FORM
     end
 
+    ctx._request_body_table = result
+    ctx._request_body_type = detected_type
+    return result
+end
+_M.get_request_body_table = get_request_body_table
+
+
+function _M.get_json_request_body_table()
+    local ctx = ngx.ctx.api_ctx
+    local body_tab, err = get_request_body_table(ctx, CONTENT_TYPE_JSON)
+    if not body_tab then
+        return nil, { message = err }
+    end
     return body_tab
 end
 
diff --git a/apisix/patch.lua b/apisix/patch.lua
index 1e2303c64..f8d504b74 100644
--- a/apisix/patch.lua
+++ b/apisix/patch.lua
@@ -380,14 +380,15 @@ function _M.patch()
         return patch_udp_socket(original_udp())
     end
 
-    -- Patch ngx.req.set_body_data to invalidate the parsed post_arg request 
body
-    -- cache in api_ctx. This ensures that post_arg.* variable lookups after a 
body
-    -- rewrite always reflect the new body content.
+    -- Patch ngx.req.set_body_data to invalidate the parsed request body cache
+    -- in api_ctx. This ensures that body lookups and post_arg.* variable
+    -- lookups after a body rewrite always reflect the new body content.
     local _orig_set_body_data = ngx.req.set_body_data
     ngx.req.set_body_data = function(data)
         local api_ctx = ngx.ctx.api_ctx
-        if api_ctx and api_ctx._post_arg_request_body then
-            api_ctx._post_arg_request_body = nil
+        if api_ctx and api_ctx._request_body_table then
+            api_ctx._request_body_table = nil
+            api_ctx._request_body_type = nil
             local var = api_ctx.var
             local cache = var and var._cache
             if cache then
diff --git a/t/admin/routes_request_body.t b/t/admin/routes_request_body.t
index beec99fa3..afb17396e 100644
--- a/t/admin/routes_request_body.t
+++ b/t/admin/routes_request_body.t
@@ -138,7 +138,7 @@ openai
 --testboundary--
 --- error_code: 404
 --- error_log
-unsupported content-type in header:
+unsupported content-type:
 
 
 
diff --git a/t/core/request.t b/t/core/request.t
index 322050830..14c2e901e 100644
--- a/t/core/request.t
+++ b/t/core/request.t
@@ -490,3 +490,55 @@ DEPRECATED: use add_header(ctx, header_name, header_value) 
instead
 ngx
 test
 apisix
+
+
+
+=== TEST 17: get_json_request_body_table caches result and re-decodes after 
set_body_data
+--- config
+    location /t {
+        content_by_lua_block {
+            local core = require("apisix.core")
+            local json = require("apisix.core.json")
+
+            ngx.ctx.api_ctx = {}
+
+            local decode_count = 0
+            local orig_decode = json.decode
+            json.decode = function(str)
+                decode_count = decode_count + 1
+                return orig_decode(str)
+            end
+
+            -- first call: populates cache
+            local t1 = core.request.get_json_request_body_table()
+            -- second and third calls: hit cache, no extra decode
+            local t2 = core.request.get_json_request_body_table()
+            local t3 = core.request.get_json_request_body_table()
+
+            ngx.say("model: ", t1 and t1.model)
+            ngx.say("same table: ", t1 == t2 and t2 == t3)
+            ngx.say("decode_count: ", decode_count)
+
+            -- invalidate cache by replacing body
+            ngx.req.set_body_data('{"model":"claude"}')
+
+            -- cache cleared, must re-decode
+            local t4 = core.request.get_json_request_body_table()
+
+            json.decode = orig_decode
+
+            ngx.say("after set_body model: ", t4 and t4.model)
+            ngx.say("decode_count: ", decode_count)
+        }
+    }
+--- request
+POST /t
+{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}
+--- more_headers
+Content-Type: application/json
+--- response_body
+model: gpt-4
+same table: true
+decode_count: 1
+after set_body model: claude
+decode_count: 2

Reply via email to