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 82fcf548f feat(proxy-rewrite): support multiple same-name headers in 
headers.add/set (#13597)
82fcf548f is described below

commit 82fcf548fa3c283e81ed8517e7eec70325902122
Author: AlinsRan <[email protected]>
AuthorDate: Wed Jul 22 14:47:14 2026 +0800

    feat(proxy-rewrite): support multiple same-name headers in headers.add/set 
(#13597)
---
 apisix/plugins/proxy-rewrite.lua        |  84 +++++++++++--
 docs/en/latest/plugins/proxy-rewrite.md |  38 +++++-
 docs/zh/latest/plugins/proxy-rewrite.md |  38 +++++-
 t/lib/server.lua                        |  28 +++++
 t/plugin/proxy-rewrite.t                | 208 ++++++++++++++++++++++++++++++++
 5 files changed, 380 insertions(+), 16 deletions(-)

diff --git a/apisix/plugins/proxy-rewrite.lua b/apisix/plugins/proxy-rewrite.lua
index 6bddfa39a..f6b74e50e 100644
--- a/apisix/plugins/proxy-rewrite.lua
+++ b/apisix/plugins/proxy-rewrite.lua
@@ -93,7 +93,19 @@ local schema = {
                                 ["^[^:]+$"] = {
                                     oneOf = {
                                         { type = "string" },
-                                        { type = "number" }
+                                        { type = "number" },
+                                        {
+                                            -- multiple values for the same
+                                            -- header name, e.g. ["v1", "v2"]
+                                            type = "array",
+                                            minItems = 1,
+                                            items = {
+                                                oneOf = {
+                                                    { type = "string" },
+                                                    { type = "number" },
+                                                }
+                                            }
+                                        }
                                     }
                                 }
                             },
@@ -106,6 +118,18 @@ local schema = {
                                     oneOf = {
                                         { type = "string" },
                                         { type = "number" },
+                                        {
+                                            -- replace the header with multiple
+                                            -- values, e.g. ["v1", "v2"]
+                                            type = "array",
+                                            minItems = 1,
+                                            items = {
+                                                oneOf = {
+                                                    { type = "string" },
+                                                    { type = "number" },
+                                                }
+                                            }
+                                        }
                                     }
                                 }
                             },
@@ -275,6 +299,13 @@ do
     end
 
 
+    local function resolve_header_value(value, ctx)
+        local val = core.utils.resolve_var_with_captures(value,
+                                        ctx.proxy_rewrite_regex_uri_captures)
+        return core.utils.resolve_var(val, ctx.var)
+    end
+
+
 function _M.rewrite(conf, ctx)
     for _, name in ipairs(upstream_names) do
         if conf[name] then
@@ -384,22 +415,51 @@ function _M.rewrite(conf, ctx)
 
         local field_cnt = #hdr_op.add
         for i = 1, field_cnt, 2 do
-            local val = core.utils.resolve_var_with_captures(hdr_op.add[i + 1],
-                                            
ctx.proxy_rewrite_regex_uri_captures)
-            val = core.utils.resolve_var(val, ctx.var)
-            -- A nil or empty table value will cause add_header function to 
throw an error.
-            if val then
-                local header = hdr_op.add[i]
-                core.request.add_header(ctx, header, val)
+            local header = hdr_op.add[i]
+            local value = hdr_op.add[i + 1]
+            -- an array value adds the header once per element (multiple
+            -- headers with the same name); a scalar adds it once.
+            if type(value) == "table" then
+                for j = 1, #value do
+                    local val = resolve_header_value(value[j], ctx)
+                    -- guard nil only: add_header throws on nil, while an empty
+                    -- string is a valid value kept to preserve the existing
+                    -- behavior for an unresolved variable/capture.
+                    if val then
+                        core.request.add_header(ctx, header, val)
+                    end
+                end
+            else
+                local val = resolve_header_value(value, ctx)
+                if val then
+                    core.request.add_header(ctx, header, val)
+                end
             end
         end
 
         local field_cnt = #hdr_op.set
         for i = 1, field_cnt, 2 do
-            local val = core.utils.resolve_var_with_captures(hdr_op.set[i + 1],
-                                            
ctx.proxy_rewrite_regex_uri_captures)
-            val = core.utils.resolve_var(val, ctx.var)
-            core.request.set_header(ctx, hdr_op.set[i], val)
+            local header = hdr_op.set[i]
+            local value = hdr_op.set[i + 1]
+            -- an array value replaces the header with multiple values in a
+            -- single set; a scalar sets a single value.
+            if type(value) == "table" then
+                local vals = {}
+                local n = 0
+                for j = 1, #value do
+                    local val = resolve_header_value(value[j], ctx)
+                    if val then
+                        n = n + 1
+                        vals[n] = val
+                    end
+                end
+                if n > 0 then
+                    core.request.set_header(ctx, header, vals)
+                end
+            else
+                local val = resolve_header_value(value, ctx)
+                core.request.set_header(ctx, header, val)
+            end
         end
 
         local field_cnt = #hdr_op.remove
diff --git a/docs/en/latest/plugins/proxy-rewrite.md 
b/docs/en/latest/plugins/proxy-rewrite.md
index d23ce9230..6a0052480 100644
--- a/docs/en/latest/plugins/proxy-rewrite.md
+++ b/docs/en/latest/plugins/proxy-rewrite.md
@@ -45,8 +45,8 @@ The `proxy-rewrite` Plugin offers options to rewrite requests 
that APISIX forwar
 | regex_uri                   | array[string] | False    |         |           
                                                                                
                                             | Regular expressions used to 
match the URI path from client requests and compose a new Upstream URI path. 
When both `uri` and `regex_uri` are configured, `uri` has a higher priority. 
The array should contain one or more **key-value pairs**, with the key being 
the regular expression to m [...]
 | host                        | string        | False    |         |           
                                                                                
                                             | Set 
[`Host`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Host) 
request header.                                                                 
                                                                                
                                                  [...]
 | headers                     | object        | False    |         |           
                                                                                
                                        |   Header actions to be executed. Can 
be set to objects of action verbs `add`, `remove`, and/or `set`; or an object 
consisting of headers to be `set`. When multiple action verbs are configured, 
actions are executed in the order of `add`, `remove`, and `set`.                
|
-| headers.add     | object   | False     |        |                 | Headers 
to append to requests. If a header already present in the request, the header 
value will be appended. Header value could be set to a constant, one or more 
[NGINX variables](https://nginx.org/en/docs/http/ngx_http_core_module.html), or 
the matched result of `regex_uri` using variables such as `$1-$2-$3`.           
                                                                                
   |
-| headers.set     | object  | False     |        |                 | Headers 
to set to requests. If a header already present in the request, the header 
value will be overwritten. Header value could be set to a constant, one or more 
[NGINX variables](https://nginx.org/en/docs/http/ngx_http_core_module.html), or 
the matched result of `regex_uri` using variables such as `$1-$2-$3`. Should 
not be used to set `Host`.                                                      
                        [...]
+| headers.add     | object   | False     |        |                 | Headers 
to append to requests. If a header already present in the request, the header 
value will be appended. Header value could be set to a constant, one or more 
[NGINX variables](https://nginx.org/en/docs/http/ngx_http_core_module.html), or 
the matched result of `regex_uri` using variables such as `$1-$2-$3`. A value 
could also be an array of such values (e.g. `["val1", "val2"]`) to append the 
header multiple times,  [...]
+| headers.set     | object  | False     |        |                 | Headers 
to set to requests. If a header already present in the request, the header 
value will be overwritten. Header value could be set to a constant, one or more 
[NGINX variables](https://nginx.org/en/docs/http/ngx_http_core_module.html), or 
the matched result of `regex_uri` using variables such as `$1-$2-$3`. A value 
could also be an array of such values (e.g. `["val1", "val2"]`) to replace the 
header with multiple va [...]
 | headers.remove  | array[string]   | False     |        |                 | 
Headers to remove from requests.
 | use_real_request_uri_unsafe | boolean       | False    | false   |           
                                                                                
                                             | If true, bypass URI 
normalization and allow for the full original request URI. Enabling this option 
is considered unsafe.         |
 
@@ -227,6 +227,40 @@ You should see a response similar to the following:
 
 Note that both headers present and the header value of `X-Api-Version` 
configured in the Plugin is appended by the header value passed in the request.
 
+### Set or Append Multiple Values for the Same Header
+
+Both `set` and `add` accept an array value to produce multiple headers with 
the same name. Use `set` to replace any incoming header with the listed values, 
or `add` to append them to the existing ones.
+
+The following example sets two `X-Api-Version` headers on the upstream request:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes"; -X PUT \
+  -H "X-API-KEY: ${admin_key}" \
+  -d '{
+    "id": "proxy-rewrite-route",
+    "methods": ["GET"],
+    "uri": "/",
+    "plugins": {
+      "proxy-rewrite": {
+        "uri": "/headers",
+        "headers": {
+          "set": {
+            "X-Api-Version": ["v1", "v2"]
+          }
+        }
+      }
+    },
+    "upstream": {
+      "type": "roundrobin",
+      "nodes": {
+        "httpbin.org:80": 1
+      }
+    }
+  }'
+```
+
+The upstream receives `X-Api-Version: v1` and `X-Api-Version: v2`. Replacing 
`set` with `add` keeps any `X-Api-Version` already present in the client 
request and appends `v1` and `v2` to it.
+
 ### Remove Existing Header
 
 The following example demonstrates how you can remove an existing header 
`User-Agent`.
diff --git a/docs/zh/latest/plugins/proxy-rewrite.md 
b/docs/zh/latest/plugins/proxy-rewrite.md
index 42047dbd7..f8229e078 100644
--- a/docs/zh/latest/plugins/proxy-rewrite.md
+++ b/docs/zh/latest/plugins/proxy-rewrite.md
@@ -45,8 +45,8 @@ description: proxy-rewrite 插件支持重写 APISIX 转发到上游服务的请
 | regex_uri | array[string] | 否 | | | 用于匹配客户端请求的 URI 路径并组成新的上游 URI 
路径的正则表达式。当同时配置 `uri` 和 `regex_uri` 时,`uri` 具有更高的优先级。该数组应包含一个或多个 
**键值对**,其中键是用于匹配 URI 的正则表达式,值是新的上游 URI 路径。例如,对于 `["^/iresty/(. *)/(. *)", 
"/$1-$2", ^/theothers/*", "/theothers"]`,如果请求最初发送到 `/iresty/hello/world`,插件会将上游 
URI 路径重写为 `/iresty/hello-world`;如果请求最初发送到 `/theothers/hello/world`,插件会将上游 URI 
路径重写为 `/theothers`。|
 | host | string | 否 | | | 设置 
[`Host`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Host) 请求标头。|
 | headers | object | 否 | | | 要执行的标头操作。可以设置为动作动词 `add`、`remove` 和/或 `set` 
的对象;或由要 `set` 的标头组成的对象。当配置了多个动作动词时,动作将按照“添加”、“删除”和“设置”的顺序执行。|
-| headers.add | object | 否 | | | 
要附加到请求的标头。如果请求中已经存在标头,则会附加标头值。标头值可以设置为常量、一个或多个 [NGINX 
变量](https://nginx.org/en/docs/http/ngx_http_core_module.html),或者 `regex_uri` 
的匹配结果(使用变量,例如 `$1-$2-$3`)。|
-| headers.set | object | 否 | | | 要设置请求的标头。如果请求中已经存在标头,则会覆盖标头值。标头值可以设置为常量、一个或多个 
[NGINX 变量](https://nginx.org/en/docs/http/ngx_http_core_module.html),或者 
`regex_uri` 的匹配结果(使用变量,例如 `$1-$2-$3`)。不应将其用于设置 `Host`。|
+| headers.add | object | 否 | | | 
要附加到请求的标头。如果请求中已经存在标头,则会附加标头值。标头值可以设置为常量、一个或多个 [NGINX 
变量](https://nginx.org/en/docs/http/ngx_http_core_module.html),或者 `regex_uri` 
的匹配结果(使用变量,例如 `$1-$2-$3`)。标头值也可以是上述值的数组(例如 `["val1", 
"val2"]`),从而多次附加该标头,生成多个同名标头。|
+| headers.set | object | 否 | | | 要设置请求的标头。如果请求中已经存在标头,则会覆盖标头值。标头值可以设置为常量、一个或多个 
[NGINX 变量](https://nginx.org/en/docs/http/ngx_http_core_module.html),或者 
`regex_uri` 的匹配结果(使用变量,例如 `$1-$2-$3`)。标头值也可以是上述值的数组(例如 `["val1", 
"val2"]`),从而将该标头替换为多个值(多个同名标头)。不应将其用于设置 `Host`。|
 | headers.remove | array[string] | 否 | | | 从请求中删除的标头。
 | use_real_request_uri_unsafe | boolean | 否 | false | | 如果为 True,则绕过 URI 
规范化并允许完整的原始请求 URI。启用此选项被视为不安全。|
 
@@ -227,6 +227,40 @@ curl "http://127.0.0.1:9080/"; -H '"X-Api-Version": "v2"'
 
 请注意,两个标头均存在,并且插件中配置的 `X-Api-Version` 标头值均附加在请求中传递的标头值上。
 
+### 为同一标头设置或附加多个值
+
+`set` 和 `add` 都支持数组值,用于生成多个同名标头。使用 `set` 将传入标头替换为列表中的多个值,或使用 `add` 
在已有标头之上追加这些值。
+
+以下示例在上游请求上设置两个 `X-Api-Version` 标头:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes"; -X PUT \
+  -H "X-API-KEY: ${admin_key}" \
+  -d '{
+    "id": "proxy-rewrite-route",
+    "methods": ["GET"],
+    "uri": "/",
+    "plugins": {
+      "proxy-rewrite": {
+        "uri": "/headers",
+        "headers": {
+          "set": {
+            "X-Api-Version": ["v1", "v2"]
+          }
+        }
+      }
+    },
+    "upstream": {
+      "type": "roundrobin",
+      "nodes": {
+        "httpbin.org:80": 1
+      }
+    }
+  }'
+```
+
+上游会收到 `X-Api-Version: v1` 和 `X-Api-Version: v2`。将 `set` 替换为 `add` 则保留客户端请求中已有的 
`X-Api-Version`,并在其后追加 `v1` 和 `v2`。
+
 ### 删除现有标头
 
 以下示例演示了如何删除现有标头 `User-Agent`。
diff --git a/t/lib/server.lua b/t/lib/server.lua
index 35322155d..119d64f8b 100644
--- a/t/lib/server.lua
+++ b/t/lib/server.lua
@@ -1299,4 +1299,32 @@ function _M.mock_compressed_upstream_response()
 end
 
 
+-- echo received request headers, emitting one line per occurrence so that
+-- same-name (multi-value) headers are distinguishable from a single
+-- comma-joined value. A genuine multi-value header arrives as a table from
+-- ngx.req.get_headers() and is printed as repeated "name: value" lines.
+function _M.plugin_proxy_rewrite_multi_header()
+    local headers = ngx.req.get_headers()
+
+    local keys = {}
+    for k in pairs(headers) do
+        if not builtin_hdr_ignore_list[k] then
+            table.insert(keys, k)
+        end
+    end
+    table.sort(keys)
+
+    for _, key in ipairs(keys) do
+        local v = headers[key]
+        if type(v) == "table" then
+            for _, item in ipairs(v) do
+                ngx.say(key, ": ", item)
+            end
+        else
+            ngx.say(key, ": ", v)
+        end
+    end
+end
+
+
 return _M
diff --git a/t/plugin/proxy-rewrite.t b/t/plugin/proxy-rewrite.t
index 6b75fbe7c..7ab2abcde 100644
--- a/t/plugin/proxy-rewrite.t
+++ b/t/plugin/proxy-rewrite.t
@@ -1516,3 +1516,211 @@ GET /hello?name=a%20b&x=1 HTTP/1.1
 --- response_body
 ngx.var.uri: /print_uri_detailed
 ngx.var.request_uri: /print_uri_detailed?name=a%20b&x=1
+
+
+
+=== TEST 58: set route(set header with multiple values)
+--- 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": "/plugin_proxy_rewrite_multi_header",
+                                "headers": {
+                                    "set": {
+                                        "x-multi": ["val1", "val2"]
+                                    }
+                                }
+                            }
+                        },
+                        "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 59: set replaces the incoming header with multiple same-name headers
+--- request
+GET /hello
+--- more_headers
+x-multi: origin
+--- response_body_like eval
+qr/x-multi: val1\nx-multi: val2/
+--- response_body_unlike eval
+qr/x-multi: origin/
+
+
+
+=== TEST 60: set route(add header with multiple values)
+--- 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": "/plugin_proxy_rewrite_multi_header",
+                                "headers": {
+                                    "add": {
+                                        "x-multi": ["val1", "val2"]
+                                    }
+                                }
+                            }
+                        },
+                        "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 61: add appends multiple same-name headers to the incoming header
+--- request
+GET /hello
+--- more_headers
+x-multi: origin
+--- response_body_like eval
+qr/x-multi: origin\nx-multi: val1\nx-multi: val2/
+
+
+
+=== TEST 62: set route(multi-value with regex_uri capture and nginx variable)
+--- 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": {
+                                "regex_uri": ["^/test/(.*)",
+                                    "/plugin_proxy_rewrite_multi_header"],
+                                "headers": {
+                                    "set": {
+                                        "x-multi": ["cap-$1", "$http_x_src"]
+                                    }
+                                }
+                            }
+                        },
+                        "upstream": {
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            },
+                            "type": "roundrobin"
+                        },
+                        "uri": "/test/*"
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 63: each array element resolves capture and variable
+--- request
+GET /test/echo
+--- more_headers
+x-src: from-src
+--- response_body_like eval
+qr/x-multi: cap-echo\nx-multi: from-src/
+
+
+
+=== TEST 64: add route(multi-value with regex_uri capture and nginx variable)
+--- 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": {
+                                "regex_uri": ["^/test/(.*)",
+                                    "/plugin_proxy_rewrite_multi_header"],
+                                "headers": {
+                                    "add": {
+                                        "x-multi": ["cap-$1", "$http_x_src"]
+                                    }
+                                }
+                            }
+                        },
+                        "upstream": {
+                            "nodes": {
+                                "127.0.0.1:1980": 1
+                            },
+                            "type": "roundrobin"
+                        },
+                        "uri": "/test/*"
+                }]]
+                )
+
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 65: add resolves capture and variable for each array element
+--- request
+GET /test/echo
+--- more_headers
+x-src: from-src
+--- response_body_like eval
+qr/x-multi: cap-echo\nx-multi: from-src/

Reply via email to