feat: Add traffic-editor plugin (#2825)

This commit is contained in:
Kent Dong
2025-12-26 17:29:55 +08:00
committed by GitHub
parent 4babdb6a4f
commit 08a7204085
18 changed files with 3026 additions and 0 deletions

View File

@@ -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"
}
]
}
]
}
```

View File

@@ -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" } }
]
}
]
}
```

View File

@@ -0,0 +1 @@
1.0.0-alpha

View File

@@ -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
}

View File

@@ -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: {}

View File

@@ -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
)

View File

@@ -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=

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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 &copyCommand{
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 &copyExecutor{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
}

View File

@@ -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 := &copyCommand{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 := &copyCommand{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))
}
}

View File

@@ -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 &regexCondition{
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}
}

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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) {}

View File

@@ -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)
}