From e5c24a10fb88f69ea4f4cca8b2c10f233005a085 Mon Sep 17 00:00:00 2001 From: DefNed <8519834+Fengxq2014@users.noreply.github.com> Date: Sun, 6 Apr 2025 09:04:40 +0800 Subject: [PATCH] feat: update custom-response plugin to returns different contents for different response status (#2002) --- .../extensions/custom-response/README.md | 150 ++++++++++++-- .../extensions/custom-response/README_EN.md | 165 +++++++++++++-- .../extensions/custom-response/VERSION | 2 +- .../custom-response/docker-compose.yaml | 25 +++ .../extensions/custom-response/envoy.yaml | 102 ++++++++-- .../extensions/custom-response/main.go | 190 ++++++++++++++---- .../extensions/custom-response/main_test.go | 80 ++++++++ 7 files changed, 627 insertions(+), 87 deletions(-) create mode 100644 plugins/wasm-go/extensions/custom-response/docker-compose.yaml create mode 100644 plugins/wasm-go/extensions/custom-response/main_test.go diff --git a/plugins/wasm-go/extensions/custom-response/README.md b/plugins/wasm-go/extensions/custom-response/README.md index e59863441..b4e6f972e 100644 --- a/plugins/wasm-go/extensions/custom-response/README.md +++ b/plugins/wasm-go/extensions/custom-response/README.md @@ -14,34 +14,152 @@ description: 自定义应答插件配置参考 插件执行优先级:`910` ## 配置字段 +### 新版本-支持多种返回 +| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | +|-------|------------------|------|-----|-------------------------------------| +| rules | array of object | 必填 | - | 规则组 | -| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | -| -------- | -------- | -------- | -------- | -------- | -| status_code | number | 选填 | 200 | 自定义 HTTP 应答状态码 | -| headers | array of string | 选填 | - | 自定义 HTTP 应答头,key 和 value 用`=`分隔 | -| body | string | 选填 | - | 自定义 HTTP 应答 Body | -| enable_on_status | array of number | 选填 | - | 匹配原始状态码,生成自定义响应,不填写时,不判断原始状态码 | +`rules`的配置字段说明如下: + +| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | +|--------------------|---------------------------|------|-----|------------------------------------------------------------------------------------------------------------------------------------------------------| +| `status_code` | number | 选填 | 200 | 自定义 HTTP 应答状态码 | +| `headers` | array of string | 选填 | - | 自定义 HTTP 应答头,key 和 value 用`=`分隔 | +| `body` | string | 选填 | - | 自定义 HTTP 应答 Body | +| `enable_on_status` | array of string or number | 选填 | - | 匹配原始状态码,生成自定义响应。可填写精确值如:`200`,`404`等,也可以模糊匹配例如:`2xx`来匹配200-299之间的状态码,`20x`来匹配200-209之间的状态码,x代表任意一位数字。不填写时,不判断原始状态码,取第一个`enable_on_status`为空的规则作为默认规则 | + +#### 模糊匹配规则: +* 长度为3 +* 至少一位数字 +* 至少一位x(不区分大小写) + +| 规则 | 匹配内容 | +|-----|------------------------------------------------------------------------------------------| +| 40x | 400-409;前两位为40的情况 | +| 1x4 | 104,114,124,134,144,154,164,174,184,194;第一位和第三位分别为1和4的情况 | +| x23 | 023,123,223,323,423,523,623,723,823,923;第二位和第三位为23的情况 | +| 4xx | 400-499;第一位为4的情况 | +| x4x | 040-049,140-149,240-249,340-349,440-449,540-549,640-649,740-749,840-849,940-949;第二位为4的情况 | +| xx4 | 尾数为4的情况 | + +### 老版本-只支持一种返回 +| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | +| -------- | -------- |------| -------- |---------------------------------| +| `status_code` | number | 选填 | 200 | 自定义 HTTP 应答状态码 | +| `headers` | array of string | 选填 | - | 自定义 HTTP 应答头,key 和 value 用`=`分隔 | +| `body` | string | 选填 | - | 自定义 HTTP 应答 Body | +| `enable_on_status` | array of number | 选填 | - | 匹配原始状态码,生成自定义响应,不填写时,不判断原始状态码 | + +匹配优先级:精确匹配 > 模糊匹配 > 默认配置(第一个enable_on_status为空的配置) ## 配置示例 -### Mock 应答场景 +### 新版本-不同状态码不同应答场景 ```yaml -status_code: 200 -headers: -- Content-Type=application/json -- Hello=World -body: "{\"hello\":\"world\"}" - +rules: + - body: '{"hello":"world 200"}' + enable_on_status: + - 200 + - 201 + headers: + - key1=value1 + - key2=value2 + status_code: 200 + - body: '{"hello":"world 404"}' + enable_on_status: + - 404 + headers: + - key1=value1 + - key2=value2 + status_code: 200 ``` -根据该配置,请求将返回自定义应答如下: +根据该配置,200、201请求将返回自定义应答如下: ```text HTTP/1.1 200 OK Content-Type: application/json -Hello: World -Content-Length: 17 +key1: value1 +key2: value2 +Content-Length: 21 + +{"hello":"world 200"} +``` +根据该配置,404请求将返回自定义应答如下: + +```text +HTTP/1.1 200 OK +Content-Type: application/json +key1: value1 +key2: value2 +Content-Length: 21 + +{"hello":"world 400"} +``` + +### 新版本-模糊匹配场景 + +```yaml +rules: + - body: '{"hello":"world 200"}' + enable_on_status: + - 200 + headers: + - key1=value1 + - key2=value2 + status_code: 200 + - body: '{"hello":"world 40x"}' + enable_on_status: + - '40x' + headers: + - key1=value1 + - key2=value2 + status_code: 200 +``` + +根据该配置,200状态码将返回自定义应答如下: + +```text +HTTP/1.1 200 OK +Content-Type: application/json +key1: value1 +key2: value2 +Content-Length: 21 + +{"hello":"world 200"} +``` +根据该配置,401-409之间的状态码将返回自定义应答如下: + +```text +HTTP/1.1 200 OK +Content-Type: application/json +key1: value1 +key2: value2 +Content-Length: 21 + +{"hello":"world 40x"} +``` + +### 老版本-不同状态码相同应答场景 + +```yaml +enable_on_status: + - 200 +status_code: 200 +headers: + - Content-Type=application/json + - Hello=World +body: "{\"hello\":\"world\"}" +``` +根据该配置,200请求将返回自定义应答如下: + +```text +HTTP/1.1 200 OK +Content-Type: application/json +key1: value1 +key2: value2 +Content-Length: 21 {"hello":"world"} ``` diff --git a/plugins/wasm-go/extensions/custom-response/README_EN.md b/plugins/wasm-go/extensions/custom-response/README_EN.md index 365696e20..4d5b071e0 100644 --- a/plugins/wasm-go/extensions/custom-response/README_EN.md +++ b/plugins/wasm-go/extensions/custom-response/README_EN.md @@ -12,30 +12,165 @@ Plugin Execution Phase: `Authentication Phase` Plugin Execution Priority: `910` ## Configuration Fields -| Name | Data Type | Requirements | Default Value | Description | -| -------- | -------- | -------- | -------- | -------- | -| status_code | number | Optional | 200 | Custom HTTP response status code | -| headers | array of string | Optional | - | Custom HTTP response headers, keys and values separated by `=` | -| body | string | Optional | - | Custom HTTP response body | -| enable_on_status | array of number | Optional | - | Match original status codes to generate custom responses; if not specified, the original status code is not checked | +### New version - Supports multiple returns +| Name | Data Type | Requirements | Default Value | Description | +|---------------------|-----------------|----------|-----|------------| +| rules | array of object | Required | - | rule array | + +The configuration field description of `rules` is as follows: + +| Name | Data Type | Requirements | Default Value | Description | +|--------------------|---------------------------|--------------|-----|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `status_code` | number | Optional | 200 | Custom HTTP response status code | +| `headers` | array of string | Optional | - | Custom HTTP response headers, keys and values separated by `=` | +| `body` | string | Optional | - | Custom HTTP response body | +| `enable_on_status` | array of string or number | Optional | - | Match the original status code to generate a custom response. You can fill in the exact value such as :`200`,`404`, etc., you can also fuzzy match such as: `2xx` to match the status code between 200-299, `20x` to match the status code between 200-209, x represents any digit. If enable_on_status is not specified, the original status code is not determined and the first rule with ENABLE_ON_status left blank is used as the default rule | + +#### Fuzzy matching rule +* Length is 3 +* At least one digit +* At least one x(case insensitive) + +| rule | Matching content | +|------|------------------------------------------------------------------------------------------| +| 40x | 400-409; If the first two digits are 40 | +| 1x4 | 104,114,124,134,144,154,164,174,184,194;The first and third positions are 1 and 4 respectively | +| x23 | 023,123,223,323,423,523,623,723,823,923;The second and third positions are 23 | +| 4xx | 400-499;The first digit is 4 | +| x4x | 040-049,140-149,240-249,340-349,440-449,540-549,640-649,740-749,840-849,940-949;The second digit is 4 | +| xx4 | When the mantissa is 4 | + +Matching priority: Exact Match > Fuzzy Match > Default configuration (the first enable_on_status parameter is null) + +## Old version - Only one return is supported +| Name | Data Type | Requirements | Default Value | Description | +| -------- | -------- | -------- | -------- |----------------------------------------------------------------------------------------------------| +| `status_code` | number | Optional | 200 | Custom HTTP response status code | +| `headers` | array of string | Optional | - | Custom HTTP response headers, keys and values separated by `=` | +| `body` | string | Optional | - | Custom HTTP response body | +| `enable_on_status` | array of number | Optional | - | Match original status codes to generate custom responses; if not specified, the original status code is not checked | + ## Configuration Example -### Mock Response Scenario + +### Different status codes for different response scenarios + ```yaml -status_code: 200 -headers: -- Content-Type=application/json -- Hello=World -body: "{\"hello\":\"world\"}" +rules: + - body: '{"hello":"world 200"}' + enable_on_status: + - '200' + - '201' + headers: + - key1=value1 + - key2=value2 + status_code: 200 + - body: '{"hello":"world 404"}' + enable_on_status: + - '404' + headers: + - key1=value1 + - key2=value2 + status_code: 200 ``` -With this configuration, the request will return the following custom response: + +According to this configuration 200 201 requests will return a custom response as follows: + ```text HTTP/1.1 200 OK Content-Type: application/json -Hello: World -Content-Length: 17 +key1: value1 +key2: value2 +Content-Length: 21 + +{"hello":"world 200"} +``` +According to this configuration 404 requests will return a custom response as follows: + +```text +HTTP/1.1 200 OK +Content-Type: application/json +key1: value1 +key2: value2 +Content-Length: 21 + +{"hello":"world 400"} +``` +With this configuration, 404 response will return the following custom response: +```text +HTTP/1.1 200 OK +Content-Type: application/json +key1: value1 +key2: value2 +Content-Length: 21 + +{"hello":"world 404"} +``` + +### Fuzzy matching scene + +```yaml +rules: + - body: '{"hello":"world 200"}' + enable_on_status: + - 200 + headers: + - key1=value1 + - key2=value2 + status_code: 200 + - body: '{"hello":"world 40x"}' + enable_on_status: + - '40x' + headers: + - key1=value1 + - key2=value2 + status_code: 200 +``` + +According to this configuration, the status 200 will return a custom reply as follows: + +```text +HTTP/1.1 200 OK +Content-Type: application/json +key1: value1 +key2: value2 +Content-Length: 21 + +{"hello":"world 200"} +``` +According to this configuration, the status code between 401-409 will return a custom reply as follows: + +```text +HTTP/1.1 200 OK +Content-Type: application/json +key1: value1 +key2: value2 +Content-Length: 21 + +{"hello":"world 40x"} +``` + +### Mock Response Scenario +```yaml +enable_on_status: + - 200 +status_code: 200 +headers: + - Content-Type=application/json + - Hello=World +body: "{\"hello\":\"world\"}" +``` +With this configuration, 200/201 response will return the following custom response: +```text +HTTP/1.1 200 OK +Content-Type: application/json +key1: value1 +key2: value2 +Content-Length: 21 + {"hello":"world"} ``` + ### Custom Response on Rate Limiting ```yaml enable_on_status: diff --git a/plugins/wasm-go/extensions/custom-response/VERSION b/plugins/wasm-go/extensions/custom-response/VERSION index 3eefcb9dd..9084fa2f7 100644 --- a/plugins/wasm-go/extensions/custom-response/VERSION +++ b/plugins/wasm-go/extensions/custom-response/VERSION @@ -1 +1 @@ -1.0.0 +1.1.0 diff --git a/plugins/wasm-go/extensions/custom-response/docker-compose.yaml b/plugins/wasm-go/extensions/custom-response/docker-compose.yaml new file mode 100644 index 000000000..9ae820e5c --- /dev/null +++ b/plugins/wasm-go/extensions/custom-response/docker-compose.yaml @@ -0,0 +1,25 @@ +services: + envoy: + image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/gateway:v2.0.7 + entrypoint: /usr/local/bin/envoy + # 注意这里对 Wasm 开启了 debug 级别日志,在生产环境部署时请使用默认的 info 级别 + # 如果需要将 Envoy 的日志级别调整为 debug,将 --log-level 参数设置为 debug + command: -c /etc/envoy/envoy.yaml --log-level info --component-log-level wasm:debug + depends_on: + - echo-server + networks: + - wasmtest + ports: + - "10000:10000" + - "9901:9901" + volumes: + - ./envoy.yaml:/etc/envoy/envoy.yaml + - ./plugin.wasm:/etc/envoy/plugin.wasm + echo-server: + image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/echo-server:1.3.0 + networks: + - wasmtest + ports: + - "3000:3000" +networks: + wasmtest: {} \ No newline at end of file diff --git a/plugins/wasm-go/extensions/custom-response/envoy.yaml b/plugins/wasm-go/extensions/custom-response/envoy.yaml index ff27982d4..c08114819 100644 --- a/plugins/wasm-go/extensions/custom-response/envoy.yaml +++ b/plugins/wasm-go/extensions/custom-response/envoy.yaml @@ -1,9 +1,3 @@ -admin: - address: - socket_address: - protocol: TCP - address: 0.0.0.0 - port_value: 9901 static_resources: listeners: - name: listener_0 @@ -27,9 +21,9 @@ static_resources: domains: ["*"] routes: - match: - prefix: "/" + prefix: "/echo" route: - cluster: httpbin + cluster: echo-server http_filters: - name: wasmdemo typed_config: @@ -42,30 +36,100 @@ static_resources: runtime: envoy.wasm.runtime.v8 code: local: - filename: /etc/envoy/main.wasm + filename: /etc/envoy/plugin.wasm configuration: "@type": "type.googleapis.com/google.protobuf.StringValue" - value: | +# value: |- +# { +# "rules": [ +# { +# "headers": [ +# "key1=value1", +# "key2=value2" +# ], +# "status_code": 200, +# "enable_on_status": [ +# 200, +# 201 +# ], +# "body": "{\"hello\":\"world 200\"}" +# }, +# { +# "headers": [ +# "key1=value1", +# "key2=value2" +# ], +# "status_code": 200, +# "enable_on_status": [ +# 404 +# ], +# "body": "{\"hello\":\"world 404\"}" +# } +# ] +# } + value: |- { - "headers": ["key1=value1", "key2=value2"], - "status_code": 200, - "enable_on_status": [200, 201], - "body": "{\"hello\":\"world\"}" + "rules": [ + { + "headers": [ + "key1=value1", + "key2=value2" + ], + "status_code": 200, + "enable_on_status": [ + 200 + ], + "body": "{\"hello\":\"world 200\"}" + }, + { + "headers": [ + "key1=value1", + "key2=value2" + ], + "status_code": 200, + "enable_on_status": [ + "40x" + ], + "body": "{\"hello\":\"world 40x\"}" + } + ] } +# value: |- +# { +# "headers": [ +# "key1=value1", +# "key2=value2" +# ], +# "status_code": 200, +# "body": "{\"hello\":\"world 200\"}", +# "enable_on_status": [ +# 200 +# ] +# } +# value: |- +# { +# "headers": [ +# "key1=value1", +# "key2=value2" +# ], +# "status_code": 200, +# "body": "{\"hello\":\"world 200\"}" +# } - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router clusters: - - name: httpbin + - name: echo-server connect_timeout: 30s type: LOGICAL_DNS - # Comment out the following line to test on v6 networks dns_lookup_family: V4_ONLY lb_policy: ROUND_ROBIN load_assignment: - cluster_name: httpbin + cluster_name: echo-server endpoints: - lb_endpoints: - endpoint: address: socket_address: - address: httpbin - port_value: 80 + address: echo-server + port_value: 3000 \ No newline at end of file diff --git a/plugins/wasm-go/extensions/custom-response/main.go b/plugins/wasm-go/extensions/custom-response/main.go index 1af26d25c..c8388d3d6 100644 --- a/plugins/wasm-go/extensions/custom-response/main.go +++ b/plugins/wasm-go/extensions/custom-response/main.go @@ -16,10 +16,12 @@ package main import ( "encoding/json" + "errors" "fmt" "strconv" "strings" + "github.com/alibaba/higress/plugins/wasm-go/pkg/log" "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper" "github.com/higress-group/proxy-wasm-go-sdk/proxywasm" "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types" @@ -29,78 +31,160 @@ import ( func main() { wrapper.SetCtx( "custom-response", - wrapper.ParseConfigBy(parseConfig), - wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders), - wrapper.ProcessResponseHeadersBy(onHttpResponseHeaders), + wrapper.ParseConfig(parseConfig), + wrapper.ProcessRequestHeaders(onHttpRequestHeaders), + wrapper.ProcessResponseHeaders(onHttpResponseHeaders), ) } type CustomResponseConfig struct { + rules []CustomResponseRule + defaultRule *CustomResponseRule + enableOnStatusRuleMap map[string]*CustomResponseRule +} + +type CustomResponseRule struct { statusCode uint32 headers [][2]string body string - enableOnStatus []uint32 + enableOnStatus []string contentType string } -func parseConfig(gjson gjson.Result, config *CustomResponseConfig, log wrapper.Log) error { +func parseConfig(gjson gjson.Result, config *CustomResponseConfig) error { + rules := gjson.Get("rules") + rulesVersion := rules.Exists() && rules.IsArray() + if rulesVersion { + for _, cf := range gjson.Get("rules").Array() { + item := new(CustomResponseRule) + if err := parseRuleItem(cf, item); err != nil { + return err + } + // the first rule item which enableOnStatus is empty to be set default + if len(item.enableOnStatus) == 0 && config.defaultRule == nil { + config.defaultRule = item + } + config.rules = append(config.rules, *item) + } + } else { + rule := new(CustomResponseRule) + if err := parseRuleItem(gjson, rule); err != nil { + return err + } + config.rules = append(config.rules, *rule) + config.defaultRule = rule + } + config.enableOnStatusRuleMap = make(map[string]*CustomResponseRule) + for i, configItem := range config.rules { + for _, statusCode := range configItem.enableOnStatus { + if v, ok := config.enableOnStatusRuleMap[statusCode]; ok { + log.Errorf("enable_on_status code used in %v, want to add %v", v, statusCode) + return errors.New("enableOnStatus can only use once") + } + config.enableOnStatusRuleMap[statusCode] = &config.rules[i] + } + } + if rulesVersion && config.defaultRule == nil && len(config.enableOnStatusRuleMap) == 0 { + return errors.New("no valid config is found") + } + return nil +} + +func parseRuleItem(gjson gjson.Result, rule *CustomResponseRule) error { headersArray := gjson.Get("headers").Array() - config.headers = make([][2]string, 0, len(headersArray)) + rule.headers = make([][2]string, 0, len(headersArray)) for _, v := range headersArray { kv := strings.SplitN(v.String(), "=", 2) if len(kv) == 2 { key := strings.TrimSpace(kv[0]) value := strings.TrimSpace(kv[1]) if strings.EqualFold(key, "content-type") { - config.contentType = value + rule.contentType = value } else if strings.EqualFold(key, "content-length") { continue } else { - config.headers = append(config.headers, [2]string{key, value}) + rule.headers = append(rule.headers, [2]string{key, value}) } } else { return fmt.Errorf("invalid header pair format: %s", v.String()) } } - config.body = gjson.Get("body").String() - if config.contentType == "" && config.body != "" { - if json.Valid([]byte(config.body)) { - config.contentType = "application/json; charset=utf-8" + rule.body = gjson.Get("body").String() + if rule.contentType == "" && rule.body != "" { + if json.Valid([]byte(rule.body)) { + rule.contentType = "application/json; charset=utf-8" } else { - config.contentType = "text/plain; charset=utf-8" + rule.contentType = "text/plain; charset=utf-8" } } - config.headers = append(config.headers, [2]string{"content-type", config.contentType}) + rule.headers = append(rule.headers, [2]string{"content-type", rule.contentType}) - config.statusCode = 200 + rule.statusCode = 200 if gjson.Get("status_code").Exists() { statusCode := gjson.Get("status_code") parsedStatusCode, err := strconv.Atoi(statusCode.String()) if err != nil { return fmt.Errorf("invalid status code value: %s", statusCode.String()) } - config.statusCode = uint32(parsedStatusCode) + rule.statusCode = uint32(parsedStatusCode) } enableOnStatusArray := gjson.Get("enable_on_status").Array() - config.enableOnStatus = make([]uint32, 0, len(enableOnStatusArray)) + rule.enableOnStatus = make([]string, 0, len(enableOnStatusArray)) for _, v := range enableOnStatusArray { - parsedEnableOnStatus, err := strconv.Atoi(v.String()) + s := v.String() + _, err := strconv.Atoi(s) if err != nil { - return fmt.Errorf("invalid enable_on_status value: %s", v.String()) + matchString, err := isValidFuzzyMatchString(s) + if err != nil { + return err + } + rule.enableOnStatus = append(rule.enableOnStatus, matchString) + continue } - config.enableOnStatus = append(config.enableOnStatus, uint32(parsedEnableOnStatus)) + rule.enableOnStatus = append(rule.enableOnStatus, s) } - return nil } -func onHttpRequestHeaders(ctx wrapper.HttpContext, config CustomResponseConfig, log wrapper.Log) types.Action { - if len(config.enableOnStatus) != 0 { +func isValidFuzzyMatchString(s string) (string, error) { + const requiredLength = 3 + if len(s) != requiredLength { + return "", fmt.Errorf("invalid enable_on_status %q: length must be %d", s, requiredLength) + } + + lower := strings.ToLower(s) + hasX := false + hasDigit := false + + for _, c := range lower { + switch { + case c == 'x': + hasX = true + case c >= '0' && c <= '9': + hasDigit = true + default: + return "", fmt.Errorf("invalid enable_on_status %q: must contain only digits and x/X", s) + } + } + + if !hasX { + return "", fmt.Errorf("invalid enable_on_status %q: fuzzy match must contain x/X (use enable_on_status for exact statusCode matching)", s) + } + if !hasDigit { + return "", fmt.Errorf("invalid enable_on_status %q: must contain at least one digit", s) + } + + return lower, nil +} + +func onHttpRequestHeaders(_ wrapper.HttpContext, config CustomResponseConfig) types.Action { + if len(config.enableOnStatusRuleMap) != 0 { return types.ActionContinue } - err := proxywasm.SendHttpResponseWithDetail(config.statusCode, "custom-response", config.headers, []byte(config.body), -1) + log.Infof("use default rule %+v", config.defaultRule) + err := proxywasm.SendHttpResponseWithDetail(config.defaultRule.statusCode, "custom-response", config.defaultRule.headers, []byte(config.defaultRule.body), -1) if err != nil { log.Errorf("send http response failed: %v", err) } @@ -108,28 +192,62 @@ func onHttpRequestHeaders(ctx wrapper.HttpContext, config CustomResponseConfig, return types.ActionPause } -func onHttpResponseHeaders(ctx wrapper.HttpContext, config CustomResponseConfig, log wrapper.Log) types.Action { - // enableOnStatus is not empty, compare the status code. +func onHttpResponseHeaders(_ wrapper.HttpContext, config CustomResponseConfig) types.Action { + // enableOnStatusRuleMap is not empty, compare the status code. // if match the status code, mock the response. statusCodeStr, err := proxywasm.GetHttpResponseHeader(":status") if err != nil { log.Errorf("get http response status code failed: %v", err) return types.ActionContinue } - statusCode, err := strconv.ParseUint(statusCodeStr, 10, 32) - if err != nil { - log.Errorf("parse http response status code failed: %v", err) + if rule, ok := config.enableOnStatusRuleMap[statusCodeStr]; ok { + err = proxywasm.SendHttpResponseWithDetail(rule.statusCode, "custom-response", rule.headers, []byte(rule.body), -1) + if err != nil { + log.Errorf("send http response failed: %v", err) + } return types.ActionContinue } - for _, v := range config.enableOnStatus { - if uint32(statusCode) == v { - err = proxywasm.SendHttpResponseWithDetail(config.statusCode, "custom-response", config.headers, []byte(config.body), -1) - if err != nil { - log.Errorf("send http response failed: %v", err) - } + if rule, match := fuzzyMatchCode(config.enableOnStatusRuleMap, statusCodeStr); match { + err = proxywasm.SendHttpResponseWithDetail(rule.statusCode, "custom-response", rule.headers, []byte(rule.body), -1) + if err != nil { + log.Errorf("send http response failed: %v", err) } + return types.ActionContinue } - return types.ActionContinue } + +func fuzzyMatchCode(statusRuleMap map[string]*CustomResponseRule, statusCode string) (*CustomResponseRule, bool) { + if len(statusRuleMap) == 0 || statusCode == "" { + return nil, false + } + codeLen := len(statusCode) + for pattern, rule := range statusRuleMap { + // 规则1:模式长度必须与状态码一致 + if len(pattern) != codeLen { + continue + } + // 纯数字的enableOnStatus已经判断过,跳过 + if !strings.Contains(pattern, "x") { + continue + } + // 规则2:所有数字位必须精确匹配 + match := true + for i, c := range pattern { + // 如果是数字位需要校验 + if c >= '0' && c <= '9' { + // 边界检查防止panic + if i >= codeLen || statusCode[i] != byte(c) { + match = false + break + } + } + // 非数字位(如x)自动匹配 + } + if match { + return rule, true + } + } + return nil, false +} diff --git a/plugins/wasm-go/extensions/custom-response/main_test.go b/plugins/wasm-go/extensions/custom-response/main_test.go new file mode 100644 index 000000000..e68ef6b68 --- /dev/null +++ b/plugins/wasm-go/extensions/custom-response/main_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "testing" +) + +func Test_prefixMatchCode(t *testing.T) { + rules := map[string]*CustomResponseRule{ + "x01": {}, + "2x3": {}, + "45x": {}, + "6xx": {}, + "x7x": {}, + "xx8": {}, + } + + tests := []struct { + code string + expectHit bool + }{ + {"101", true}, // 匹配x01 + {"201", true}, // 匹配x01 + {"111", false}, // 不匹配 + {"203", true}, // 匹配2x3 + {"213", true}, // 匹配2x3 + {"450", true}, // 匹配45x + {"451", true}, // 匹配45x + {"600", true}, // 匹配6xx + {"611", true}, // 匹配6xx + {"612", true}, // 匹配6xx + {"171", true}, // 匹配x7x + {"161", false}, // 不匹配 + {"228", true}, // 匹配xx8 + {"229", false}, // 不匹配 + {"123", false}, // 不匹配 + } + + for _, tt := range tests { + _, found := fuzzyMatchCode(rules, tt.code) + if found != tt.expectHit { + t.Errorf("code:%s expect:%v got:%v", tt.code, tt.expectHit, found) + } + } +} + +func TestIsValidPrefixString(t *testing.T) { + tests := []struct { + input string + expected string + hasError bool + }{ + {"x1x", "x1x", false}, + {"X2X", "x2x", false}, + {"xx1", "xx1", false}, + {"x12", "x12", false}, + {"1x2", "1x2", false}, + {"12x", "12x", false}, + {"123", "", true}, // 缺少x + {"xxx", "", true}, // 缺少数字 + {"xYx", "", true}, // 非法字符 + {"x1", "", true}, // 长度不足 + {"x123", "", true}, // 长度超限 + } + + for _, tt := range tests { + result, err := isValidFuzzyMatchString(tt.input) + if tt.hasError { + if err == nil { + t.Errorf("%q: expected error but got none", tt.input) + } + } else { + if err != nil { + t.Errorf("%q: unexpected error: %v", tt.input, err) + } + if result != tt.expected { + t.Errorf("%q: expected %q, got %q", tt.input, tt.expected, result) + } + } + } +}