From 95ff52cde9ee8bde01637c269c313b1a044bce74 Mon Sep 17 00:00:00 2001 From: Kent Dong Date: Fri, 26 Dec 2025 17:29:55 +0800 Subject: [PATCH] feat: Add traffic-editor plugin (#2825) --- .../extensions/traffic-editor/README.md | 206 +++++++ .../extensions/traffic-editor/README_EN.md | 212 +++++++ .../wasm-go/extensions/traffic-editor/VERSION | 1 + .../extensions/traffic-editor/config.go | 37 ++ .../traffic-editor/docker-compose.yaml | 26 + .../wasm-go/extensions/traffic-editor/go.mod | 24 + .../wasm-go/extensions/traffic-editor/go.sum | 31 ++ .../wasm-go/extensions/traffic-editor/http.go | 22 + .../wasm-go/extensions/traffic-editor/main.go | 177 ++++++ .../extensions/traffic-editor/main_test.go | 306 +++++++++++ .../extensions/traffic-editor/pkg/command.go | 515 ++++++++++++++++++ .../traffic-editor/pkg/command_test.go | 309 +++++++++++ .../traffic-editor/pkg/condition.go | 325 +++++++++++ .../traffic-editor/pkg/condition_test.go | 217 ++++++++ .../extensions/traffic-editor/pkg/context.go | 310 +++++++++++ .../traffic-editor/pkg/context_test.go | 218 ++++++++ .../traffic-editor/pkg/mock_test.go | 26 + .../extensions/traffic-editor/pkg/ref.go | 64 +++ 18 files changed, 3026 insertions(+) create mode 100644 plugins/wasm-go/extensions/traffic-editor/README.md create mode 100644 plugins/wasm-go/extensions/traffic-editor/README_EN.md create mode 100644 plugins/wasm-go/extensions/traffic-editor/VERSION create mode 100644 plugins/wasm-go/extensions/traffic-editor/config.go create mode 100644 plugins/wasm-go/extensions/traffic-editor/docker-compose.yaml create mode 100644 plugins/wasm-go/extensions/traffic-editor/go.mod create mode 100644 plugins/wasm-go/extensions/traffic-editor/go.sum create mode 100644 plugins/wasm-go/extensions/traffic-editor/http.go create mode 100644 plugins/wasm-go/extensions/traffic-editor/main.go create mode 100644 plugins/wasm-go/extensions/traffic-editor/main_test.go create mode 100644 plugins/wasm-go/extensions/traffic-editor/pkg/command.go create mode 100644 plugins/wasm-go/extensions/traffic-editor/pkg/command_test.go create mode 100644 plugins/wasm-go/extensions/traffic-editor/pkg/condition.go create mode 100644 plugins/wasm-go/extensions/traffic-editor/pkg/condition_test.go create mode 100644 plugins/wasm-go/extensions/traffic-editor/pkg/context.go create mode 100644 plugins/wasm-go/extensions/traffic-editor/pkg/context_test.go create mode 100644 plugins/wasm-go/extensions/traffic-editor/pkg/mock_test.go create mode 100644 plugins/wasm-go/extensions/traffic-editor/pkg/ref.go diff --git a/plugins/wasm-go/extensions/traffic-editor/README.md b/plugins/wasm-go/extensions/traffic-editor/README.md new file mode 100644 index 000000000..3bf77303f --- /dev/null +++ b/plugins/wasm-go/extensions/traffic-editor/README.md @@ -0,0 +1,206 @@ +--- +title: 请求响应编辑 +keywords: [ higress,request,response,edit ] +description: 请求响应编辑插件使用说明 +--- + +## 功能说明 + +`traffic-editor` 插件可以对请求/响应头进行修改,支持的修改操作类型包括删除、重命名、更新、添加、追加、映射、去重。 + +## 运行属性 + +插件执行阶段:`默认阶段` +插件执行优先级:`100` + +## 配置字段 + +| 字段名 | 类型 | 必填 | 说明 | +|--------------------|-----------------------------------------|----|---------------------| +| defaultConfig | object (CommandSet) | 否 | 默认命令集配置,无条件执行的编辑操作 | +| conditionalConfigs | array of object (ConditionalCommandSet) | 否 | 条件命令集配置,按条件执行不同编辑操作 | + +### CommandSet 结构 + +| 字段名 | 类型 | 必填 | 默认值 | 说明 | +|----------------|---------------------------|----|-------|------------| +| disableReroute | bool | 否 | false | 是否禁用自动路由重选 | +| commands | array of object (Command) | 是 | - | 编辑命令列表 | + +### ConditionalCommandSet 结构 + +| 字段名 | 类型 | 必填 | 说明 | +|------------|---------------------------|----|---------------------| +| conditions | array | 是 | 条件列表,见下表 | +| commands | array of object (Command) | 是 | 命令列表,结构同 CommandSet | + +#### Command 结构 + +| 字段名 | 类型 | 必填 | 说明 | +|------|--------|----|-------------------| +| type | string | 是 | 命令类型。其他配置字段由类型决定。 | + +##### set 命令 + +功能为将某个字段设置为指定值。`type` 字段值为 `set`。 + +其它字段如下: + +| 字段名 | 类型 | 必填 | 说明 | +|--------|--------------|----|--------| +| target | object (Ref) | 是 | 目标字段信息 | +| value | string | 是 | 要设置的值 | + +##### concat 命令 + +功能为将多个值拼接后赋值给目标字段。`type` 字段值为 `concat`。 + +其它字段如下: + +| 字段名 | 类型 | 必填 | 说明 | +|--------|-----------------------|----|--------------------------| +| target | object (Ref) | 是 | 目标字段信息 | +| values | array of (string/Ref) | 是 | 要拼接的值列表,可以是字符串或字段引用(Ref) | + +##### copy 命令 + +功能为将源字段的值复制到目标字段。`type` 字段值为 `copy`。 + +其它字段如下: + +| 字段名 | 类型 | 必填 | 说明 | +|--------|--------------|----|--------| +| source | object (Ref) | 是 | 源字段信息 | +| target | object (Ref) | 是 | 目标字段信息 | + +##### delete 命令 + +功能为删除指定字段。`type` 字段值为 `delete`。 + +其它字段如下: + +| 字段名 | 类型 | 必填 | 说明 | +|--------|--------------|----|----------| +| target | object (Ref) | 是 | 要删除的字段信息 | + +##### rename 命令 + +功能为将字段重命名。`type` 字段值为 `rename`。 + +其它字段如下: + +| 字段名 | 类型 | 必填 | 说明 | +|--------|--------------|----|-------| +| source | object (Ref) | 是 | 原字段信息 | +| target | object (Ref) | 是 | 新字段信息 | + +#### Condition 结构 + +| 字段名 | 类型 | 必填 | 说明 | +|------|--------|----|-------------------| +| type | string | 是 | 条件类型。其他配置字段由类型决定。 | + +##### equals 条件 + +判断某字段值是否等于指定值。`type` 字段值为 `equals`。 + +| 字段名 | 类型 | 必填 | 说明 | +|--------|--------------|----|---------| +| value1 | object (Ref) | 是 | 参与比较的字段 | +| value2 | string | 是 | 目标值 | + +##### prefix 条件 + +判断某字段值是否以指定前缀开头。`type` 字段值为 `prefix`。 + +| 字段名 | 类型 | 必填 | 说明 | +|--------|--------------|----|---------| +| value | object (Ref) | 是 | 参与比较的字段 | +| prefix | string | 是 | 前缀字符串 | + +##### suffix 条件 + +判断某字段值是否以指定后缀结尾。`type` 字段值为 `suffix`。 + +| 字段名 | 类型 | 必填 | 说明 | +|--------|--------------|----|---------| +| value | object (Ref) | 是 | 参与比较的字段 | +| suffix | string | 是 | 后缀字符串 | + +##### contains 条件 + +判断某字段值是否包含指定子串。`type` 字段值为 `contains`。 + +| 字段名 | 类型 | 必填 | 说明 | +|--------|--------------|----|---------| +| value | object (Ref) | 是 | 参与比较的字段 | +| substr | string | 是 | 子串 | + +##### regex 条件 + +判断某字段值是否匹配指定正则表达式。`type` 字段值为 `regex`。 + +| 字段名 | 类型 | 必填 | 说明 | +|---------|--------------|----|---------| +| value | object (Ref) | 是 | 参与比较的字段 | +| pattern | string | 是 | 正则表达式 | + +#### Ref 结构 + +用于标识一个请求或响应中的字段。 + +| 字段名 | 类型 | 必填 | 说明 | +|------|--------|----|--------------------------------------------------------------| +| type | string | 是 | 字段类型。可选值有:`request_header`、`request_query`、`response_header` | +| name | string | 是 | 字段名称 | + +### 示例配置 + +```json +{ + "defaultConfig": { + "disableReroute": false, + "commands": [ + { + "type": "set", + "target": { + "type": "request_header", + "name": "x-user" + }, + "value": "admin" + }, + { + "type": "delete", + "target": { + "type": "request_header", + "name": "x-dummy" + } + } + ] + }, + "conditionalConfigs": [ + { + "conditions": [ + { + "type": "equals", + "value1": { + "type": "request_query", + "name": "id" + }, + "value2": "1" + } + ], + "commands": [ + { + "type": "set", + "target": { + "type": "response_header", + "name": "x-id" + }, + "value": "1" + } + ] + } + ] +} +``` diff --git a/plugins/wasm-go/extensions/traffic-editor/README_EN.md b/plugins/wasm-go/extensions/traffic-editor/README_EN.md new file mode 100644 index 000000000..9b8196b4b --- /dev/null +++ b/plugins/wasm-go/extensions/traffic-editor/README_EN.md @@ -0,0 +1,212 @@ +--- +title: Request/Response Editor +keywords: [higress,request,response,edit] +description: Usage guide for the request/response editor plugin +--- + +## Features + +The `traffic-editor` plugin allows you to modify request/response headers. Supported operations include delete, rename, update, add, append, map, and deduplicate. + +## Runtime Properties + +Plugin execution phase: `UNSPECIFIED` +Plugin execution priority: `100` + +## Configuration Fields + +| Field Name | Type | Required | Description | +|--------------------|-------------------------------------------|----------|-----------------------------------| +| defaultConfig | object (CommandSet) | No | Default command set, executed unconditionally | +| conditionalConfigs | array of object (ConditionalCommandSet) | No | Conditional command sets, executed based on conditions | + +### CommandSet Structure + +| Field Name | Type | Required | Default | Description | +|----------------|----------------------------|----------|---------|----------------------------| +| disableReroute | bool | No | false | Whether to disable automatic route selection | +| commands | array of object (Command) | Yes | - | List of edit commands | + +### ConditionalCommandSet Structure + +| Field Name | Type | Required | Description | +|-------------|----------------------------|----------|-----------------------------------| +| conditions | array | Yes | List of conditions, see below | +| commands | array of object (Command) | Yes | List of commands, same as CommandSet | + +#### Command Structure + +| Field Name | Type | Required | Description | +|------------|--------|----------|------------------------------| +| type | string | Yes | Command type, other fields depend on type | + +##### set Command + +Sets a field to a specified value. `type` field value is `set`. + +Other fields: + +| Field Name | Type | Required | Description | +|------------|---------------|----------|------------------| +| target | object (Ref) | Yes | Target field info| +| value | string | Yes | Value to set | + +##### concat Command + +Concatenates multiple values and assigns to the target field. `type` field value is `concat`. + +Other fields: + +| Field Name | Type | Required | Description | +|------------|-----------------------|----------|----------------------------------------------| +| target | object (Ref) | Yes | Target field info | +| values | array of (string/Ref) | Yes | Values to concatenate, can be string or Ref | + +##### copy Command + +Copies the value from the source field to the target field. `type` field value is `copy`. + +Other fields: + +| Field Name | Type | Required | Description | +|------------|---------------|----------|------------------| +| source | object (Ref) | Yes | Source field info| +| target | object (Ref) | Yes | Target field info| + +##### delete Command + +Deletes the specified field. `type` field value is `delete`. + +Other fields: + +| Field Name | Type | Required | Description | +|------------|---------------|----------|------------------| +| target | object (Ref) | Yes | Field to delete | + +##### rename Command + +Renames a field. `type` field value is `rename`. + +Other fields: + +| Field Name | Type | Required | Description | +|------------|---------------|----------|------------------| +| source | object (Ref) | Yes | Original field info| +| target | object (Ref) | Yes | New field info | + +#### Condition Structure + +| Field Name | Type | Required | Description | +|------------|--------|----------|------------------------------| +| type | string | Yes | Condition type, other fields depend on type | + +##### equals Condition + +Checks if a field value equals the specified value. `type` field value is `equals`. + +| Field Name | Type | Required | Description | +|------------|---------------|----------|------------------| +| value1 | object (Ref) | Yes | Field to compare | +| value2 | string | Yes | Target value | + +##### prefix Condition + +Checks if a field value starts with the specified prefix. `type` field value is `prefix`. + +| Field Name | Type | Required | Description | +|------------|---------------|----------|------------------| +| value | object (Ref) | Yes | Field to compare | +| prefix | string | Yes | Prefix string | + +##### suffix Condition + +Checks if a field value ends with the specified suffix. `type` field value is `suffix`. + +| Field Name | Type | Required | Description | +|------------|---------------|----------|------------------| +| value | object (Ref) | Yes | Field to compare | +| suffix | string | Yes | Suffix string | + +##### contains Condition + +Checks if a field value contains the specified substring. `type` field value is `contains`. + +| Field Name | Type | Required | Description | +|------------|---------------|----------|------------------| +| value | object (Ref) | Yes | Field to compare | +| substr | string | Yes | Substring | + +##### regex Condition + +Checks if a field value matches the specified regular expression. `type` field value is `regex`. + +| Field Name | Type | Required | Description | +|------------|---------------|----------|------------------| +| value | object (Ref) | Yes | Field to compare | +| pattern | string | Yes | Regular expression| + +#### Ref Structure + +Used to identify a field in the request or response. + +| Field Name | Type | Required | Description | +|------------|--------|----------|------------------------------------------------------------------| +| type | string | Yes | Field type: `request_header`, `request_query`, `response_header` | +| name | string | Yes | Field name | + +### Example Configuration + +```json +{ + "defaultConfig": { + "disableReroute": false, + "commands": [ + { "type": "set", "target": { "type": "request_header", "name": "x-user" }, "value": "admin" }, + { "type": "delete", "target": { "type": "request_header", "name": "x-remove" } } + ] + }, + "conditionalConfigs": [ + { + "conditions": [ + { "type": "equals", "value1": { "type": "request_query", "name": "role" }, "value2": "admin" } + ], + "commands": [ + { "type": "set", "target": { "type": "response_header", "name": "x-status" }, "value": "is-admin" } + ] + }, + { + "conditions": [ + { "type": "prefix", "value": { "type": "request_header", "name": "x-path" }, "prefix": "/api/" } + ], + "commands": [ + { "type": "rename", "source": { "type": "request_header", "name": "x-old" }, "target": { "type": "request_header", "name": "x-new" } } + ] + }, + { + "conditions": [ + { "type": "suffix", "value": { "type": "request_header", "name": "x-path" }, "suffix": ".json" } + ], + "commands": [ + { "type": "copy", "source": { "type": "request_query", "name": "id" }, "target": { "type": "response_header", "name": "x-id" } } + ] + }, + { + "conditions": [ + { "type": "contains", "value": { "type": "request_header", "name": "x-info" }, "substr": "test" } + ], + "commands": [ + { "type": "concat", "target": { "type": "response_header", "name": "x-token" }, "values": ["prefix-", { "type": "request_query", "name": "token" }] } + ] + }, + { + "conditions": [ + { "type": "regex", "value": { "type": "request_query", "name": "email" }, "pattern": "^.+@example\\.com$" } + ], + "commands": [ + { "type": "delete", "target": { "type": "response_header", "name": "x-temp" } } + ] + } + ] +} +``` + diff --git a/plugins/wasm-go/extensions/traffic-editor/VERSION b/plugins/wasm-go/extensions/traffic-editor/VERSION new file mode 100644 index 000000000..dadcca1e0 --- /dev/null +++ b/plugins/wasm-go/extensions/traffic-editor/VERSION @@ -0,0 +1 @@ +1.0.0-alpha diff --git a/plugins/wasm-go/extensions/traffic-editor/config.go b/plugins/wasm-go/extensions/traffic-editor/config.go new file mode 100644 index 000000000..77d1ec35b --- /dev/null +++ b/plugins/wasm-go/extensions/traffic-editor/config.go @@ -0,0 +1,37 @@ +package main + +import ( + "github.com/tidwall/gjson" + + "github.com/alibaba/higress/plugins/wasm-go/extensions/traffic-editor/pkg" +) + +type PluginConfig struct { + DefaultConfig *pkg.CommandSet `json:"defaultConfig,omitempty"` + ConditionalConfigs []*pkg.ConditionalCommandSet `json:"conditionalConfigs,omitempty"` +} + +func (c *PluginConfig) FromJson(json gjson.Result) error { + c.DefaultConfig = nil + defaultConfigJson := json.Get("defaultConfig") + if defaultConfigJson.Exists() && defaultConfigJson.IsObject() { + c.DefaultConfig = &pkg.CommandSet{} + if err := c.DefaultConfig.FromJson(defaultConfigJson); err != nil { + return err + } + } + + c.ConditionalConfigs = nil + conditionalConfigsJson := json.Get("conditionalConfigs") + if conditionalConfigsJson.Exists() && conditionalConfigsJson.IsArray() { + for _, item := range conditionalConfigsJson.Array() { + config := &pkg.ConditionalCommandSet{} + if err := config.FromJson(item); err != nil { + return err + } + c.ConditionalConfigs = append(c.ConditionalConfigs, config) + } + } + + return nil +} diff --git a/plugins/wasm-go/extensions/traffic-editor/docker-compose.yaml b/plugins/wasm-go/extensions/traffic-editor/docker-compose.yaml new file mode 100644 index 000000000..1add8c252 --- /dev/null +++ b/plugins/wasm-go/extensions/traffic-editor/docker-compose.yaml @@ -0,0 +1,26 @@ +version: '3.7' +services: + envoy: + image: higress-registry.cn-hangzhou.cr.aliyuncs.com/higress/gateway:v2.1.6 + entrypoint: /usr/local/bin/envoy + # 注意这里对wasm开启了debug级别日志,正式部署时则默认info级别 + command: -c /etc/envoy/envoy.yaml --component-log-level wasm:debug + #depends_on: + # - httpbin + networks: + - wasmtest + ports: + - "10000:10000" + volumes: + - ./envoy.yaml:/etc/envoy/envoy.yaml + - ./plugin.wasm:/etc/envoy/main.wasm + + httpbin: + image: kong/httpbin:latest + networks: + - wasmtest + ports: + - "12345:80" + +networks: + wasmtest: {} \ No newline at end of file diff --git a/plugins/wasm-go/extensions/traffic-editor/go.mod b/plugins/wasm-go/extensions/traffic-editor/go.mod new file mode 100644 index 000000000..8cf452fb0 --- /dev/null +++ b/plugins/wasm-go/extensions/traffic-editor/go.mod @@ -0,0 +1,24 @@ +module github.com/alibaba/higress/plugins/wasm-go/extensions/traffic-editor + +go 1.24.1 + +toolchain go1.24.4 + +require ( + github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250822030947-8345453fddd0 + github.com/higress-group/wasm-go v1.0.2 + github.com/stretchr/testify v1.11.1 + github.com/tidwall/gjson v1.18.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/tetratelabs/wazero v1.7.2 // indirect + github.com/tidwall/match v1.2.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/resp v0.1.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/plugins/wasm-go/extensions/traffic-editor/go.sum b/plugins/wasm-go/extensions/traffic-editor/go.sum new file mode 100644 index 000000000..54f9f2faf --- /dev/null +++ b/plugins/wasm-go/extensions/traffic-editor/go.sum @@ -0,0 +1,31 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250822030947-8345453fddd0 h1:YGdj8KBzVjabU3STUfwMZghB+VlX6YLfJtLbrsWaOD0= +github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250822030947-8345453fddd0/go.mod h1:tRI2LfMudSkKHhyv1uex3BWzcice2s/l8Ah8axporfA= +github.com/higress-group/wasm-go v1.0.2 h1:8fQqR+wHts8tP+v7GYxmsCNyW5nAjn9wPYV0/+Seqzg= +github.com/higress-group/wasm-go v1.0.2/go.mod h1:882/J8ccU4i+LeyFKmeicbHWAYLj8y7YZr60zk0OOCI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tetratelabs/wazero v1.7.2 h1:1+z5nXJNwMLPAWaTePFi49SSTL0IMx/i3Fg8Yc25GDc= +github.com/tetratelabs/wazero v1.7.2/go.mod h1:ytl6Zuh20R/eROuyDaGPkp82O9C/DJfXAwJfQ3X6/7Y= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/resp v0.1.1 h1:Ly20wkhqKTmDUPlyM1S7pWo5kk0tDu8OoC/vFArXmwE= +github.com/tidwall/resp v0.1.1/go.mod h1:3/FrruOBAxPTPtundW0VXgmsQ4ZBA0Aw714lVYgwFa0= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/wasm-go/extensions/traffic-editor/http.go b/plugins/wasm-go/extensions/traffic-editor/http.go new file mode 100644 index 000000000..77ce2b709 --- /dev/null +++ b/plugins/wasm-go/extensions/traffic-editor/http.go @@ -0,0 +1,22 @@ +package main + +import "strings" + +func headerSlice2Map(headerSlice [][2]string) map[string][]string { + headerMap := make(map[string][]string) + for _, header := range headerSlice { + k, v := strings.ToLower(header[0]), header[1] + headerMap[k] = append(headerMap[k], v) + } + return headerMap +} + +func headerMap2Slice(headerMap map[string][]string) [][2]string { + headerSlice := make([][2]string, 0, len(headerMap)) + for k, vs := range headerMap { + for _, v := range vs { + headerSlice = append(headerSlice, [2]string{k, v}) + } + } + return headerSlice +} diff --git a/plugins/wasm-go/extensions/traffic-editor/main.go b/plugins/wasm-go/extensions/traffic-editor/main.go new file mode 100644 index 000000000..049e5f804 --- /dev/null +++ b/plugins/wasm-go/extensions/traffic-editor/main.go @@ -0,0 +1,177 @@ +// Copyright (c) 2022 Alibaba Group Holding Ltd. +// +// Licensed 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. + +package main + +import ( + "fmt" + + "github.com/tidwall/gjson" + + "github.com/alibaba/higress/plugins/wasm-go/extensions/traffic-editor/pkg" + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm" + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types" + "github.com/higress-group/wasm-go/pkg/log" + "github.com/higress-group/wasm-go/pkg/wrapper" +) + +const ( + ctxKeyEditorContext = "editorContext" +) + +func main() {} + +func init() { + wrapper.SetCtx( + "traffic-editor", + wrapper.ParseConfig(parseConfig), + wrapper.ProcessRequestHeaders(onHttpRequestHeaders), + wrapper.ProcessResponseHeaders(onHttpResponseHeaders), + ) +} + +func parseConfig(json gjson.Result, config *PluginConfig) (err error) { + if err := config.FromJson(json); err != nil { + return fmt.Errorf("failed to parse plugin config: %v", err) + } + return nil +} + +func onHttpRequestHeaders(ctx wrapper.HttpContext, config PluginConfig) types.Action { + log.Debugf("onHttpRequestHeaders called with config") + + editorContext := pkg.NewEditorContext() + if headers, err := proxywasm.GetHttpRequestHeaders(); err == nil { + editorContext.SetRequestHeaders(headerSlice2Map(headers)) + } else { + log.Errorf("failed to get request headers: %v", err) + } + saveEditorContext(ctx, editorContext) + + effectiveCommandSet := findEffectiveCommandSet(editorContext, &config) + if effectiveCommandSet == nil { + log.Debugf("no effective command set found for request %s", ctx.Path()) + return types.ActionContinue + } + if len(effectiveCommandSet.Commands) == 0 { + log.Debugf("the effective command set found for request %s is empty", ctx.Path()) + return types.ActionContinue + } + + log.Debugf("an effective command set found for request %s with %d commands", ctx.Path(), len(effectiveCommandSet.Commands)) + editorContext.SetEffectiveCommandSet(effectiveCommandSet) + editorContext.SetCommandExecutors(effectiveCommandSet.CreatExecutors()) + + // Make sure the editor context is clean before executing any command. + editorContext.ResetDirtyFlags() + + if effectiveCommandSet.DisableReroute { + ctx.DisableReroute() + } + + executeCommands(editorContext, pkg.StageRequestHeaders) + + if err := saveRequestHeaderChanges(editorContext); err != nil { + log.Errorf("failed to save request header changes: %v", err) + } + + // Make sure the editor context is clean before continue. + editorContext.ResetDirtyFlags() + + return types.ActionContinue +} + +func onHttpResponseHeaders(ctx wrapper.HttpContext, config PluginConfig) types.Action { + log.Debugf("onHttpResponseHeaders called with config") + + editorContext := loadEditorContext(ctx) + if editorContext.GetEffectiveCommandSet() == nil { + log.Debugf("no effective command set found for request %s", ctx.Path()) + return types.ActionContinue + } + + if headers, err := proxywasm.GetHttpResponseHeaders(); err == nil { + editorContext.SetResponseHeaders(headerSlice2Map(headers)) + } else { + log.Errorf("failed to get response headers: %v", err) + } + + // Make sure the editor context is clean before executing any command. + editorContext.ResetDirtyFlags() + + executeCommands(editorContext, pkg.StageResponseHeaders) + if err := saveResponseHeaderChanges(editorContext); err != nil { + log.Errorf("failed to save response header changes: %v", err) + } + + // Make sure the editor context is clean before continue. + editorContext.ResetDirtyFlags() + + return types.ActionContinue +} + +func findEffectiveCommandSet(editorContext pkg.EditorContext, config *PluginConfig) *pkg.CommandSet { + if config == nil { + return nil + } + if len(config.ConditionalConfigs) != 0 { + for i, conditionalConfig := range config.ConditionalConfigs { + log.Debugf("Evaluating conditional config %d: %+v", i, conditionalConfig) + if conditionalConfig.Matches(editorContext) { + log.Debugf("Use the conditional command set %d", i) + return &conditionalConfig.CommandSet + } + } + } + log.Debugf("Use the default command set") + return config.DefaultConfig +} + +func executeCommands(editorContext pkg.EditorContext, stage pkg.Stage) { + for _, executor := range editorContext.GetCommandExecutors() { + if err := executor.Run(editorContext, stage); err != nil { + log.Errorf("failed to execute a %s command in stage %s: %v", executor.GetCommand().GetType(), pkg.Stage2String[stage], err) + } + } +} + +func saveRequestHeaderChanges(editorContext pkg.EditorContext) error { + if !editorContext.IsRequestHeadersDirty() { + log.Debugf("no request header change to save") + return nil + } + + log.Debugf("saving request header changes: %v", editorContext.GetRequestHeaders()) + headerSlice := headerMap2Slice(editorContext.GetRequestHeaders()) + return proxywasm.ReplaceHttpRequestHeaders(headerSlice) +} + +func saveResponseHeaderChanges(editorContext pkg.EditorContext) error { + if !editorContext.IsResponseHeadersDirty() { + log.Debugf("no response header change to save") + return nil + } + log.Debugf("saving response header changes: %v", editorContext.GetResponseHeaders()) + headerSlice := headerMap2Slice(editorContext.GetResponseHeaders()) + return proxywasm.ReplaceHttpResponseHeaders(headerSlice) +} + +func loadEditorContext(ctx wrapper.HttpContext) pkg.EditorContext { + editorContext, _ := ctx.GetContext(ctxKeyEditorContext).(pkg.EditorContext) + return editorContext +} + +func saveEditorContext(ctx wrapper.HttpContext, editorContext pkg.EditorContext) { + ctx.SetContext(ctxKeyEditorContext, editorContext) +} diff --git a/plugins/wasm-go/extensions/traffic-editor/main_test.go b/plugins/wasm-go/extensions/traffic-editor/main_test.go new file mode 100644 index 000000000..9dcc51b6a --- /dev/null +++ b/plugins/wasm-go/extensions/traffic-editor/main_test.go @@ -0,0 +1,306 @@ +package main + +import ( + "strings" + "testing" + + "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types" + "github.com/higress-group/wasm-go/pkg/test" + "github.com/stretchr/testify/require" +) + +func TestSample(t *testing.T) { + test.RunTest(t, func(t *testing.T) { + t.Run("default config only", func(t *testing.T) { + host, status := test.NewTestHost([]byte(` +{ + "defaultConfig": { + "commands": [ + { + "type": "set", + "target": { + "type": "request_header", + "name": "x-test" + }, + "value": "123456" + } + ] + } +} + `)) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + + action := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":path", "/get"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + }) + require.Equal(t, types.ActionContinue, action) + + expectedNewHeaders := [][2]string{ + {":authority", "example.com"}, + {":path", "/get"}, + {":method", "POST"}, + {"x-test", "123456"}, + {"Content-Type", "application/json"}, + } + newHeaders := host.GetRequestHeaders() + require.True(t, compareHeaders(expectedNewHeaders, newHeaders), "expected headers: %v, got: %v", expectedNewHeaders, newHeaders) + }) + }) +} + +func TestSetMultipleRequestHeaders(t *testing.T) { + test.RunTest(t, func(t *testing.T) { + host, status := test.NewTestHost([]byte(`{ + "defaultConfig": { + "commands": [ + {"type": "set", "target": {"type": "request_header", "name": "x-a"}, "value": "aaa"}, + {"type": "set", "target": {"type": "request_header", "name": "x-b"}, "value": "bbb"} + ] + } + }`)) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + originalHeaders := [][2]string{ + {":authority", "example.com"}, + {":path", "/get"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + {"x-c", "ccc"}, + } + action := host.CallOnHttpRequestHeaders(originalHeaders) + require.Equal(t, types.ActionContinue, action) + expectedHeaders := [][2]string{ + {":authority", "example.com"}, + {":path", "/get"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + {"x-a", "aaa"}, + {"x-b", "bbb"}, + {"x-c", "ccc"}, + } + newHeaders := host.GetRequestHeaders() + require.True(t, compareHeaders(expectedHeaders, newHeaders)) + }) +} + +func TestConditionalConfigMatch(t *testing.T) { + test.RunTest(t, func(t *testing.T) { + host, status := test.NewTestHost([]byte(`{ + "defaultConfig": { + "commands": [ + {"type": "set", "target": {"type": "request_header", "name": "x-def"}, "value": "default"} + ] + }, + "conditionalConfigs": [ + { + "conditions": [ + {"type": "equals", "value1": {"type": "request_header", "name": "x-cond"}, "value2": "match"} + ], + "commands": [ + {"type": "set", "target": {"type": "request_header", "name": "x-special"}, "value": "special"} + ] + } + ] + }`)) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + originalHeaders := [][2]string{ + {":authority", "example.com"}, + {":path", "/data"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + {"x-cond", "match"}, + } + action := host.CallOnHttpRequestHeaders(originalHeaders) + require.Equal(t, types.ActionContinue, action) + expectedHeaders := [][2]string{ + {":authority", "example.com"}, + {":path", "/data"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + {"x-cond", "match"}, + {"x-special", "special"}, + } + newHeaders := host.GetRequestHeaders() + require.True(t, compareHeaders(expectedHeaders, newHeaders)) + }) +} + +func TestConditionalConfigNoMatch(t *testing.T) { + test.RunTest(t, func(t *testing.T) { + host, status := test.NewTestHost([]byte(`{ + "defaultConfig": { + "commands": [ + {"type": "set", "target": {"type": "request_header", "name": "x-def"}, "value": "default"} + ] + }, + "conditionalConfigs": [ + { + "conditions": [ + {"type": "equals", "value1": {"type": "request_header", "name": "x-cond"}, "value2": "match"} + ], + "commands": [ + {"type": "set", "target": {"type": "request_header", "name": "x-special"}, "value": "special"} + ] + } + ] + }`)) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + originalHeaders := [][2]string{ + {":authority", "example.com"}, + {":path", "/get"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + {"x-cond", "notmatch"}, + } + action := host.CallOnHttpRequestHeaders(originalHeaders) + require.Equal(t, types.ActionContinue, action) + expectedHeaders := [][2]string{ + {":authority", "example.com"}, + {":path", "/get"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + {"x-cond", "notmatch"}, + {"x-def", "default"}, + } + newHeaders := host.GetRequestHeaders() + require.True(t, compareHeaders(expectedHeaders, newHeaders)) + }) +} + +func TestSetResponseHeader(t *testing.T) { + test.RunTest(t, func(t *testing.T) { + host, status := test.NewTestHost([]byte(`{ + "defaultConfig": { + "commands": [ + {"type": "set", "target": {"type": "response_header", "name": "x-res"}, "value": "respval"} + ] + } + }`)) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + action := host.CallOnHttpResponseHeaders([][2]string{{"x-origin", "originval"}}) + require.Equal(t, types.ActionContinue, action) + newHeaders := host.GetResponseHeaders() + require.True(t, compareHeaders([][2]string{{"x-origin", "originval"}, {"x-res", "respval"}}, newHeaders)) + }) +} + +func TestPathQueryParseAndHeaderChange(t *testing.T) { + test.RunTest(t, func(t *testing.T) { + host, status := test.NewTestHost([]byte(`{ + "defaultConfig": { + "commands": [ + {"type": "set", "target": {"type": "request_query", "name": "foo"}, "value": "bar"} + ] + } + }`)) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + action := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + {":path", "/get?foo=old&baz=1"}, + }) + require.Equal(t, types.ActionContinue, action) + newHeaders := host.GetRequestHeaders() + found := false + for _, h := range newHeaders { + if h[0] == ":path" && strings.Contains(h[1], "foo=bar") { + found = true + } + } + require.True(t, found, "path header should be updated with foo=bar") + }) +} + +func TestConditionSetMultiStage(t *testing.T) { + test.RunTest(t, func(t *testing.T) { + host, status := test.NewTestHost([]byte(`{ + "conditionalConfigs": [ + { + "conditions": [ + {"type": "equals", "value1": {"type": "request_header", "name": "x-a"}, "value2": "aaa"} + ], + "commands": [ + {"type": "set", "target": {"type": "response_header", "name": "x-b"}, "value": "bbb"} + ] + } + ] + }`)) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + actionReq := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + {":path", "/get?foo=old&baz=1"}, + {"x-a", "aaa"}, + }) + require.Equal(t, types.ActionContinue, actionReq) + actionResp := host.CallOnHttpResponseHeaders([][2]string{{"content-type", "application/json"}}) + require.Equal(t, types.ActionContinue, actionResp) + newHeaders := host.GetResponseHeaders() + require.True(t, compareHeaders([][2]string{{"x-b", "bbb"}, {"content-type", "application/json"}}, newHeaders)) + }) +} + +func TestConditionSetMultiStage2(t *testing.T) { + test.RunTest(t, func(t *testing.T) { + host, status := test.NewTestHost([]byte(`{ + "conditionalConfigs": [ + { + "conditions": [ + {"type": "equals", "value1": {"type": "request_header", "name": "x-a"}, "value2": "aaa"} + ], + "commands": [ + {"type": "copy", "source": {"type": "request_header", "name": "x-b"}, "target": {"type": "response_header", "name": "x-c"}} + ] + } + ] + }`)) + defer host.Reset() + require.Equal(t, types.OnPluginStartStatusOK, status) + actionReq := host.CallOnHttpRequestHeaders([][2]string{ + {":authority", "example.com"}, + {":method", "POST"}, + {"Content-Type", "application/json"}, + {":path", "/get?foo=old&baz=1"}, + {"x-a", "aaa"}, + {"x-b", "bbb"}, + }) + require.Equal(t, types.ActionContinue, actionReq) + actionResp := host.CallOnHttpResponseHeaders([][2]string{{"content-type", "application/json"}}) + require.Equal(t, types.ActionContinue, actionResp) + newHeaders := host.GetResponseHeaders() + require.True(t, compareHeaders([][2]string{{"x-c", "bbb"}, {"content-type", "application/json"}}, newHeaders)) + }) +} +func compareHeaders(headers1, headers2 [][2]string) bool { + if len(headers1) != len(headers2) { + return false + } + m1 := make(map[string]string, len(headers1)) + m2 := make(map[string]string, len(headers2)) + for _, h := range headers1 { + m1[strings.ToLower(h[0])] = h[1] + } + for _, h := range headers2 { + m2[strings.ToLower(h[0])] = h[1] + } + if len(m1) != len(m2) { + return false + } + for k, v := range m1 { + if mv, ok := m2[k]; !ok || mv != v { + return false + } + } + return true +} diff --git a/plugins/wasm-go/extensions/traffic-editor/pkg/command.go b/plugins/wasm-go/extensions/traffic-editor/pkg/command.go new file mode 100644 index 000000000..ea9644cd6 --- /dev/null +++ b/plugins/wasm-go/extensions/traffic-editor/pkg/command.go @@ -0,0 +1,515 @@ +package pkg + +import ( + "errors" + "fmt" + + "github.com/higress-group/wasm-go/pkg/log" + "github.com/tidwall/gjson" +) + +const ( + commandTypeSet = "set" + commandTypeConcat = "concat" + commandTypeCopy = "copy" + commandTypeDelete = "delete" + commandTypeRename = "rename" +) + +var ( + commandFactories = map[string]func(gjson.Result) (Command, error){ + "set": newSetCommand, + "concat": newConcatCommand, + "copy": newCopyCommand, + "delete": newDeleteCommand, + "rename": newRenameCommand, + } +) + +type CommandSet struct { + DisableReroute bool `json:"disableReroute"` + Commands []Command `json:"commands,omitempty"` + RelatedStages map[Stage]bool `json:"-"` +} + +func (s *CommandSet) FromJson(json gjson.Result) error { + relatedStages := map[Stage]bool{} + if commandsJson := json.Get("commands"); commandsJson.Exists() && commandsJson.IsArray() { + for _, item := range commandsJson.Array() { + if command, err := NewCommand(item); err != nil { + return fmt.Errorf("failed to create command from json: %v\n %v", err, item) + } else { + s.Commands = append(s.Commands, command) + for _, ref := range command.GetRefs() { + relatedStages[ref.GetStage()] = true + } + } + } + } + s.RelatedStages = relatedStages + if disableReroute := json.Get("disableReroute"); disableReroute.Exists() { + s.DisableReroute = disableReroute.Bool() + } else { + s.DisableReroute = false + } + return nil +} + +func (s *CommandSet) CreatExecutors() []Executor { + executors := make([]Executor, 0, len(s.Commands)) + for _, command := range s.Commands { + executor := command.CreateExecutor() + executors = append(executors, executor) + } + return executors +} + +type ConditionalCommandSet struct { + ConditionSet + CommandSet +} + +func (s *ConditionalCommandSet) FromJson(json gjson.Result) error { + if err := s.ConditionSet.FromJson(json); err != nil { + return err + } + if err := s.CommandSet.FromJson(json); err != nil { + return err + } + return nil +} + +type Command interface { + GetType() string + GetRefs() []*Ref + CreateExecutor() Executor +} + +type Executor interface { + GetCommand() Command + Run(editorContext EditorContext, stage Stage) error +} + +func NewCommand(json gjson.Result) (Command, error) { + t := json.Get("type").String() + if t == "" { + return nil, errors.New("command type is required") + } + if constructor, ok := commandFactories[t]; ok && constructor != nil { + return constructor(json) + } else { + return nil, errors.New("unknown command type: " + t) + } +} + +type baseExecutor struct { + finished bool +} + +// setCommand +func newSetCommand(json gjson.Result) (Command, error) { + var targetRef *Ref + var err error + if t := json.Get("target"); !t.Exists() { + return nil, errors.New("setCommand: target field is required") + } else { + targetRef, err = NewRef(t) + if err != nil { + return nil, fmt.Errorf("setCommand: failed to create ref from target field: %v\n %v", err, t.Raw) + } + } + var value string + if v := json.Get("value"); !v.Exists() { + return nil, errors.New("setCommand: value field is required") + } else { + value = v.String() + if value == "" { + return nil, errors.New("setCommand: value cannot be empty") + } + } + return &setCommand{ + targetRef: targetRef, + value: value, + }, nil +} + +type setCommand struct { + targetRef *Ref + value string +} + +func (c *setCommand) GetType() string { + return commandTypeSet +} + +func (c *setCommand) GetRefs() []*Ref { + return []*Ref{c.targetRef} +} + +func (c *setCommand) CreateExecutor() Executor { + return &setExecutor{command: c} +} + +type setExecutor struct { + baseExecutor + command *setCommand +} + +func (e *setExecutor) GetCommand() Command { + return e.command +} + +func (e *setExecutor) Run(editorContext EditorContext, stage Stage) error { + if e.finished { + return nil + } + + command := e.command + log.Debugf("setCommand: checking stage %s for target %s", Stage2String[stage], command.targetRef) + if command.targetRef.GetStage() == stage { + log.Debugf("setCommand: set %s to %s", command.targetRef, command.value) + editorContext.SetRefValue(command.targetRef, command.value) + e.finished = true + } + + return nil +} + +// concatCommand +func newConcatCommand(json gjson.Result) (Command, error) { + var targetRef *Ref + var err error + if t := json.Get("target"); !t.Exists() { + return nil, errors.New("concatCommand: target field is required") + } else { + targetRef, err = NewRef(t) + if err != nil { + return nil, fmt.Errorf("concatCommand: failed to create ref from target field: %v\n %v", err, t.Raw) + } + } + + valuesJson := json.Get("values") + if !valuesJson.Exists() || !valuesJson.IsArray() { + return nil, errors.New("concatCommand: values field is required and must be an array") + } + + values := make([]interface{}, 0, len(valuesJson.Array())) + for _, item := range valuesJson.Array() { + var value interface{} + if item.IsObject() { + valueRef, err := NewRef(item) + if err != nil { + return nil, fmt.Errorf("concatCommand: failed to create ref from values field: %v\n %v", err, item.Raw) + } + if valueRef.GetStage() > targetRef.GetStage() { + return nil, fmt.Errorf("concatCommand: the processing stage of value [%s] cannot be after the stage of target [%s]", Stage2String[valueRef.GetStage()], Stage2String[targetRef.GetStage()]) + } + value = valueRef + } else { + value = item.String() + } + values = append(values, value) + } + + return &concatCommand{ + targetRef: targetRef, + values: values, + }, nil +} + +type concatCommand struct { + targetRef *Ref + values []interface{} +} + +func (c *concatCommand) GetType() string { + return commandTypeConcat +} + +func (c *concatCommand) GetRefs() []*Ref { + refs := []*Ref{c.targetRef} + if c.values != nil && len(c.values) != 0 { + for _, value := range c.values { + if ref, ok := value.(*Ref); ok { + refs = append(refs, ref) + } + } + } + return refs +} + +func (c *concatCommand) CreateExecutor() Executor { + return &concatExecutor{command: c} +} + +type concatExecutor struct { + baseExecutor + command *concatCommand + values []string +} + +func (e *concatExecutor) GetCommand() Command { + return e.command +} + +func (e *concatExecutor) Run(editorContext EditorContext, stage Stage) error { + if e.finished { + return nil + } + + command := e.command + + if e.values == nil { + e.values = make([]string, len(command.values)) + } + + for i, value := range command.values { + if value == nil || e.values[i] != "" { + continue + } + v := "" + if s, ok := value.(string); ok { + v = s + } else if ref, ok := value.(*Ref); ok && ref.GetStage() == stage { + v = editorContext.GetRefValue(ref) + } + e.values[i] = v + } + + if command.targetRef.GetStage() == stage { + result := "" + for _, v := range e.values { + if v == "" { + continue + } + result += v + } + log.Debugf("concatCommand: set %s to %s", command.targetRef, result) + editorContext.SetRefValue(command.targetRef, result) + e.finished = true + } + return nil +} + +// copyCommand +func newCopyCommand(json gjson.Result) (Command, error) { + var sourceRef *Ref + var targetRef *Ref + var err error + if t := json.Get("source"); !t.Exists() { + return nil, errors.New("copyCommand: source field is required") + } else { + sourceRef, err = NewRef(t) + if err != nil { + return nil, fmt.Errorf("copyCommand: failed to create ref from source field: %v\n %v", err, t.Raw) + } + } + if t := json.Get("target"); !t.Exists() { + return nil, errors.New("copyCommand: target field is required") + } else { + targetRef, err = NewRef(t) + if err != nil { + return nil, fmt.Errorf("copyCommand: failed to create ref from target field: %v\n %v", err, t.Raw) + } + } + if sourceRef.GetStage() > targetRef.GetStage() { + return nil, fmt.Errorf("copyCommand: the processing stage of source [%s] cannot be after the stage of target [%s]", Stage2String[sourceRef.GetStage()], Stage2String[targetRef.GetStage()]) + } + return ©Command{ + sourceRef: sourceRef, + targetRef: targetRef, + }, nil +} + +type copyCommand struct { + sourceRef *Ref + targetRef *Ref +} + +func (c *copyCommand) GetType() string { + return commandTypeCopy +} + +func (c *copyCommand) GetRefs() []*Ref { + return []*Ref{c.sourceRef, c.targetRef} +} + +func (c *copyCommand) CreateExecutor() Executor { + return ©Executor{command: c} +} + +type copyExecutor struct { + baseExecutor + command *copyCommand + valueToCopy string +} + +func (e *copyExecutor) GetCommand() Command { + return e.command +} + +func (e *copyExecutor) Run(editorContext EditorContext, stage Stage) error { + if e.finished { + return nil + } + + command := e.command + + if command.sourceRef.GetStage() == stage { + e.valueToCopy = editorContext.GetRefValue(command.sourceRef) + log.Debugf("copyCommand: valueToCopy=%s", e.valueToCopy) + } + + if e.valueToCopy == "" { + log.Debug("copyCommand: valueToCopy is empty. skip.") + e.finished = true + return nil + } + + if command.targetRef.GetStage() == stage { + editorContext.SetRefValue(command.targetRef, e.valueToCopy) + log.Debugf("copyCommand: set %s to %s", e.valueToCopy, command.targetRef) + e.finished = true + } + + return nil +} + +// deleteCommand +func newDeleteCommand(json gjson.Result) (Command, error) { + var targetRef *Ref + var err error + if t := json.Get("target"); !t.Exists() { + return nil, errors.New("deleteCommand: target field is required") + } else { + targetRef, err = NewRef(t) + if err != nil { + return nil, fmt.Errorf("deleteCommand: failed to create ref from target field: %v\n %v", err, t.Raw) + } + } + return &deleteCommand{ + targetRef: targetRef, + }, nil +} + +type deleteCommand struct { + targetRef *Ref +} + +func (c *deleteCommand) GetType() string { + return commandTypeDelete +} + +func (c *deleteCommand) GetRefs() []*Ref { + return []*Ref{c.targetRef} +} + +func (c *deleteCommand) CreateExecutor() Executor { + return &deleteExecutor{command: c} +} + +type deleteExecutor struct { + baseExecutor + command *deleteCommand +} + +func (e *deleteExecutor) GetCommand() Command { + return e.command +} + +func (e *deleteExecutor) Run(editorContext EditorContext, stage Stage) error { + if e.finished { + return nil + } + + command := e.command + log.Debugf("deleteCommand: checking stage %s for target %s", Stage2String[stage], command.targetRef) + + if command.targetRef.GetStage() == stage { + log.Debugf("deleteCommand: delete %s", command.targetRef) + editorContext.DeleteRefValues(command.targetRef) + e.finished = true + log.Debugf("deleteCommand: finished deleting %s", command.targetRef) + } else { + log.Debugf("deleteCommand: stage %s does not match targetRef stage %s, skipping.", Stage2String[stage], Stage2String[command.targetRef.GetStage()]) + } + + return nil +} + +// renameCommand +func newRenameCommand(json gjson.Result) (Command, error) { + var targetRef *Ref + var err error + if t := json.Get("target"); !t.Exists() { + return nil, errors.New("renameCommand: target field is required") + } else { + targetRef, err = NewRef(t) + if err != nil { + return nil, fmt.Errorf("renameCommand: failed to create ref from target field: %v\n %v", err, t.Raw) + } + } + newName := json.Get("newName").String() + if newName == "" { + return nil, errors.New("renameCommand: newName field is required") + } + return &renameCommand{ + targetRef: targetRef, + newName: newName, + }, nil +} + +type renameCommand struct { + targetRef *Ref + newName string +} + +func (c *renameCommand) GetType() string { + return commandTypeRename +} + +func (c *renameCommand) GetRefs() []*Ref { + return []*Ref{c.targetRef} +} + +func (c *renameCommand) CreateExecutor() Executor { + return &renameExecutor{command: c} +} + +type renameExecutor struct { + baseExecutor + command *renameCommand +} + +func (e *renameExecutor) GetCommand() Command { + return e.command +} + +func (e *renameExecutor) Run(editorContext EditorContext, stage Stage) error { + if e.finished { + return nil + } + + command := e.command + log.Debugf("renameCommand: checking stage %s for target %s", Stage2String[stage], command.targetRef) + + if command.targetRef.GetStage() == stage { + if command.newName == command.targetRef.Name { + log.Debugf("renameCommand: skip renaming %s to itself", command.targetRef) + } else { + values := editorContext.GetRefValues(command.targetRef) + log.Debugf("renameCommand: rename %s to %s value=%v", command.targetRef, command.newName, values) + editorContext.SetRefValues(&Ref{ + Type: command.targetRef.Type, + Name: command.newName, + }, values) + editorContext.DeleteRefValues(command.targetRef) + log.Debugf("renameCommand: finished renaming %s to %s", command.targetRef, command.newName) + } + e.finished = true + } else { + log.Debugf("renameCommand: stage %s does not match targetRef stage %s, skipping.", Stage2String[stage], Stage2String[command.targetRef.GetStage()]) + } + + return nil +} diff --git a/plugins/wasm-go/extensions/traffic-editor/pkg/command_test.go b/plugins/wasm-go/extensions/traffic-editor/pkg/command_test.go new file mode 100644 index 000000000..b48084756 --- /dev/null +++ b/plugins/wasm-go/extensions/traffic-editor/pkg/command_test.go @@ -0,0 +1,309 @@ +package pkg + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestNewSetCommand_Success(t *testing.T) { + jsonStr := `{"type":"set","target":{"type":"request_header","name":"foo"},"value":"bar"}` + json := gjson.Parse(jsonStr) + cmd, err := newSetCommand(json) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if cmd.GetType() != "set" { + t.Errorf("expected type 'set', got %s", cmd.GetType()) + } + refs := cmd.GetRefs() + if len(refs) != 1 { + t.Errorf("expected 1 ref, got %d", len(refs)) + } +} + +func TestNewSetCommand_MissingTarget(t *testing.T) { + jsonStr := `{"type":"set","value":"bar"}` + json := gjson.Parse(jsonStr) + _, err := newSetCommand(json) + if err == nil || err.Error() != "setCommand: target field is required" { + t.Errorf("expected target field error, got %v", err) + } +} + +func TestNewSetCommand_MissingValue(t *testing.T) { + jsonStr := `{"type":"set","target":{"type":"request_header","name":"foo"}}` + json := gjson.Parse(jsonStr) + _, err := newSetCommand(json) + if err == nil || err.Error() != "setCommand: value field is required" { + t.Errorf("expected value field error, got %v", err) + } +} + +func TestNewConcatCommand_Success(t *testing.T) { + jsonStr := `{"type":"concat","target":{"type":"request_header","name":"foo"},"values":["a","b"]}` + json := gjson.Parse(jsonStr) + cmd, err := newConcatCommand(json) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if cmd.GetType() != "concat" { + t.Errorf("expected type 'concat', got %s", cmd.GetType()) + } + refs := cmd.GetRefs() + if len(refs) < 1 { + t.Errorf("expected at least 1 ref, got %d", len(refs)) + } +} + +func TestNewConcatCommand_MissingTarget(t *testing.T) { + jsonStr := `{"type":"concat","values":["a","b"]}` + json := gjson.Parse(jsonStr) + _, err := newConcatCommand(json) + if err == nil || err.Error() != "concatCommand: target field is required" { + t.Errorf("expected target field error, got %v", err) + } +} + +func TestNewConcatCommand_MissingValues(t *testing.T) { + jsonStr := `{"type":"concat","target":{"type":"request_header","name":"foo"}}` + json := gjson.Parse(jsonStr) + _, err := newConcatCommand(json) + if err == nil || err.Error() != "concatCommand: values field is required and must be an array" { + t.Errorf("expected values field error, got %v", err) + } +} + +func TestNewCopyCommand_Success(t *testing.T) { + jsonStr := `{"type":"copy","source":{"type":"request_header","name":"foo"},"target":{"type":"request_header","name":"bar"}}` + json := gjson.Parse(jsonStr) + cmd, err := newCopyCommand(json) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if cmd.GetType() != "copy" { + t.Errorf("expected type 'copy', got %s", cmd.GetType()) + } + refs := cmd.GetRefs() + if len(refs) != 2 { + t.Errorf("expected 2 refs, got %d", len(refs)) + } +} + +func TestNewCopyCommand_MissingSource(t *testing.T) { + jsonStr := `{"type":"copy","target":{"type":"request_header","name":"bar"}}` + json := gjson.Parse(jsonStr) + _, err := newCopyCommand(json) + if err == nil || err.Error() != "copyCommand: source field is required" { + t.Errorf("expected source field error, got %v", err) + } +} + +func TestNewCopyCommand_MissingTarget(t *testing.T) { + jsonStr := `{"type":"copy","source":{"type":"request_header","name":"foo"}}` + json := gjson.Parse(jsonStr) + _, err := newCopyCommand(json) + if err == nil || err.Error() != "copyCommand: target field is required" { + t.Errorf("expected target field error, got %v", err) + } +} + +func TestNewCopyCommand_SourceStageAfterTarget(t *testing.T) { + jsonStr := `{"type":"copy","source":{"type":"response_header","name":"foo"},"target":{"type":"request_header","name":"bar"}}` + json := gjson.Parse(jsonStr) + _, err := newCopyCommand(json) + if err == nil || err.Error() != "copyCommand: the processing stage of source [response_headers] cannot be after the stage of target [request_headers]" { + t.Errorf("expected source stage field error, got %v", err) + } +} + +func TestNewDeleteCommand_Success(t *testing.T) { + jsonStr := `{"type":"delete","target":{"type":"request_header","name":"foo"}}` + json := gjson.Parse(jsonStr) + cmd, err := newDeleteCommand(json) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if cmd.GetType() != "delete" { + t.Errorf("expected type 'delete', got %s", cmd.GetType()) + } + refs := cmd.GetRefs() + if len(refs) != 1 { + t.Errorf("expected 1 ref, got %d", len(refs)) + } +} + +func TestNewDeleteCommand_MissingTarget(t *testing.T) { + jsonStr := `{"type":"delete"}` + json := gjson.Parse(jsonStr) + _, err := newDeleteCommand(json) + if err == nil || err.Error() != "deleteCommand: target field is required" { + t.Errorf("expected target field error, got %v", err) + } +} + +func TestNewRenameCommand_Success(t *testing.T) { + jsonStr := `{"type":"rename","target":{"type":"request_header","name":"foo"},"newName":"bar"}` + json := gjson.Parse(jsonStr) + cmd, err := newRenameCommand(json) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if cmd.GetType() != "rename" { + t.Errorf("expected type 'rename', got %s", cmd.GetType()) + } + refs := cmd.GetRefs() + if len(refs) != 1 { + t.Errorf("expected 1 ref, got %d", len(refs)) + } +} + +func TestNewRenameCommand_MissingTarget(t *testing.T) { + jsonStr := `{"type":"rename","newName":"bar"}` + json := gjson.Parse(jsonStr) + _, err := newRenameCommand(json) + if err == nil || err.Error() != "renameCommand: target field is required" { + t.Errorf("expected target field error, got %v", err) + } +} + +func TestNewRenameCommand_MissingNewName(t *testing.T) { + jsonStr := `{"type":"rename","target":{"type":"request_header","name":"foo"}}` + json := gjson.Parse(jsonStr) + _, err := newRenameCommand(json) + if err == nil || err.Error() != "renameCommand: newName field is required" { + t.Errorf("expected newName field error, got %v", err) + } +} + +func TestSetExecutor_Run_SingleStage(t *testing.T) { + ref := &Ref{Type: RefTypeRequestHeader, Name: "foo"} + cmd := &setCommand{targetRef: ref, value: "bar"} + executor := cmd.CreateExecutor() + ctx := NewEditorContext() + stage := StageRequestHeaders + + err := executor.Run(ctx, stage) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if ctx.GetRefValue(ref) != "bar" { + t.Errorf("expected value 'bar', got %s", ctx.GetRefValue(ref)) + } +} + +func TestConcatExecutor_Run_SingleStage(t *testing.T) { + ref := &Ref{Type: RefTypeRequestHeader, Name: "foo"} + srcRef := &Ref{Type: RefTypeRequestHeader, Name: "test"} + cmd := &concatCommand{targetRef: ref, values: []interface{}{"a", srcRef, "b"}} + executor := cmd.CreateExecutor() + ctx := NewEditorContext() + ctx.SetRefValue(srcRef, "-") + stage := StageRequestHeaders + + err := executor.Run(ctx, stage) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if ctx.GetRefValue(ref) != "a-b" { + t.Errorf("expected value 'a-b', got %s", ctx.GetRefValue(ref)) + } +} + +func TestConcatExecutor_Run_MultiStages(t *testing.T) { + ref := &Ref{Type: RefTypeResponseHeader, Name: "foo"} + srcRef := &Ref{Type: RefTypeRequestHeader, Name: "test"} + cmd := &concatCommand{targetRef: ref, values: []interface{}{"a", srcRef, "b"}} + executor := cmd.CreateExecutor() + ctx := NewEditorContext() + ctx.SetRefValue(srcRef, "-") + + err := executor.Run(ctx, StageRequestHeaders) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + err = executor.Run(ctx, StageResponseHeaders) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if ctx.GetRefValue(ref) != "a-b" { + t.Errorf("expected value 'a-b', got %s", ctx.GetRefValue(ref)) + } +} + +func TestCopyExecutor_Run_SingleStage(t *testing.T) { + source := &Ref{Type: RefTypeRequestHeader, Name: "foo"} + target := &Ref{Type: RefTypeRequestHeader, Name: "bar"} + ctx := NewEditorContext() + ctx.SetRefValue(source, "baz") + cmd := ©Command{sourceRef: source, targetRef: target} + executor := cmd.CreateExecutor() + stage := StageRequestHeaders + + err := executor.Run(ctx, stage) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if ctx.GetRefValue(target) != "baz" { + t.Errorf("expected value 'baz' for target, got %s", ctx.GetRefValue(target)) + } +} + +func TestCopyExecutor_Run_MultiStages(t *testing.T) { + source := &Ref{Type: RefTypeRequestHeader, Name: "foo"} + target := &Ref{Type: RefTypeResponseHeader, Name: "bar"} + ctx := NewEditorContext() + ctx.SetRefValue(source, "baz") + cmd := ©Command{sourceRef: source, targetRef: target} + executor := cmd.CreateExecutor() + + err := executor.Run(ctx, StageRequestHeaders) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + err = executor.Run(ctx, StageResponseHeaders) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if ctx.GetRefValue(target) != "baz" { + t.Errorf("expected value 'baz' for target, got %s", ctx.GetRefValue(target)) + } +} + +func TestDeleteExecutor_Run(t *testing.T) { + ref := &Ref{Type: RefTypeRequestHeader, Name: "foo"} + ctx := NewEditorContext() + ctx.SetRefValue(ref, "bar") + cmd := &deleteCommand{targetRef: ref} + executor := cmd.CreateExecutor() + stage := StageRequestHeaders + + err := executor.Run(ctx, stage) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if ctx.GetRefValue(ref) != "" { + t.Errorf("expected value to be deleted, got %s", ctx.GetRefValue(ref)) + } +} + +func TestRenameExecutor_Run(t *testing.T) { + ref := &Ref{Type: RefTypeRequestHeader, Name: "foo"} + ctx := NewEditorContext() + ctx.SetRefValue(ref, "bar") + cmd := &renameCommand{targetRef: ref, newName: "baz"} + executor := cmd.CreateExecutor() + stage := StageRequestHeaders + + err := executor.Run(ctx, stage) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + newRef := &Ref{Type: ref.Type, Name: "baz"} + if ctx.GetRefValue(newRef) != "bar" { + t.Errorf("expected value 'bar' for new name, got %s", ctx.GetRefValue(newRef)) + } + if ctx.GetRefValue(ref) != "" { + t.Errorf("expected old name to be deleted, got %s", ctx.GetRefValue(ref)) + } +} diff --git a/plugins/wasm-go/extensions/traffic-editor/pkg/condition.go b/plugins/wasm-go/extensions/traffic-editor/pkg/condition.go new file mode 100644 index 000000000..86615b330 --- /dev/null +++ b/plugins/wasm-go/extensions/traffic-editor/pkg/condition.go @@ -0,0 +1,325 @@ +package pkg + +import ( + "errors" + "fmt" + "regexp" + "strings" + + "github.com/higress-group/wasm-go/pkg/log" + "github.com/tidwall/gjson" +) + +const ( + conditionTypeEquals = "equals" + conditionTypePrefix = "prefix" + conditionTypeSuffix = "suffix" + conditionTypeContains = "contains" + conditionTypeRegex = "regex" +) + +var ( + conditionFactories = map[string]func(gjson.Result) (Condition, error){ + conditionTypeEquals: newEqualsCondition, + conditionTypePrefix: newPrefixCondition, + conditionTypeSuffix: newSuffixCondition, + conditionTypeContains: newContainsCondition, + conditionTypeRegex: newRegexCondition, + } +) + +type ConditionSet struct { + Conditions []Condition `json:"conditions,omitempty"` + RelatedStages map[Stage]bool `json:"-"` +} + +func (s *ConditionSet) FromJson(json gjson.Result) error { + relatedStages := map[Stage]bool{} + s.Conditions = nil + if conditionsJson := json.Get("conditions"); conditionsJson.Exists() && conditionsJson.IsArray() { + for _, item := range conditionsJson.Array() { + if condition, err := CreateCondition(item); err != nil { + return fmt.Errorf("failed to create condition from json: %v\n %v", err, item) + } else { + s.Conditions = append(s.Conditions, condition) + for _, ref := range condition.GetRefs() { + relatedStages[ref.GetStage()] = true + } + } + } + } + s.RelatedStages = relatedStages + + return nil +} + +func (s *ConditionSet) Matches(editorContext EditorContext) bool { + if len(s.Conditions) == 0 { + return true + } + for _, condition := range s.Conditions { + if !condition.Evaluate(editorContext) { + return false + } + } + return true +} + +type Condition interface { + GetType() string + GetRefs() []*Ref + Evaluate(ctx EditorContext) bool +} + +func CreateCondition(json gjson.Result) (Condition, error) { + t := json.Get("type").String() + if t == "" { + return nil, errors.New("condition type is required") + } + if constructor, ok := conditionFactories[t]; !ok || constructor == nil { + return nil, errors.New("unknown condition type: " + t) + } else if condition, err := constructor(json); err != nil { + return nil, fmt.Errorf("failed to create condition with type %s: %v", t, err) + } else { + for _, ref := range condition.GetRefs() { + if ref.GetStage() >= StageResponseHeaders { + return nil, fmt.Errorf("condition only supports request refs") + } + } + return condition, nil + } +} + +// equalsCondition +func newEqualsCondition(json gjson.Result) (Condition, error) { + value1 := json.Get("value1") + if value1.Type != gjson.JSON { + return nil, errors.New("equalsCondition: value1 field type must be JSON object") + } + value1Ref, err := NewRef(value1) + if err != nil { + return nil, errors.New("equalsCondition: failed to create value1 ref: " + err.Error()) + } + value2 := json.Get("value2").String() + return &equalsCondition{ + value1Ref: value1Ref, + value2: value2, + }, nil +} + +type equalsCondition struct { + value1Ref *Ref + value2 string +} + +func (c *equalsCondition) GetType() string { + return conditionTypeEquals +} + +func (c *equalsCondition) GetRefs() []*Ref { + return []*Ref{c.value1Ref} +} + +func (c *equalsCondition) Evaluate(ctx EditorContext) bool { + log.Debugf("Evaluating equals condition: value1Ref=%v, value2=%s", c.value1Ref, c.value2) + ref1Values := ctx.GetRefValues(c.value1Ref) + if len(ref1Values) == 0 { + log.Debugf("No values found for ref1: %v", c.value1Ref) + return false + } + for _, value1 := range ref1Values { + if value1 == c.value2 { + log.Debugf("Condition matched: %s == %s", value1, c.value2) + return true + } + } + log.Debugf("No matches found for condition: value1Ref=%v, value2=%s", c.value1Ref, c.value2) + return false +} + +// prefixCondition +func newPrefixCondition(json gjson.Result) (Condition, error) { + value := json.Get("value") + if value.Type != gjson.JSON { + return nil, errors.New("prefixCondition: value field type must be JSON object") + } + valueRef, err := NewRef(value) + if err != nil { + return nil, errors.New("prefixCondition: failed to create value ref: " + err.Error()) + } + prefix := json.Get("prefix").String() + return &prefixCondition{ + valueRef: valueRef, + prefix: prefix, + }, nil +} + +type prefixCondition struct { + valueRef *Ref + prefix string +} + +func (c *prefixCondition) GetType() string { + return conditionTypePrefix +} + +func (c *prefixCondition) GetRefs() []*Ref { + return []*Ref{c.valueRef} +} + +func (c *prefixCondition) Evaluate(ctx EditorContext) bool { + log.Debugf("Evaluating prefix condition: valueRef=%v, prefix=%s", c.valueRef, c.prefix) + refValues := ctx.GetRefValues(c.valueRef) + if len(refValues) == 0 { + log.Debugf("No values found for ref: %v", c.valueRef) + return false + } + for _, value := range refValues { + if strings.HasPrefix(value, c.prefix) { + log.Debugf("Condition matched: %s starts with %s", value, c.prefix) + return true + } + } + log.Debugf("No matches found for condition: valueRef=%v, prefix=%s", c.valueRef, c.prefix) + return false +} + +// suffixCondition +func newSuffixCondition(json gjson.Result) (Condition, error) { + value := json.Get("value") + if value.Type != gjson.JSON { + return nil, errors.New("suffixCondition: value field type must be JSON object") + } + valueRef, err := NewRef(value) + if err != nil { + return nil, errors.New("suffixCondition: failed to create value ref: " + err.Error()) + } + suffix := json.Get("suffix").String() + return &suffixCondition{ + valueRef: valueRef, + suffix: suffix, + }, nil +} + +type suffixCondition struct { + valueRef *Ref + suffix string +} + +func (c *suffixCondition) GetType() string { + return conditionTypeSuffix +} + +func (c *suffixCondition) GetRefs() []*Ref { + return []*Ref{c.valueRef} +} +func (c *suffixCondition) Evaluate(ctx EditorContext) bool { + log.Debugf("Evaluating suffix condition: valueRef=%v, prefix=%s", c.valueRef, c.suffix) + refValues := ctx.GetRefValues(c.valueRef) + if len(refValues) == 0 { + log.Debugf("No values found for ref: %v", c.valueRef) + return false + } + for _, value := range refValues { + if strings.HasSuffix(value, c.suffix) { + log.Debugf("Condition matched: %s ends with %s", value, c.suffix) + return true + } + } + log.Debugf("No matches found for condition: valueRef=%v, prefix=%s", c.valueRef, c.suffix) + return false +} + +// containsCondition +func newContainsCondition(json gjson.Result) (Condition, error) { + value := json.Get("value") + if value.Type != gjson.JSON { + return nil, errors.New("containsCondition: value field type must be JSON object") + } + valueRef, err := NewRef(value) + if err != nil { + return nil, errors.New("containsCondition: failed to create value ref: " + err.Error()) + } + part := json.Get("part").String() + return &containsCondition{ + valueRef: valueRef, + part: part, + }, nil +} + +type containsCondition struct { + valueRef *Ref + part string +} + +func (c *containsCondition) GetType() string { + return conditionTypeContains +} + +func (c *containsCondition) GetRefs() []*Ref { + return []*Ref{c.valueRef} +} + +func (c *containsCondition) Evaluate(ctx EditorContext) bool { + refValues := ctx.GetRefValues(c.valueRef) + if len(refValues) == 0 { + return false + } + for _, value := range refValues { + if strings.Contains(value, c.part) { + return true + } + } + return false +} + +// regexCondition +func newRegexCondition(json gjson.Result) (Condition, error) { + value := json.Get("value") + if value.Type != gjson.JSON { + return nil, errors.New("regexCondition: value field type must be JSON object") + } + valueRef, err := NewRef(value) + if err != nil { + return nil, errors.New("regexCondition: failed to create value ref: " + err.Error()) + } + patternStr := json.Get("pattern").String() + pattern, err := regexp.Compile(patternStr) + if err != nil { + return nil, errors.New("regexCondition: failed to compile pattern: " + err.Error()) + } + return ®exCondition{ + valueRef: valueRef, + pattern: pattern, + }, nil +} + +type regexCondition struct { + valueRef *Ref + pattern *regexp.Regexp +} + +func (c *regexCondition) GetType() string { + return conditionTypeRegex +} + +func (c *regexCondition) Evaluate(ctx EditorContext) bool { + log.Debugf("Evaluating regex condition: valueRef=%v, pattern=%s", c.valueRef, c.pattern.String()) + refValues := ctx.GetRefValues(c.valueRef) + if len(refValues) == 0 { + log.Debugf("No values found for ref: %v", c.valueRef) + return false + } + for _, value := range refValues { + if c.pattern.MatchString(value) { + log.Debugf("Condition matched: %s matches %s", value, c.pattern.String()) + return true + } + } + log.Debugf("No matches found for condition: valueRef=%v, pattern=%s", c.valueRef, c.pattern.String()) + return false +} + +func (c *regexCondition) GetRefs() []*Ref { + return []*Ref{c.valueRef} +} diff --git a/plugins/wasm-go/extensions/traffic-editor/pkg/condition_test.go b/plugins/wasm-go/extensions/traffic-editor/pkg/condition_test.go new file mode 100644 index 000000000..2a9b31bda --- /dev/null +++ b/plugins/wasm-go/extensions/traffic-editor/pkg/condition_test.go @@ -0,0 +1,217 @@ +package pkg + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +// --- equalsCondition tests --- +func TestEqualsCondition_Match(t *testing.T) { + json := gjson.Parse(`{"type":"equals","value1":{"type":"request_header","name":"x-test"},"value2":"abc"}`) + cond, err := CreateCondition(json) + if err != nil { + t.Fatalf("CreateCondition failed: %v", err) + } + ctx := NewEditorContext() + ctx.SetRequestHeaders(map[string][]string{"x-test": {"abc"}}) + if !cond.Evaluate(ctx) { + t.Error("equalsCondition should match") + } +} + +func TestEqualsCondition_NoMatch(t *testing.T) { + json := gjson.Parse(`{"type":"equals","value1":{"type":"request_header","name":"x-test"},"value2":"abc"}`) + cond, _ := CreateCondition(json) + ctx := NewEditorContext() + ctx.SetRequestHeaders(map[string][]string{"x-test": {"def"}}) + if cond.Evaluate(ctx) { + t.Error("equalsCondition should not match") + } +} + +// --- prefixCondition tests --- +func TestPrefixCondition_Match(t *testing.T) { + json := gjson.Parse(`{"type":"prefix","value":{"type":"request_query","name":"foo"},"prefix":"bar"}`) + cond, err := CreateCondition(json) + if err != nil { + t.Fatalf("CreateCondition failed: %v", err) + } + ctx := NewEditorContext() + ctx.SetRequestQueries(map[string][]string{"foo": {"barbaz"}}) + if !cond.Evaluate(ctx) { + t.Error("prefixCondition should match") + } +} + +func TestPrefixCondition_NoMatch(t *testing.T) { + json := gjson.Parse(`{"type":"prefix","value":{"type":"request_query","name":"foo"},"prefix":"bar"}`) + cond, _ := CreateCondition(json) + ctx := NewEditorContext() + ctx.SetRequestQueries(map[string][]string{"foo": {"bazbar"}}) + if cond.Evaluate(ctx) { + t.Error("prefixCondition should not match") + } +} + +// --- suffixCondition tests --- +func TestSuffixCondition_Match(t *testing.T) { + json := gjson.Parse(`{"type":"suffix","value":{"type":"request_header","name":"x-end"},"suffix":"xyz"}`) + cond, err := CreateCondition(json) + if err != nil { + t.Fatalf("CreateCondition failed: %v", err) + } + ctx := NewEditorContext() + ctx.SetRequestHeaders(map[string][]string{"x-end": {"123xyz"}}) + if !cond.Evaluate(ctx) { + t.Error("suffixCondition should match") + } +} + +func TestSuffixCondition_NoMatch(t *testing.T) { + json := gjson.Parse(`{"type":"suffix","value":{"type":"request_header","name":"x-end"},"suffix":"xyz"}`) + cond, _ := CreateCondition(json) + ctx := NewEditorContext() + ctx.SetRequestHeaders(map[string][]string{"x-end": {"xyz123"}}) + if cond.Evaluate(ctx) { + t.Error("suffixCondition should not match") + } +} + +// --- containsCondition tests --- +func TestContainsCondition_Match(t *testing.T) { + json := gjson.Parse(`{"type":"contains","value":{"type":"request_query","name":"foo"},"part":"baz"}`) + cond, err := CreateCondition(json) + if err != nil { + t.Fatalf("CreateCondition failed: %v", err) + } + ctx := NewEditorContext() + ctx.SetRequestQueries(map[string][]string{"foo": {"barbaz"}}) + if !cond.Evaluate(ctx) { + t.Error("containsCondition should match") + } +} + +func TestContainsCondition_NoMatch(t *testing.T) { + json := gjson.Parse(`{"type":"contains","value":{"type":"request_query","name":"foo"},"part":"baz"}`) + cond, _ := CreateCondition(json) + ctx := NewEditorContext() + ctx.SetRequestQueries(map[string][]string{"foo": {"bar"}}) + if cond.Evaluate(ctx) { + t.Error("containsCondition should not match") + } +} + +// --- regexCondition tests --- +func TestRegexCondition_Match(t *testing.T) { + json := gjson.Parse(`{"type":"regex","value":{"type":"request_header","name":"x-reg"},"pattern":"^abc.*"}`) + cond, err := CreateCondition(json) + if err != nil { + t.Fatalf("CreateCondition failed: %v", err) + } + ctx := NewEditorContext() + ctx.SetRequestHeaders(map[string][]string{"x-reg": {"abcdef"}}) + if !cond.Evaluate(ctx) { + t.Error("regexCondition should match") + } +} + +func TestRegexCondition_NoMatch(t *testing.T) { + json := gjson.Parse(`{"type":"regex","value":{"type":"request_header","name":"x-reg"},"pattern":"^abc.*"}`) + cond, _ := CreateCondition(json) + ctx := NewEditorContext() + ctx.SetRequestHeaders(map[string][]string{"x-reg": {"defabc"}}) + if cond.Evaluate(ctx) { + t.Error("regexCondition should not match") + } +} + +// --- CreateCondition error cases --- +func TestCreateCondition_UnknownType(t *testing.T) { + json := gjson.Parse(`{"type":"unknown","value1":{"type":"request_header","name":"x-test"},"value2":"abc"}`) + _, err := CreateCondition(json) + if err == nil { + t.Error("CreateCondition should fail for unknown type") + } +} + +func TestCreateCondition_MissingType(t *testing.T) { + json := gjson.Parse(`{"value1":{"type":"request_header","name":"x-test"},"value2":"abc"}`) + _, err := CreateCondition(json) + if err == nil { + t.Error("CreateCondition should fail for missing type") + } +} + +func TestCreateCondition_InvalidRefType(t *testing.T) { + json := gjson.Parse(`{"type":"equals","value1":{"type":"invalid_type","name":"x-test"},"value2":"abc"}`) + _, err := CreateCondition(json) + if err == nil { + t.Error("CreateCondition should fail for invalid ref type") + } +} + +func TestCreateCondition_MissingRefName(t *testing.T) { + json := gjson.Parse(`{"type":"equals","value1":{"type":"request_header"},"value2":"abc"}`) + _, err := CreateCondition(json) + if err == nil { + t.Error("CreateCondition should fail for missing ref name") + } +} + +// --- ConditionSet tests --- +func TestConditionSet_Matches_AllMatch(t *testing.T) { + json := gjson.Parse(`{"conditions":[{"type":"equals","value1":{"type":"request_header","name":"x-test"},"value2":"abc"},{"type":"prefix","value":{"type":"request_query","name":"foo"},"prefix":"bar"}]}`) + var set ConditionSet + if err := set.FromJson(json); err != nil { + t.Fatalf("FromJson failed: %v", err) + } + ctx := NewEditorContext() + ctx.SetRequestHeaders(map[string][]string{"x-test": {"abc"}}) + ctx.SetRequestQueries(map[string][]string{"foo": {"barbaz"}}) + if !set.Matches(ctx) { + t.Error("ConditionSet should match when all conditions match") + } +} + +func TestConditionSet_Matches_OneNoMatch(t *testing.T) { + json := gjson.Parse(`{"conditions":[{"type":"equals","value1":{"type":"request_header","name":"x-test"},"value2":"abc"},{"type":"prefix","value":{"type":"request_query","name":"foo"},"prefix":"bar"}]}`) + var set ConditionSet + if err := set.FromJson(json); err != nil { + t.Fatalf("FromJson failed: %v", err) + } + ctx := NewEditorContext() + ctx.SetRequestHeaders(map[string][]string{"x-test": {"abc"}}) + ctx.SetRequestQueries(map[string][]string{"foo": {"baz"}}) + if set.Matches(ctx) { + t.Error("ConditionSet should not match if one condition does not match") + } +} + +func TestConditionSet_Matches_Empty(t *testing.T) { + json := gjson.Parse(`{"conditions":[]}`) + var set ConditionSet + if err := set.FromJson(json); err != nil { + t.Fatalf("FromJson failed: %v", err) + } + ctx := NewEditorContext() + if !set.Matches(ctx) { + t.Error("ConditionSet with no conditions should always match") + } +} + +// --- GetType/GetRefs coverage --- +func TestCondition_GetTypeAndRefs(t *testing.T) { + json := gjson.Parse(`{"type":"equals","value1":{"type":"request_header","name":"x-test"},"value2":"abc"}`) + cond, err := CreateCondition(json) + if err != nil { + t.Fatalf("CreateCondition failed: %v", err) + } + if cond.GetType() != "equals" { + t.Error("GetType should return 'equals'") + } + refs := cond.GetRefs() + if len(refs) != 1 || refs[0].Type != "request_header" || refs[0].Name != "x-test" { + t.Error("GetRefs should return correct ref") + } +} diff --git a/plugins/wasm-go/extensions/traffic-editor/pkg/context.go b/plugins/wasm-go/extensions/traffic-editor/pkg/context.go new file mode 100644 index 000000000..1ba8a33a8 --- /dev/null +++ b/plugins/wasm-go/extensions/traffic-editor/pkg/context.go @@ -0,0 +1,310 @@ +package pkg + +import ( + "maps" + "net/url" + "strings" + + "github.com/higress-group/wasm-go/pkg/log" +) + +type Stage int + +const ( + StageInvalid Stage = iota + StageRequestHeaders + StageRequestBody + StageResponseHeaders + StageResponseBody + + pathHeader = ":path" +) + +var ( + OrderedStages = []Stage{ + StageRequestHeaders, + StageRequestBody, + StageResponseHeaders, + StageResponseBody, + } + Stage2String = map[Stage]string{ + StageRequestHeaders: "request_headers", + StageRequestBody: "request_body", + StageResponseHeaders: "response_headers", + StageResponseBody: "response_body", + } +) + +type EditorContext interface { + GetEffectiveCommandSet() *CommandSet + SetEffectiveCommandSet(cmdSet *CommandSet) + GetCommandExecutors() []Executor + SetCommandExecutors(executors []Executor) + GetCurrentStage() Stage + SetCurrentStage(stage Stage) + + GetRequestPath() string + SetRequestPath(path string) + GetRequestHeader(key string) []string + GetRequestHeaders() map[string][]string + SetRequestHeaders(map[string][]string) + GetRequestQuery(key string) []string + GetRequestQueries() map[string][]string + SetRequestQueries(map[string][]string) + GetResponseHeader(key string) []string + GetResponseHeaders() map[string][]string + SetResponseHeaders(map[string][]string) + + GetRefValue(ref *Ref) string + GetRefValues(ref *Ref) []string + SetRefValue(ref *Ref, value string) + SetRefValues(ref *Ref, values []string) + DeleteRefValues(ref *Ref) + + IsRequestHeadersDirty() bool + IsResponseHeadersDirty() bool + ResetDirtyFlags() +} + +func NewEditorContext() EditorContext { + return &editorContext{} +} + +type editorContext struct { + effectiveCommandSet *CommandSet + commandExecutors []Executor + + currentStage Stage + + requestPath string + requestHeaders map[string][]string + requestQueries map[string][]string + responseHeaders map[string][]string + + requestHeadersDirty bool + responseHeadersDirty bool +} + +func (ctx *editorContext) GetEffectiveCommandSet() *CommandSet { + return ctx.effectiveCommandSet +} + +func (ctx *editorContext) SetEffectiveCommandSet(cmdSet *CommandSet) { + ctx.effectiveCommandSet = cmdSet +} + +func (ctx *editorContext) GetCommandExecutors() []Executor { + return ctx.commandExecutors +} + +func (ctx *editorContext) SetCommandExecutors(executors []Executor) { + ctx.commandExecutors = executors +} + +func (ctx *editorContext) GetCurrentStage() Stage { + return ctx.currentStage +} + +func (ctx *editorContext) SetCurrentStage(stage Stage) { + ctx.currentStage = stage +} + +func (ctx *editorContext) GetRequestPath() string { + return ctx.requestPath +} + +func (ctx *editorContext) SetRequestPath(path string) { + ctx.requestPath = path + ctx.savePathToHeader() +} + +func (ctx *editorContext) GetRequestHeader(key string) []string { + if ctx.requestHeaders == nil { + return nil + } + return ctx.requestHeaders[strings.ToLower(key)] +} + +func (ctx *editorContext) GetRequestHeaders() map[string][]string { + return maps.Clone(ctx.requestHeaders) +} + +func (ctx *editorContext) SetRequestHeaders(headers map[string][]string) { + ctx.requestHeaders = headers + ctx.loadPathFromHeader() + ctx.requestHeadersDirty = true +} + +func (ctx *editorContext) GetRequestQuery(key string) []string { + if ctx.requestQueries == nil { + return nil + } + return ctx.requestQueries[key] +} + +func (ctx *editorContext) GetRequestQueries() map[string][]string { + return maps.Clone(ctx.requestQueries) +} + +func (ctx *editorContext) SetRequestQueries(queries map[string][]string) { + ctx.requestQueries = queries + ctx.savePathToHeader() +} + +func (ctx *editorContext) GetResponseHeader(key string) []string { + if ctx.responseHeaders == nil { + return nil + } + return ctx.responseHeaders[strings.ToLower(key)] +} + +func (ctx *editorContext) GetResponseHeaders() map[string][]string { + return maps.Clone(ctx.responseHeaders) +} + +func (ctx *editorContext) SetResponseHeaders(headers map[string][]string) { + ctx.responseHeaders = headers + ctx.responseHeadersDirty = true +} + +func (ctx *editorContext) GetRefValue(ref *Ref) string { + values := ctx.GetRefValues(ref) + if len(values) == 0 { + return "" + } + return values[0] +} + +func (ctx *editorContext) GetRefValues(ref *Ref) []string { + if ref == nil { + return nil + } + switch ref.Type { + case RefTypeRequestHeader: + return ctx.GetRequestHeader(strings.ToLower(ref.Name)) + case RefTypeRequestQuery: + return ctx.GetRequestQuery(ref.Name) + case RefTypeResponseHeader: + return ctx.GetResponseHeader(strings.ToLower(ref.Name)) + default: + return nil + } +} + +func (ctx *editorContext) SetRefValue(ref *Ref, value string) { + if ref == nil { + return + } + ctx.SetRefValues(ref, []string{value}) +} + +func (ctx *editorContext) SetRefValues(ref *Ref, values []string) { + if ref == nil { + return + } + switch ref.Type { + case RefTypeRequestHeader: + if ctx.requestHeaders == nil { + ctx.requestHeaders = make(map[string][]string) + } + loweredRefName := strings.ToLower(ref.Name) + ctx.requestHeaders[loweredRefName] = values + ctx.requestHeadersDirty = true + if loweredRefName == pathHeader { + ctx.loadPathFromHeader() + } + break + case RefTypeRequestQuery: + if ctx.requestQueries == nil { + ctx.requestQueries = make(map[string][]string) + } + ctx.requestQueries[ref.Name] = values + ctx.savePathToHeader() + break + case RefTypeResponseHeader: + if ctx.responseHeaders == nil { + ctx.responseHeaders = make(map[string][]string) + } + ctx.responseHeaders[strings.ToLower(ref.Name)] = values + ctx.responseHeadersDirty = true + break + } +} + +func (ctx *editorContext) DeleteRefValues(ref *Ref) { + if ref == nil { + return + } + switch ref.Type { + case RefTypeRequestHeader: + delete(ctx.requestHeaders, strings.ToLower(ref.Name)) + ctx.requestHeadersDirty = true + break + case RefTypeRequestQuery: + delete(ctx.requestQueries, ref.Name) + ctx.savePathToHeader() + break + case RefTypeResponseHeader: + delete(ctx.responseHeaders, strings.ToLower(ref.Name)) + ctx.responseHeadersDirty = true + break + } +} + +func (ctx *editorContext) IsRequestHeadersDirty() bool { + return ctx.requestHeadersDirty +} + +func (ctx *editorContext) IsResponseHeadersDirty() bool { + return ctx.responseHeadersDirty +} + +func (ctx *editorContext) ResetDirtyFlags() { + ctx.requestHeadersDirty = false + ctx.responseHeadersDirty = false +} + +func (ctx *editorContext) savePathToHeader() { + u, err := url.Parse(ctx.requestPath) + if err != nil { + log.Errorf("failed to build the new path with query strings: %v", err) + return + } + + query := url.Values{} + for k, vs := range ctx.requestQueries { + for _, v := range vs { + query.Add(k, v) + } + } + u.RawQuery = query.Encode() + ctx.SetRefValue(&Ref{Type: RefTypeRequestHeader, Name: pathHeader}, u.String()) +} + +func (ctx *editorContext) loadPathFromHeader() { + paths := ctx.GetRequestHeader(pathHeader) + + if len(paths) == 0 || paths[0] == "" { + log.Warn("the request has an empty path") + ctx.requestPath = "" + ctx.requestQueries = make(map[string][]string) + return + } + + path := paths[0] + queries := make(map[string][]string) + + u, err := url.Parse(path) + if err != nil { + log.Warnf("unable to parse the request path: %s", path) + ctx.requestPath = "" + ctx.requestQueries = make(map[string][]string) + return + } + + ctx.requestPath = u.Path + for k, vs := range u.Query() { + queries[k] = vs + } + ctx.requestQueries = queries +} diff --git a/plugins/wasm-go/extensions/traffic-editor/pkg/context_test.go b/plugins/wasm-go/extensions/traffic-editor/pkg/context_test.go new file mode 100644 index 000000000..64c9dc5e9 --- /dev/null +++ b/plugins/wasm-go/extensions/traffic-editor/pkg/context_test.go @@ -0,0 +1,218 @@ +package pkg + +import ( + "reflect" + "testing" +) + +func newTestRef(t, name string) *Ref { + return &Ref{Type: t, Name: name} +} + +func TestEditorContext_CommandSetAndExecutors(t *testing.T) { + ctx := NewEditorContext().(*editorContext) + cmdSet := &CommandSet{} + ctx.SetEffectiveCommandSet(cmdSet) + if ctx.GetEffectiveCommandSet() != cmdSet { + t.Errorf("EffectiveCommandSet not set/get correctly") + } + + executors := []Executor{nil, nil} + ctx.SetCommandExecutors(executors) + if !reflect.DeepEqual(ctx.GetCommandExecutors(), executors) { + t.Errorf("CommandExecutors not set/get correctly") + } +} + +func TestEditorContext_Stage(t *testing.T) { + ctx := NewEditorContext().(*editorContext) + ctx.SetCurrentStage(StageRequestHeaders) + if ctx.GetCurrentStage() != StageRequestHeaders { + t.Errorf("CurrentStage not set/get correctly") + } +} + +func TestEditorContext_RequestPath(t *testing.T) { + ctx := NewEditorContext().(*editorContext) + ctx.SetRequestPath("/foo/bar") + if ctx.GetRequestPath() != "/foo/bar" { + t.Errorf("RequestPath not set/get correctly") + } +} + +func TestEditorContext_RequestHeaders(t *testing.T) { + ctx := NewEditorContext().(*editorContext) + headers := map[string][]string{"foo": {"bar"}, "baz": {"qux"}} + ctx.SetRequestHeaders(headers) + if !reflect.DeepEqual(ctx.GetRequestHeaders(), headers) { + t.Errorf("RequestHeaders not set/get correctly") + } + if !ctx.IsRequestHeadersDirty() { + t.Errorf("RequestHeadersDirty not set correctly") + } + if got := ctx.GetRequestHeader("foo"); !reflect.DeepEqual(got, []string{"bar"}) { + t.Errorf("GetRequestHeader failed") + } +} + +func TestEditorContext_RequestQueries(t *testing.T) { + ctx := NewEditorContext().(*editorContext) + queries := map[string][]string{"foo": {"bar"}, "baz": {"qux"}} + ctx.SetRequestQueries(queries) + if !reflect.DeepEqual(ctx.GetRequestQueries(), queries) { + t.Errorf("RequestQueries not set/get correctly") + } + if !ctx.IsRequestHeadersDirty() { + t.Errorf("RequestHeadersDirty not set correctly") + } + if got := ctx.GetRequestQuery("foo"); !reflect.DeepEqual(got, []string{"bar"}) { + t.Errorf("GetRequestQuery failed") + } +} + +func TestEditorContext_ResponseHeaders(t *testing.T) { + ctx := NewEditorContext().(*editorContext) + headers := map[string][]string{"foo": {"bar"}, "baz": {"qux"}} + ctx.SetResponseHeaders(headers) + if !reflect.DeepEqual(ctx.GetResponseHeaders(), headers) { + t.Errorf("ResponseHeaders not set/get correctly") + } + if !ctx.IsResponseHeadersDirty() { + t.Errorf("ResponseHeadersDirty not set correctly") + } + if got := ctx.GetResponseHeader("foo"); !reflect.DeepEqual(got, []string{"bar"}) { + t.Errorf("GetResponseHeader failed") + } +} + +func TestEditorContext_RefValueAndValues(t *testing.T) { + ctx := NewEditorContext().(*editorContext) + rh := newTestRef(RefTypeRequestHeader, "foo") + rq := newTestRef(RefTypeRequestQuery, "bar") + rh2 := newTestRef(RefTypeResponseHeader, "baz") + + ctx.SetRefValue(rh, "v1") + ctx.SetRefValues(rq, []string{"v2", "v3"}) + ctx.SetRefValues(rh2, []string{"v4"}) + + if v := ctx.GetRefValue(rh); v != "v1" { + t.Errorf("GetRefValue(RequestHeader) failed: %v", v) + } + if v := ctx.GetRefValues(rq); !reflect.DeepEqual(v, []string{"v2", "v3"}) { + t.Errorf("GetRefValues(RequestQuery) failed: %v", v) + } + if v := ctx.GetRefValues(rh2); !reflect.DeepEqual(v, []string{"v4"}) { + t.Errorf("GetRefValues(ResponseHeader) failed: %v", v) + } +} + +func TestEditorContext_DeleteRefValues(t *testing.T) { + ctx := NewEditorContext().(*editorContext) + rh := newTestRef(RefTypeRequestHeader, "foo") + rq := newTestRef(RefTypeRequestQuery, "bar") + rh2 := newTestRef(RefTypeResponseHeader, "baz") + + ctx.SetRefValue(rh, "v1") + ctx.SetRefValues(rq, []string{"v2", "v3"}) + ctx.SetRefValues(rh2, []string{"v4"}) + + ctx.DeleteRefValues(rh) + ctx.DeleteRefValues(rq) + ctx.DeleteRefValues(rh2) + + if v := ctx.GetRefValues(rh); len(v) != 0 { + t.Errorf("DeleteRefValues(RequestHeader) failed: %v", v) + } + if v := ctx.GetRefValues(rq); len(v) != 0 { + t.Errorf("DeleteRefValues(RequestQuery) failed: %v", v) + } + if v := ctx.GetRefValues(rh2); len(v) != 0 { + t.Errorf("DeleteRefValues(ResponseHeader) failed: %v", v) + } +} + +func TestEditorContext_ResetDirtyFlags(t *testing.T) { + ctx := NewEditorContext().(*editorContext) + ctx.SetRequestHeaders(map[string][]string{"foo": {"bar"}}) + ctx.SetRequestQueries(map[string][]string{"foo": {"bar"}}) + ctx.SetResponseHeaders(map[string][]string{"foo": {"bar"}}) + ctx.ResetDirtyFlags() + if ctx.IsRequestHeadersDirty() || ctx.IsRequestHeadersDirty() || ctx.IsResponseHeadersDirty() { + t.Errorf("ResetDirtyFlags failed") + } +} + +func TestEditorContext_IsRequestHeadersDirty_SetHeaders(t *testing.T) { + ctx := NewEditorContext().(*editorContext) + if ctx.IsRequestHeadersDirty() { + t.Errorf("RequestHeadersDirty should be false initially") + } + ctx.SetRequestHeaders(map[string][]string{"foo": {"bar"}}) + if !ctx.IsRequestHeadersDirty() { + t.Errorf("RequestHeadersDirty should be true after SetRequestHeaders") + } + ctx.ResetDirtyFlags() + if ctx.IsRequestHeadersDirty() { + t.Errorf("RequestHeadersDirty should be false after ResetDirtyFlags") + } + ref := newTestRef(RefTypeRequestHeader, "foo") + ctx.SetRefValue(ref, "baz") + if !ctx.IsRequestHeadersDirty() { + t.Errorf("RequestHeadersDirty should be true after SetRefValue") + } + ctx.ResetDirtyFlags() + ctx.DeleteRefValues(ref) + if !ctx.IsRequestHeadersDirty() { + t.Errorf("RequestHeadersDirty should be true after DeleteRefValues") + } +} + +func TestEditorContext_IsRequestHeadersDirty_SetQueries(t *testing.T) { + ctx := NewEditorContext().(*editorContext) + if ctx.IsRequestHeadersDirty() { + t.Errorf("RequestQueriesDirty should be false initially") + } + ctx.SetRequestQueries(map[string][]string{"foo": {"bar"}}) + if !ctx.IsRequestHeadersDirty() { + t.Errorf("RequestQueriesDirty should be true after SetRequestQueries") + } + ctx.ResetDirtyFlags() + if ctx.IsRequestHeadersDirty() { + t.Errorf("RequestQueriesDirty should be false after ResetDirtyFlags") + } + ref := newTestRef(RefTypeRequestQuery, "foo") + ctx.SetRefValues(ref, []string{"baz"}) + if !ctx.IsRequestHeadersDirty() { + t.Errorf("RequestQueriesDirty should be true after SetRefValues") + } + ctx.ResetDirtyFlags() + ctx.DeleteRefValues(ref) + if !ctx.IsRequestHeadersDirty() { + t.Errorf("RequestQueriesDirty should be true after DeleteRefValues") + } +} + +func TestEditorContext_IsResponseHeadersDirty(t *testing.T) { + ctx := NewEditorContext().(*editorContext) + if ctx.IsResponseHeadersDirty() { + t.Errorf("ResponseHeadersDirty should be false initially") + } + ctx.SetResponseHeaders(map[string][]string{"foo": {"bar"}}) + if !ctx.IsResponseHeadersDirty() { + t.Errorf("ResponseHeadersDirty should be true after SetResponseHeaders") + } + ctx.ResetDirtyFlags() + if ctx.IsResponseHeadersDirty() { + t.Errorf("ResponseHeadersDirty should be false after ResetDirtyFlags") + } + ref := newTestRef(RefTypeResponseHeader, "foo") + ctx.SetRefValues(ref, []string{"baz"}) + if !ctx.IsResponseHeadersDirty() { + t.Errorf("ResponseHeadersDirty should be true after SetRefValues") + } + ctx.ResetDirtyFlags() + ctx.DeleteRefValues(ref) + if !ctx.IsResponseHeadersDirty() { + t.Errorf("ResponseHeadersDirty should be true after DeleteRefValues") + } +} diff --git a/plugins/wasm-go/extensions/traffic-editor/pkg/mock_test.go b/plugins/wasm-go/extensions/traffic-editor/pkg/mock_test.go new file mode 100644 index 000000000..b91414d39 --- /dev/null +++ b/plugins/wasm-go/extensions/traffic-editor/pkg/mock_test.go @@ -0,0 +1,26 @@ +package pkg + +import ( + "github.com/higress-group/wasm-go/pkg/log" +) + +func init() { + // Initialize mock logger for testing + log.SetPluginLog(&mockLogger{}) +} + +type mockLogger struct{} + +func (m *mockLogger) Trace(msg string) {} +func (m *mockLogger) Tracef(format string, args ...interface{}) {} +func (m *mockLogger) Debug(msg string) {} +func (m *mockLogger) Debugf(format string, args ...interface{}) {} +func (m *mockLogger) Info(msg string) {} +func (m *mockLogger) Infof(format string, args ...interface{}) {} +func (m *mockLogger) Warn(msg string) {} +func (m *mockLogger) Warnf(format string, args ...interface{}) {} +func (m *mockLogger) Error(msg string) {} +func (m *mockLogger) Errorf(format string, args ...interface{}) {} +func (m *mockLogger) Critical(msg string) {} +func (m *mockLogger) Criticalf(format string, args ...interface{}) {} +func (m *mockLogger) ResetID(pluginID string) {} diff --git a/plugins/wasm-go/extensions/traffic-editor/pkg/ref.go b/plugins/wasm-go/extensions/traffic-editor/pkg/ref.go new file mode 100644 index 000000000..1dffe38cb --- /dev/null +++ b/plugins/wasm-go/extensions/traffic-editor/pkg/ref.go @@ -0,0 +1,64 @@ +package pkg + +import ( + "errors" + "fmt" + + "github.com/tidwall/gjson" +) + +const ( + RefTypeRequestHeader = "request_header" + RefTypeRequestQuery = "request_query" + RefTypeResponseHeader = "response_header" +) + +var ( + refType2Stage = map[string]Stage{ + RefTypeRequestHeader: StageRequestHeaders, + RefTypeRequestQuery: StageRequestHeaders, + RefTypeResponseHeader: StageResponseHeaders, + } +) + +type Ref struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + + stage Stage +} + +func NewRef(json gjson.Result) (*Ref, error) { + ref := &Ref{} + + if t := json.Get("type").String(); t != "" { + ref.Type = t + } else { + return nil, errors.New("missing type field") + } + + if _, ok := refType2Stage[ref.Type]; !ok { + return nil, fmt.Errorf("unknown ref type: %s", ref.Type) + } + + if name := json.Get("name").String(); name != "" { + ref.Name = name + } else { + return nil, errors.New("missing name field") + } + + return ref, nil +} + +func (r *Ref) GetStage() Stage { + if r.stage == 0 { + if stage, ok := refType2Stage[r.Type]; ok { + r.stage = stage + } + } + return r.stage +} + +func (r *Ref) String() string { + return fmt.Sprintf("%s/%s", r.Type, r.Name) +}