From a4351064209524b7ae926a2a90f22ef9e9e9293f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BD=97=E6=B3=BD=E8=BD=A9?= Date: Tue, 30 Aug 2022 14:32:24 +0800 Subject: [PATCH] feat(response-rewrite): support adding header (#7794) --- apisix/plugins/response-rewrite.lua | 160 +++++++++++++++--- docs/en/latest/plugins/response-rewrite.md | 61 ++++--- docs/zh/latest/plugins/response-rewrite.md | 61 ++++--- t/plugin/response-rewrite2.t | 178 +++++++++++++++++++++ 4 files changed, 402 insertions(+), 58 deletions(-) diff --git a/apisix/plugins/response-rewrite.lua b/apisix/plugins/response-rewrite.lua index 9a4015fb98bb..c07924253eb9 100644 --- a/apisix/plugins/response-rewrite.lua +++ b/apisix/plugins/response-rewrite.lua @@ -19,6 +19,7 @@ local expr = require("resty.expr.v1") local re_compile = require("resty.core.regex").re_match_compile local plugin_name = "response-rewrite" local ngx = ngx +local re_match = ngx.re.match local re_sub = ngx.re.sub local re_gsub = ngx.re.gsub local pairs = pairs @@ -27,13 +28,63 @@ local type = type local pcall = pcall +local lrucache = core.lrucache.new({ + type = "plugin", +}) + local schema = { type = "object", properties = { headers = { description = "new headers for response", - type = "object", - minProperties = 1, + anyOf = { + { + type = "object", + minProperties = 1, + patternProperties = { + ["^[^:]+$"] = { + oneOf = { + {type = "string"}, + {type = "number"}, + } + } + }, + }, + { + properties = { + add = { + type = "array", + minItems = 1, + items = { + type = "string", + -- "Set-Cookie: =; Max-Age=" + pattern = "^[^:]+:[^:]+[^/]$" + } + }, + set = { + type = "object", + minProperties = 1, + patternProperties = { + ["^[^:]+$"] = { + oneOf = { + {type = "string"}, + {type = "number"}, + } + } + }, + }, + remove = { + type = "array", + minItems = 1, + items = { + type = "string", + -- "Set-Cookie" + pattern = "^[^:]+$" + } + }, + }, + } + } }, body = { description = "new body for response", @@ -121,6 +172,33 @@ local function vars_matched(conf, ctx) end +local function is_new_headers_conf(headers) + return + (headers.add and type(headers.add) == "table") or + (headers.set and type(headers.set) == "table") or + (headers.remove and type(headers.remove) == "table") +end + + +local function check_set_headers(headers) + for field, value in pairs(headers) do + if type(field) ~= 'string' then + return false, 'invalid type as header field' + end + + if type(value) ~= 'string' and type(value) ~= 'number' then + return false, 'invalid type as header value' + end + + if #field == 0 then + return false, 'invalid field length in header' + end + end + + return true +end + + function _M.check_schema(conf) local ok, err = core.schema.check(schema, conf) if not ok then @@ -128,17 +206,10 @@ function _M.check_schema(conf) end if conf.headers then - for field, value in pairs(conf.headers) do - if type(field) ~= 'string' then - return false, 'invalid type as header field' - end - - if type(value) ~= 'string' and type(value) ~= 'number' then - return false, 'invalid type as header value' - end - - if #field == 0 then - return false, 'invalid field length in header' + if not is_new_headers_conf(conf.headers) then + ok, err = check_set_headers(conf.headers) + if not ok then + return false, err end end end @@ -216,6 +287,42 @@ function _M.body_filter(conf, ctx) end end + +local function create_header_operation(hdr_conf) + local set = {} + local add = {} + if is_new_headers_conf(hdr_conf) then + if hdr_conf.add then + for _, value in ipairs(hdr_conf.add) do + local m, err = re_match(value, [[^([^:\s]+)\s*:\s*([^:]+)$]], "jo") + if not m then + return nil, err + end + core.table.insert_tail(add, m[1], m[2]) + end + end + + if hdr_conf.set then + for field, value in pairs(hdr_conf.set) do + --reform header from object into array, so can avoid use pairs, which is NYI + core.table.insert_tail(set, field, value) + end + end + + else + for field, value in pairs(hdr_conf) do + core.table.insert_tail(set, field, value) + end + end + + return { + add = add, + set = set, + remove = hdr_conf.remove or {}, + } +end + + function _M.header_filter(conf, ctx) ctx.response_rewrite_matched = vars_matched(conf, ctx) if not ctx.response_rewrite_matched then @@ -235,19 +342,28 @@ function _M.header_filter(conf, ctx) return end - --reform header from object into array, so can avoid use pairs, which is NYI - if not conf.headers_arr then - conf.headers_arr = {} + local hdr_op, err = core.lrucache.plugin_ctx(lrucache, ctx, nil, + create_header_operation, conf.headers) + if not hdr_op then + core.log.error("failed to create header operation: ", err) + return + end - for field, value in pairs(conf.headers) do - core.table.insert_tail(conf.headers_arr, field, value) - end + local field_cnt = #hdr_op.add + for i = 1, field_cnt, 2 do + local val = core.utils.resolve_var(hdr_op.add[i+1], ctx.var) + core.response.add_header(hdr_op.add[i], val) end - local field_cnt = #conf.headers_arr + local field_cnt = #hdr_op.set for i = 1, field_cnt, 2 do - local val = core.utils.resolve_var(conf.headers_arr[i+1], ctx.var) - ngx.header[conf.headers_arr[i]] = val + local val = core.utils.resolve_var(hdr_op.set[i+1], ctx.var) + core.response.set_header(hdr_op.set[i], val) + end + + local field_cnt = #hdr_op.remove + for i = 1, field_cnt do + core.response.set_header(hdr_op.remove[i], nil) end end diff --git a/docs/en/latest/plugins/response-rewrite.md b/docs/en/latest/plugins/response-rewrite.md index 91739d39ff82..cce6103ca77a 100644 --- a/docs/en/latest/plugins/response-rewrite.md +++ b/docs/en/latest/plugins/response-rewrite.md @@ -44,18 +44,21 @@ You can also use the [redirect](./redirect.md) Plugin to setup redirects. ## Attributes -| Name | Type | Required | Default | Valid values | Description | -|-------------|---------|----------|---------|---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| status_code | integer | False | | [200, 598] | New HTTP status code in the response. If unset, falls back to the original status code. | -| body | string | False | | | New body of the response. The content-length would also be reset. | -| body_base64 | boolean | False | false | | When set, the body of the request will be decoded before writing to the client. | -| headers | object | False | | | New headers for the response. Headers are overwritten if they are present in the Upstream response otherwise, they are added to the Upstream headers. To remove a header, set the header value to an empty string. The values in the header can contain Nginx variables like `$remote_addr` and `$balancer_ip`. | -| vars | array[] | False | | See [lua-resty-expr](https://github.com/api7/lua-resty-expr#operator-list) for a list of available operators. | Nginx variable expressions to conditionally execute the rewrite. The Plugin will be executed unconditionally if this value is empty. | -| filters | array[] | False | | | List of filters that modify the response body by replacing one specified string with another. | -| filters.regex | string | True | | | Regex pattern to match on the response body. | -| filters.scope | string | False | "once" | "once","global" | Range to substitute. `once` substitutes the first match of `filters.regex` and `global` does global substitution. | -| filters.replace | string | True | | | Content to substitute with. | -| filters.options | string | False | "jo" | | Regex options. See [ngx.re.match](https://github.com/openresty/lua-nginx-module#ngxrematch). | +| Name | Type | Required | Default | Valid values | Description | +|-----------------|---------|----------|---------|---------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| status_code | integer | False | | [200, 598] | New HTTP status code in the response. If unset, falls back to the original status code. | +| body | string | False | | | New body of the response. The content-length would also be reset. | +| body_base64 | boolean | False | false | | When set, the body of the request will be decoded before writing to the client. | +| headers | object | False | | | | +| headers.add | array | False | | | Append the new headers to the response. The format is `["name: value", ...]`. The values in the header can contain Nginx variables like `$remote_addr` and `$balancer_ip`. | +| headers.set | object | False | | | Rewriting the headers. The format is `{"name": "value", ...}`. The values in the header can contain Nginx variables like `$remote_addr` and `$balancer_ip`. | +| headers.remove | array | False | | | Remove the headers. The format is `["name", ...]`. | +| vars | array[] | False | | See [lua-resty-expr](https://github.com/api7/lua-resty-expr#operator-list) for a list of available operators. | Nginx variable expressions to conditionally execute the rewrite. The Plugin will be executed unconditionally if this value is empty. | +| filters | array[] | False | | | List of filters that modify the response body by replacing one specified string with another. | +| filters.regex | string | True | | | Regex pattern to match on the response body. | +| filters.scope | string | False | "once" | "once","global" | Range to substitute. `once` substitutes the first match of `filters.regex` and `global` does global substitution. | +| filters.replace | string | True | | | Content to substitute with. | +| filters.options | string | False | "jo" | | Regex options. See [ngx.re.match](https://github.com/openresty/lua-nginx-module#ngxrematch). | :::note @@ -76,9 +79,11 @@ curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f1 "response-rewrite": { "body": "{\"code\":\"ok\",\"message\":\"new json body\"}", "headers": { - "X-Server-id": 3, - "X-Server-status": "on", - "X-Server-balancer_addr": "$balancer_ip:$balancer_port" + "set": { + "X-Server-id": 3, + "X-Server-status": "on", + "X-Server-balancer_addr": "$balancer_ip:$balancer_port" + } }, "vars":[ [ "status","==",200 ] @@ -96,6 +101,24 @@ curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f1 Here, `vars` is configured to run the Plugin only on responses with a 200 status code. +Besides `set` operation, you can also `add` or `remove` response header like: + +```json +"headers": { + "add": [ + "X-Server-balancer_addr: $balancer_ip:$balancer_port" + ], + "remove": [ + "X-TO-BE-REMOVED" + ] +} +``` + +The execution order among those operations are ["add", "set", "remove"]. + +If you are using the deprecated `headers` configuration which puts the headers directly under `headers`, +you need to move them to `headers.set`. + ## Example usage Once you have enabled the Plugin as shown above, you can make a request: @@ -143,9 +166,11 @@ curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f13 "plugins":{ "response-rewrite":{ "headers":{ - "X-Server-id":3, - "X-Server-status":"on", - "X-Server-balancer_addr":"$balancer_ip:$balancer_port" + "set": { + "X-Server-id":3, + "X-Server-status":"on", + "X-Server-balancer_addr":"$balancer_ip:$balancer_port" + } }, "filters":[ { diff --git a/docs/zh/latest/plugins/response-rewrite.md b/docs/zh/latest/plugins/response-rewrite.md index c89b0256b4c8..649fcef7c8ed 100644 --- a/docs/zh/latest/plugins/response-rewrite.md +++ b/docs/zh/latest/plugins/response-rewrite.md @@ -44,18 +44,21 @@ description: 本文介绍了关于 Apache APISIX `response-rewrite` 插件的基 ## 属性 -| 名称 | 类型 | 必选项 | 默认值 | 有效值 | 描述 | -|-----------------|---------|-----|--------|-----------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------| -| status_code | integer | 否 | | [200, 598] | 修改上游返回状态码,默认保留原始响应代码。 | -| body | string | 否 | | | 修改上游返回的 `body` 内容,如果设置了新内容,header 里面的 content-length 字段也会被去掉。 | -| body_base64 | boolean | 否 | false | | 描述 `body` 字段是否需要 base64 解码之后再返回给客户端,用在某些图片和 Protobuffer 场景。 | -| headers | object | 否 | | | 返回给客户端的 `headers`,这里可以设置多个。头信息如果存在将重写,不存在则添加。想要删除某个 header 的话,把对应的值设置为空字符串即可。这个值能够以 `$var` 的格式包含 NGINX 变量,比如 `$remote_addr $balancer_ip`。 | -| vars | array[] | 否 | | | `vars` 是一个表达式列表,只有满足条件的请求和响应才会修改 body 和 header 信息,来自 [lua-resty-expr](https://github.com/api7/lua-resty-expr#operator-list)。如果 `vars` 字段为空,那么所有的重写动作都会被无条件的执行。 | -| filters | array[] | 否 | | | 一组过滤器,采用指定字符串表达式修改响应体。 | -| filters.regex | string | 是 | | | 用于匹配响应体正则表达式。 | -| filters.scope | string | 否 | "once" | "once","global" | 替换范围,"once" 表达式 `filters.regex` 仅替换首次匹配上响应体的内容,"global" 则进行全局替换。 | -| filters.replace | string | 是 | | | 替换后的内容。 | -| filters.options | string | 否 | "jo" | | 正则匹配有效参数,可选项见 [ngx.re.match](https://github.com/openresty/lua-nginx-module#ngxrematch)。 | +| 名称 | 类型 | 必选项 | 默认值 | 有效值 | 描述 | +|-----------------|---------|--------|--------|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| status_code | integer | 否 | | [200, 598] | 修改上游返回状态码,默认保留原始响应代码。 | +| body | string | 否 | | | 修改上游返回的 `body` 内容,如果设置了新内容,header 里面的 content-length 字段也会被去掉。 | +| body_base64 | boolean | 否 | false | | 描述 `body` 字段是否需要 base64 解码之后再返回给客户端,用在某些图片和 Protobuffer 场景。 | +| headers | object | 否 | | | | +| headers.add | array | 否 | | | 添加新的响应头。格式为 `["name: value", ...]`。这个值能够以 `$var` 的格式包含 NGINX 变量,比如 `$remote_addr $balancer_ip`。 | +| headers.set | object | 否 | | | 改写响应头。格式为 `{"name": "value", ...}`。这个值能够以 `$var` 的格式包含 NGINX 变量,比如 `$remote_addr $balancer_ip`。 | +| headers.remove | array | 否 | | | 移除响应头。格式为 `["name", ...]`。 | +| vars | array[] | 否 | | | `vars` 是一个表达式列表,只有满足条件的请求和响应才会修改 body 和 header 信息,来自 [lua-resty-expr](https://github.com/api7/lua-resty-expr#operator-list)。如果 `vars` 字段为空,那么所有的重写动作都会被无条件的执行。 | +| filters | array[] | 否 | | | 一组过滤器,采用指定字符串表达式修改响应体。 | +| filters.regex | string | 是 | | | 用于匹配响应体正则表达式。 | +| filters.scope | string | 否 | "once" | "once","global" | 替换范围,"once" 表达式 `filters.regex` 仅替换首次匹配上响应体的内容,"global" 则进行全局替换。 | +| filters.replace | string | 是 | | | 替换后的内容。 | +| filters.options | string | 否 | "jo" | | 正则匹配有效参数,可选项见 [ngx.re.match](https://github.com/openresty/lua-nginx-module#ngxrematch)。 | :::note @@ -77,9 +80,11 @@ curl http://127.0.0.1:9080/apisix/admin/routes/1 \ "response-rewrite": { "body": "{\"code\":\"ok\",\"message\":\"new json body\"}", "headers": { - "X-Server-id": 3, - "X-Server-status": "on", - "X-Server-balancer_addr": "$balancer_ip:$balancer_port" + "set": { + "X-Server-id": 3, + "X-Server-status": "on", + "X-Server-balancer_addr": "$balancer_ip:$balancer_port" + } }, "vars":[ [ "status","==",200 ] @@ -97,6 +102,24 @@ curl http://127.0.0.1:9080/apisix/admin/routes/1 \ 在上述命令中,通过配置 `vars` 参数可以让该插件仅在具有 200 状态码的响应上运行插件。 +除了 `set` 操作,你也可以像这样增加或移除响应头: + +```json +"headers": { + "add": [ + "X-Server-balancer_addr: $balancer_ip:$balancer_port" + ], + "remove": [ + "X-TO-BE-REMOVED" + ] +} +``` + +这些操作的执行顺序为 ["add", "set", "remove"]。 + +我们不再对直接在 `headers` 下面设置响应头的方式提供支持。 +如果你的配置是把响应头设置到 `headers` 的下一层,你需要将这些配置移到 `headers.set`。 + ## 测试插件 通过上述命令启用插件后,可以使用如下命令测试插件是否启用成功: @@ -142,9 +165,11 @@ curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f13 "plugins":{ "response-rewrite":{ "headers":{ - "X-Server-id":3, - "X-Server-status":"on", - "X-Server-balancer_addr":"$balancer_ip:$balancer_port" + "set": { + "X-Server-id":3, + "X-Server-status":"on", + "X-Server-balancer_addr":"$balancer_ip:$balancer_port" + } }, "filters":[ { diff --git a/t/plugin/response-rewrite2.t b/t/plugin/response-rewrite2.t index 48401f915308..e3209314632a 100644 --- a/t/plugin/response-rewrite2.t +++ b/t/plugin/response-rewrite2.t @@ -517,3 +517,181 @@ passed GET /hello --- response_body hello world + + + +=== TEST 19: schema check for headers +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + for _, case in ipairs({ + {add = { + {"headers:"} + }}, + {remove = { + {"headers:"} + }}, + {set = { + {"headers"} + }}, + {set = { + {[""] = 1} + }}, + {set = { + {["a"] = true} + }}, + }) do + local plugin = require("apisix.plugins.response-rewrite") + local ok, err = plugin.check_schema({headers = case}) + if not ok then + ngx.say(err) + else + ngx.say("done") + end + end + } +} +--- response_body eval +"property \"headers\" validation failed: object matches none of the required\n" x 5 + + + +=== TEST 20: add headers +--- 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": { + "response-rewrite": { + "headers": { + "add": [ + "Cache-Control: no-cache", + "Cache-Control : max-age=0, must-revalidate" + ] + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uris": ["/hello"] + }]] + ) + + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 21: hit +--- request +GET /hello +--- response_headers +Cache-Control: no-cache, max-age=0, must-revalidate + + + +=== TEST 22: set headers +--- 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": { + "response-rewrite": { + "headers": { + "add": [ + "Cache-Control: no-cache" + ], + "set": { + "Cache-Control": "max-age=0, must-revalidate" + } + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uris": ["/hello"] + }]] + ) + + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 23: hit +--- request +GET /hello +--- response_headers +Cache-Control: max-age=0, must-revalidate + + + +=== TEST 24: remove headers +--- 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": { + "response-rewrite": { + "headers": { + "add": [ + "Set-Cookie: =; Max-Age=" + ], + "set": { + "Cache-Control": "max-age=0, must-revalidate" + }, + "remove": [ + "Set-Cookie", + "Cache-Control" + ] + } + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uris": ["/hello"] + }]] + ) + + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 25: hit +--- request +GET /hello +--- response_headers +Cache-Control: +Set-Cookie: