diff --git a/plugins/wasm-go/Dockerfile b/plugins/wasm-go/Dockerfile
index 9900871db..07349054b 100644
--- a/plugins/wasm-go/Dockerfile
+++ b/plugins/wasm-go/Dockerfile
@@ -14,7 +14,7 @@ COPY . .
WORKDIR /workspace/extensions/$PLUGIN_NAME
RUN go mod tidy
-RUN tinygo build -o /main.wasm -scheduler=none -gc=custom -tags='custommalloc nottinygc_finalizer' -target=wasi ./main.go
+RUN tinygo build -o /main.wasm -scheduler=none -gc=custom -tags='custommalloc nottinygc_finalizer' -target=wasi ./
FROM scratch as output
diff --git a/plugins/wasm-go/extensions/transformer/README.md b/plugins/wasm-go/extensions/transformer/README.md
new file mode 100644
index 000000000..904b2f7d0
--- /dev/null
+++ b/plugins/wasm-go/extensions/transformer/README.md
@@ -0,0 +1,500 @@
+# 功能说明
+`transformer` 插件可以对请求/响应头、请求查询参数、请求/响应体参数进行转换,支持的转换操作类型包括删除、重命名、更新、添加、追加、映射、去重。
+
+
+# 配置字段
+
+| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
+| -------- | -------- | -------- | -------- | -------- |
+| type | string | 必填,可选值为 `request`, `response` | - | 指定转换器类型 |
+| rules | array of object | 选填 | - | 指定转换操作类型以及请求/响应头、请求查询参数、请求/响应体参数的转换规则 |
+
+`rules`中每一项的配置字段说明如下:
+
+| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
+| -------- | -------- | -------- | -------- | -------- |
+| operate | string | 必填,可选值为 `remove`, `rename`, `replace`, `add`, `append`, `map`, `dedupe` | - | 指定转换操作类型,支持的操作类型有删除 (remove)、重命名 (rename)、更新 (replace)、添加 (add)、追加 (append)、映射 (map)、去重 (dedupe),当存在多项不同类型的转换规则时,按照上述操作类型顺序依次执行 |
+| headers | array of object | 选填 | - | 指定请求/响应头转换规则 |
+| querys | array of object | 选填 | - | 指定请求查询参数转换规则 |
+| body | array of object | 选填 | - | 指定请求/响应体参数转换规则,请求体转换允许 content-type 为 `application/json`, `application/x-www-form-urlencoded`, `multipart/form-data`;响应体转换仅允许 content-type 为 `application/json` |
+
+`headers`, `querys`, `body`中每一项的配置字段说明如下:
+
+| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
+| -------- | -------- | -------- | -------- |---------------------------------------------------|
+| key | string | 选填 | - | 指定键,详见[转换操作类型](#转换操作类型) |
+| value | string | 选填 | - | 指定值,详见[转换操作类型](#转换操作类型) |
+| value_type | string | 选填,可选值为 `object`, `boolean`, `number`, `string` | string | 当`content-type: application/json`时,该字段指定请求/响应体参数的值类型 |
+| host_pattern | string | 选填 | - | 指定请求主机名匹配规则,当转换操作类型为 `replace`, `add`, `append` 时有效 |
+| path_pattern | string | 选填 | - | 指定请求路径匹配规则,当转换操作类型为 `replace`, `add`, `append` 时有效 |
+
+注意:
+
+* `request transformer` 支持以下转换对象:请求头部、请求查询参数、请求体(application/json, application/x-www-form-urlencoded, multipart/form-data)
+* `response transformer` 支持以下转换对象:响应头部、响应体(application/json)
+
+* 转换操作类型的执行顺序:remove → rename → replace → add → append → map → dedupe
+* 当转换对象为 headers 时,` key` 不区分大小写;当为 headers 且为 `rename`, `map` 操作时,`value` 也不区分大小写(因为此时该字段具有 key 含义);而 querys 和 body 的 `key`, `value` 字段均区分大小写
+* `value_type` 仅对 content-type 为 application/json 的请求/响应体有效
+* `host_pattern` 和 `path_pathern` 支持 [RE2 语法](https://pkg.go.dev/regexp/syntax),仅对 `replace`, `add`, `append` 操作有效,且在一项转换规则中两者只能选填其一,若均填写,则 `host_pattern` 生效,而 `path_pattern` 失效
+
+
+
+# 转换操作类型
+
+| 操作类型 | key 字段含义 | value 字段含义 | 描述 |
+| ------------- | ----------------- |-----| ------------------------------------------------------------ |
+| 删除 remove | 目标 key |无需设置| 若存在指定的 `key`,则删除;否则无操作 |
+| 重命名 rename | 目标 oldKey |新的 key 名称 newKey| 若存在指定的 `oldKey:value`,则将其键名重命名为 `newKey`,得到 `newKey:value`;否则无操作 |
+| 更新 replace | 目标 key |新的 value 值 newValue| 若存在指定的 `key:value`,则将其 value 更新为 `newValue`,得到 `key:newValue`;否则无操作 |
+| 添加 add | 添加的 key | 添加的 value |若不存在指定的 `key:value`,则添加;否则无操作 |
+| 追加 append | 目标 key |追加的 value值 appendValue| 若存在指定的 `key:value`,则追加 appendValue 得到 `key:[value..., appendValue]`;否则相当于执行 add 操作,得到 `key:appendValue` |
+| 映射 map | 映射来源 fromKey |映射目标 toKey| 若存在指定的 `fromKey:fromValue`,则将其值 fromValue 映射给 toKey 的值,得到 `toKey:fromValue`,同时保留 `fromKey:fromValue`(注:若 toKey 已存在则其值会被覆盖);否则无操作 |
+| 去重 dedupe | 目标 key |指定去重策略 strategy| `strategy` 可选值为:
`RETAIN_UNIQUE`: 按顺序保留所有唯一值,如 `k1:[v1,v2,v3,v3,v2,v1]`,去重后得到 `k1:[v1,v2,v3]`
`RETAIN_LAST`: 保留最后一个值,如 `k1:[v1,v2,v3]`,去重后得到 `k1:v3`
`RETAIN_FIRST` (default): 保留第一个值,如 `k1:[v1,v2,v3]`,去重后得到 `k1:v1`
(注:若去重后只剩下一个元素 v1 时,键值对变为 `k1:v1`, 而不是 `k1:[v1]`) |
+
+
+
+
+# 配置示例
+
+## Request Transformer
+
+### 转换请求头部
+
+```yaml
+type: request
+rules:
+- operate: remove
+ headers:
+ - key: X-remove
+- operate: rename
+ headers:
+ - key: X-not-renamed
+ value: X-renamed
+- operate: replace
+ headers:
+ - key: X-replace
+ value: replaced
+- operate: add
+ headers:
+ - key: X-add-append
+ value: host-$1
+ host_pattern: ^(.*)\.com$
+- operate: append
+ headers:
+ - key: X-add-append
+ value: path-$1
+ path_pattern: ^.*?\/(\w+)[\?]{0,1}.*$
+- operate: map
+ headers:
+ - key: X-add-append
+ value: X-map
+- operate: dedupe
+ headers:
+ - key: X-dedupe-first
+ value: RETAIN_FIRST
+ - key: X-dedupe-last
+ value: RETAIN_LAST
+ - key: X-dedupe-unique
+ value: RETAIN_UNIQUE
+```
+
+发送请求
+
+```bash
+$ curl -v console.higress.io/get -H 'host: foo.bar.com' \
+-H 'X-remove: exist' -H 'X-not-renamed:test' -H 'X-replace:not-replaced' \
+-H 'X-dedupe-first:1' -H 'X-dedupe-first:2' -H 'X-dedupe-first:3' \
+-H 'X-dedupe-last:a' -H 'X-dedupe-last:b' -H 'X-dedupe-last:c' \
+-H 'X-dedupe-unique:1' -H 'X-dedupe-unique:2' -H 'X-dedupe-unique:3' \
+-H 'X-dedupe-unique:3' -H 'X-dedupe-unique:2' -H 'X-dedupe-unique:1'
+
+# httpbin 响应结果
+{
+ "args": {},
+ "headers": {
+ ...
+ "X-Add-Append": "host-foo.bar,path-get",
+ ...
+ "X-Dedupe-First": "1",
+ "X-Dedupe-Last": "c",
+ "X-Dedupe-Unique": "1,2,3",
+ ...
+ "X-Map": "host-foo.bar,path-get",
+ "X-Renamed": "test",
+ "X-Replace": "replaced"
+ },
+ ...
+}
+```
+
+### 转换请求查询参数
+
+```yaml
+type: request
+rules:
+- operate: remove
+ querys:
+ - key: k1
+- operate: rename
+ querys:
+ - key: k2
+ value: k2-new
+- operate: replace
+ querys:
+ - key: k2-new
+ value: v2-new
+- operate: add
+ querys:
+ - key: k3
+ value: v31-$1
+ path_pattern: ^.*?\/(\w+)[\?]{0,1}.*$
+- operate: append
+ querys:
+ - key: k3
+ value: v32
+- operate: map
+ querys:
+ - key: k3
+ value: k4
+- operate: dedupe
+ querys:
+ - key: k4
+ value: RETAIN_FIRST
+```
+
+发送请求
+
+```bash
+$ curl -v "console.higress.io/get?k1=v11&k1=v12&k2=v2"
+
+# httpbin 响应结果
+{
+ "args": {
+ "k2-new": "v2-new",
+ "k3": [
+ "v31-get",
+ "v32"
+ ],
+ "k4": "v31-get"
+ },
+ ...
+ "url": "http://foo.bar.com/get?k2-new=v2-new&k3=v31-get&k3=v32&k4=v31-get"
+}
+```
+
+### 转换请求体
+
+```yaml
+type: request
+rules:
+- operate: remove
+ body:
+ - key: a1
+- operate: rename
+ body:
+ - key: a2
+ value: a2-new
+- operate: replace
+ body:
+ - key: a3
+ value: t3-new
+ value_type: string
+- operate: add
+ body:
+ - key: a1-new
+ value: t1-new
+ value_type: string
+- operate: append
+ body:
+ - key: a1-new
+ value: t1-$1-append
+ value_type: string
+ host_pattern: ^(.*)\.com$
+- operate: map
+ body:
+ - key: a1-new
+ value: a4
+- operate: dedupe
+ body:
+ - key: a4
+ value: RETAIN_FIRST
+```
+
+发送请求:
+
+**1. Content-Type: application/json**
+
+```bash
+$ curl -v -x POST console.higress.io/post -H 'host: foo.bar.com' \
+-H 'Content-Type: application/json' -d '{"a1":"t1","a2":"t2","a3":"t3"}'
+
+# httpbin 响应结果
+{
+ ...
+ "headers": {
+ ...
+ "Content-Type": "application/json",
+ ...
+ },
+ "json": {
+ "a1-new": [
+ "t1-new",
+ "t1-foo.bar-append"
+ ],
+ "a2-new": "t2",
+ "a3": "t3-new",
+ "a4": "t1-new"
+ },
+ ...
+}
+```
+
+**2. Content-Type: application/x-www-form-urlencoded**
+
+```bash
+$ curl -v -X POST console.higress.io/post -H 'host: foo.bar.com' \
+-d 'a1=t1&a2=t2&a3=t3'
+
+# httpbin 响应结果
+{
+ ...
+ "form": {
+ "a1-new": [
+ "t1-new",
+ "t1-foo.bar-append"
+ ],
+ "a2-new": "t2",
+ "a3": "t3-new",
+ "a4": "t1-new"
+ },
+ "headers": {
+ ...
+ "Content-Type": "application/x-www-form-urlencoded",
+ ...
+ },
+ ...
+}
+```
+
+**3. Content-Type: multipart/form-data**
+
+```bash
+$ curl -v -X POST console.higress.io/post -H 'host: foo.bar.com' \
+-F a1=t1 -F a2=t2 -F a3=t3
+
+# httpbin 响应结果
+{
+ ...
+ "form": {
+ "a1-new": [
+ "t1-new",
+ "t1-foo.bar-append"
+ ],
+ "a2-new": "t2",
+ "a3": "t3-new",
+ "a4": "t1-new"
+ },
+ "headers": {
+ ...
+ "Content-Type": "multipart/form-data; boundary=------------------------1118b3fab5afbc4e",
+ ...
+ },
+ ...
+}
+```
+
+## Response Transformer
+
+与 Request Transformer 类似,在此仅说明转换 JSON 形式的请求/响应体时的注意事项:
+
+### key 嵌套 `.`
+
+1.通常情况下,指定的 key 中含有 `.` 表示嵌套含义,如下:
+
+```yaml
+type: response
+rules:
+- operate: add
+ body:
+ - key: foo.bar
+ value: value
+```
+
+```bash
+$ curl -v console.higress.io/get
+
+# httpbin 响应结果
+{
+ ...
+ "foo": {
+ "bar": "value"
+ },
+ ...
+}
+```
+
+2.当使用 `\.` 对 key 中的 `.` 进行转义后,表示非嵌套含义,如下:
+
+> 当使用双引号括住字符串时使用 `\\.` 进行转义
+
+```yaml
+type: response
+rules:
+- operate: add
+ body:
+ - key: foo\.bar
+ value: value
+```
+
+```bash
+$ curl -v console.higress.io/get
+
+# httpbin 响应结果
+{
+ ...
+ "foo.bar": "value",
+ ...
+}
+```
+
+### 访问数组元素 `.index`
+
+可以通过数组下标 `array.index 访问数组元素,下标从 0 开始:
+
+```json
+{
+ "users": [
+ {
+ "123": { "name": "zhangsan", "age": 18 }
+ },
+ {
+ "456": { "name": "lisi", "age": 19 }
+ }
+ ]
+}
+```
+
+1.移除 `user` 第一个元素:
+
+```yaml
+type: request
+rules:
+- operate: remove
+ body:
+ - key: users.0
+```
+
+```bash
+$ curl -v -X POST console.higress.io/post \
+-H 'Content-Type: application/json' \
+-d '{"users":[{"123":{"name":"zhangsan"}},{"456":{"name":"lisi"}}]}'
+
+# httpbin 响应结果
+{
+ ...
+ "json": {
+ "users": [
+ {
+ "456": {
+ "name": "lisi"
+ }
+ }
+ ]
+ },
+ ...
+}
+```
+
+2.将 `users` 第一个元素的 key 为 `123` 重命名为 `msg`:
+
+```yaml
+type: request
+rules:
+- operate: rename
+ body:
+ - key: users.0.123
+ value: users.0.first
+```
+
+```bash
+$ curl -v -X POST console.higress.io/post \
+-H 'Content-Type: application/json' \
+-d '{"users":[{"123":{"name":"zhangsan"}},{"456":{"name":"lisi"}}]}'
+
+
+# httpbin 响应结果
+{
+ ...
+ "json": {
+ "users": [
+ {
+ "msg": {
+ "name": "zhangsan"
+ }
+ },
+ {
+ "456": {
+ "name": "lisi"
+ }
+ }
+ ]
+ },
+ ...
+}
+```
+
+### 遍历数组元素 `.#`
+
+可以使用 `array.#` 对数组进行遍历操作:
+
+> ❗️该操作目前只能用在 replace 上,请勿在其他转换中尝试该操作,以免造成无法预知的结果
+
+```json
+{
+ "users": [
+ {
+ "name": "zhangsan",
+ "age": 18
+ },
+ {
+ "name": "lisi",
+ "age": 19
+ }
+ ]
+}
+```
+
+```yaml
+type: request
+rules:
+- operate: replace
+ body:
+ - key: users.#.age
+ value: 20
+```
+
+```bash
+$ curl -v -X POST console.higress.io/post \
+-H 'Content-Type: application/json' \
+-d '{"users":[{"name":"zhangsan","age":18},{"name":"lisi","age":19}]}'
+
+
+# httpbin 响应结果
+{
+ ...
+ "json": {
+ "users": [
+ {
+ "age": "20",
+ "name": "zhangsan"
+ },
+ {
+ "age": "20",
+ "name": "lisi"
+ }
+ ]
+ },
+ ...
+}
+```
diff --git a/plugins/wasm-go/extensions/transformer/VERSION b/plugins/wasm-go/extensions/transformer/VERSION
new file mode 100644
index 000000000..afaf360d3
--- /dev/null
+++ b/plugins/wasm-go/extensions/transformer/VERSION
@@ -0,0 +1 @@
+1.0.0
\ No newline at end of file
diff --git a/plugins/wasm-go/extensions/transformer/go.mod b/plugins/wasm-go/extensions/transformer/go.mod
new file mode 100644
index 000000000..5b7a1b57f
--- /dev/null
+++ b/plugins/wasm-go/extensions/transformer/go.mod
@@ -0,0 +1,25 @@
+module github.com/alibaba/higress/plugins/wasm-go/extensions/transformer
+
+go 1.19
+
+replace github.com/alibaba/higress/plugins/wasm-go => ../..
+
+require (
+ github.com/alibaba/higress/plugins/wasm-go v0.0.0-20230829022308-8747e1ddadf0
+ github.com/pkg/errors v0.9.1
+ github.com/stretchr/testify v1.8.0
+ github.com/tetratelabs/proxy-wasm-go-sdk v0.22.0
+ github.com/tidwall/gjson v1.17.0
+)
+
+require (
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/google/uuid v1.3.0 // indirect
+ github.com/magefile/mage v1.14.0 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/tidwall/match v1.1.1 // indirect
+ github.com/tidwall/pretty v1.2.1 // indirect
+ github.com/tidwall/sjson v1.2.5 // indirect
+ github.com/wasilibs/nottinygc v0.3.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/plugins/wasm-go/extensions/transformer/go.sum b/plugins/wasm-go/extensions/transformer/go.sum
new file mode 100644
index 000000000..2e58dc16d
--- /dev/null
+++ b/plugins/wasm-go/extensions/transformer/go.sum
@@ -0,0 +1,40 @@
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+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.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
+github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
+github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
+github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/tetratelabs/proxy-wasm-go-sdk v0.22.0 h1:kS7BvMKN+FiptV4pfwiNX8e3q14evxAWkhYbxt8EI1M=
+github.com/tetratelabs/proxy-wasm-go-sdk v0.22.0/go.mod h1:qkW5MBz2jch2u8bS59wws65WC+Gtx3x0aPUX5JL7CXI=
+github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw=
+github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM=
+github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
+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/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
+github.com/wasilibs/nottinygc v0.3.0 h1:0L1jsJ1MsyN5tdinmFbLfuEA0TnHRcqaBM9pDTJVJmU=
+github.com/wasilibs/nottinygc v0.3.0/go.mod h1:oDcIotskuYNMpqMF23l7Z8uzD4TC0WXHK8jetlB3HIo=
+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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+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/transformer/main.go b/plugins/wasm-go/extensions/transformer/main.go
new file mode 100644
index 000000000..47e7aefbc
--- /dev/null
+++ b/plugins/wasm-go/extensions/transformer/main.go
@@ -0,0 +1,965 @@
+// 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 (
+ "regexp"
+ "strings"
+
+ "github.com/alibaba/higress/plugins/wasm-go/pkg/wrapper"
+
+ "github.com/pkg/errors"
+ "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm"
+ "github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm/types"
+ "github.com/tidwall/gjson"
+ "github.com/tidwall/sjson"
+)
+
+func main() {
+ wrapper.SetCtx(
+ "transformer",
+ wrapper.ParseConfigBy(parseConfig),
+ wrapper.ProcessRequestHeadersBy(onHttpRequestHeaders),
+ wrapper.ProcessRequestBodyBy(onHttpRequestBody),
+ wrapper.ProcessResponseHeadersBy(onHttpResponseHeaders),
+ wrapper.ProcessResponseBodyBy(onHttpResponseBody),
+ )
+}
+
+// @Name transformer
+// @Category custom
+// @Phase UNSPECIFIED_PHASE
+// @Priority 100
+// @Title zh-CN 请求/响应转换器
+// @Title en-US Request/Response Transformer
+// @Description zh-CN transformer 插件可以对请求/响应头、请求查询参数、请求/响应体参数进行转换,支持的转换操作类型包括删除、重命名、更新、添加、追加、映射、去重。
+// @Description en-US The transformer plugin can transform request/response headers, request query parameters, and request/response body parameters. Supported transform operations include remove, rename, replace, add, append, map, and dedupe.
+// @IconUrl https://img.alicdn.com/imgextra/i1/O1CN018iKKih1iVx287RltL_!!6000000004419-2-tps-42-42.png
+// @Version 1.0.0
+//
+// @Contact.name Higress Team
+// @Contact.url http://higress.io/
+// @Contact.email admin@higress.io
+//
+// @Example
+// type: request
+// rules:
+// - operate: remove
+// headers:
+// - key: X-remove
+// querys:
+// - key: k1
+// body:
+// - key: a1
+// - operate: rename
+// headers:
+// - key: X-not-renamed
+// value: X-renamed
+// - operate: replace
+// headers:
+// - key: X-replace
+// value: replaced
+// - operate: add
+// headers:
+// - key: X-add-append
+// value: host-$1
+// host_pattern: ^(.*)\.com$
+// - operate: append
+// headers:
+// - key: X-add-append
+// value: path-$1
+// path_pattern: ^.*?\/(\w+)[\?]{0,1}.*$
+// body:
+// - key: a1-new
+// value: t1-$1-append
+// value_type: string
+// host_pattern: ^(.*)\.com$
+// - operate: map
+// headers:
+// - key: X-add-append
+// value: X-map
+// - operate: dedupe
+// headers:
+// - key: X-dedupe-first
+// value: RETAIN_FIRST
+// @End
+type TransformerConfig struct {
+ // @Title 转换器类型
+ // @Description 指定转换器类型,可选值为 request, response。
+ typ string `yaml:"type"`
+
+ // @Title 转换规则
+ // @Description 指定转换操作类型以及请求/响应头、请求查询参数、请求/响应体参数的转换规则
+ rules []TransformRule `yaml:"rules"`
+
+ // this field is not exposed to the user and is used to store the request/response transformer instance
+ trans Transformer `yaml:"-"`
+}
+
+type TransformRule struct {
+ // @Title 转换操作类型
+ // @Description 指定转换操作类型,可选值为 remove, rename, replace, add, append, map, dedupe
+ operate string `yaml:"operate"`
+
+ // @Title 请求/响应头转换规则
+ // @Description 指定请求/响应头转换规则
+ headers []Param `yaml:"headers"`
+
+ // @Title 请求查询参数转换规则
+ // @Description 指定请求查询参数转换规则
+ querys []Param `yaml:"querys"`
+
+ // @Title 请求/响应体参数转换规则
+ // @Description 指定请求/响应体参数转换规则,请求体转换允许 content-type 为 application/json, application/x-www-form-urlencoded, multipart/form-data;响应体转换仅允许 content-type 为 application/json
+ body []Param `yaml:"body"`
+}
+
+type Param struct {
+ // @Title 参数的键
+ // @Description 指定键值对的键
+ key string `yaml:"key"`
+
+ // @Title 参数的值
+ // @Description 指定键值对的值,可能的含义有:空 (remove),key (rename, map), value (replace, add, append), strategy (dedupe)
+ value string `yaml:"value"`
+
+ // @Title 值类型
+ // @Description 当 content-type=application/json 时,为请求/响应体参数指定值类型,可选值为 object, boolean, number, string(default)
+ valueType string `yaml:"value_type"`
+
+ // @Title 请求主机名匹配规则
+ // @Description 指定主机名匹配规则,当转换操作类型为 replace, add, append 时有效
+ hostPattern string `yaml:"host_pattern"`
+
+ // @Title 请求路径匹配规则
+ // @Description 指定路径匹配规则,当转换操作类型为 replace, add, append 时有效
+ pathPattern string `yaml:"path_pattern"`
+}
+
+func parseConfig(json gjson.Result, config *TransformerConfig, log wrapper.Log) (err error) {
+ config.typ = strings.ToLower(json.Get("type").String())
+ if config.typ != "request" && config.typ != "response" {
+ return errors.Errorf("invalid transformer type %q", config.typ)
+ }
+
+ config.rules = make([]TransformRule, 0)
+ rules := json.Get("rules").Array()
+ for _, r := range rules {
+ var tRule TransformRule
+ tRule.operate = strings.ToLower(r.Get("operate").String())
+ if !isValidOperation(tRule.operate) {
+ return errors.Errorf("invalid operate type %q", tRule.operate)
+ }
+ for _, h := range r.Get("headers").Array() {
+ tRule.headers = append(tRule.headers, constructParam(&h, tRule.operate, ""))
+ }
+ for _, q := range r.Get("querys").Array() {
+ tRule.querys = append(tRule.querys, constructParam(&q, tRule.operate, ""))
+ }
+ for _, b := range r.Get("body").Array() {
+ valueType := strings.ToLower(b.Get("value_type").String())
+ if valueType == "" { // default
+ valueType = "string"
+ }
+ if !isValidJsonType(valueType) {
+ return errors.Errorf("invalid body params type %q", valueType)
+ }
+ tRule.body = append(tRule.body, constructParam(&b, tRule.operate, valueType))
+ }
+ config.rules = append(config.rules, tRule)
+ }
+
+ switch config.typ {
+ case "request":
+ config.trans, err = newRequestTransformer(config)
+ case "response":
+ config.trans, err = newResponseTransformer(config)
+ }
+ if err != nil {
+ return errors.Wrapf(err, "failed to new %s transformer", config.typ)
+ }
+
+ return nil
+}
+
+func constructParam(item *gjson.Result, op, valueType string) Param {
+ p := Param{
+ key: item.Get("key").String(),
+ value: item.Get("value").String(),
+ valueType: valueType,
+ }
+ if op == "replace" || op == "add" || op == "append" {
+ p.hostPattern = item.Get("host_pattern").String()
+ p.pathPattern = item.Get("path_pattern").String()
+ }
+ return p
+}
+
+func onHttpRequestHeaders(ctx wrapper.HttpContext, config TransformerConfig, log wrapper.Log) types.Action {
+ // because it may be a response transformer, so the setting of host and path have to advance
+ host, path := ctx.Host(), ctx.Path()
+ ctx.SetContext("host", host)
+ ctx.SetContext("path", path)
+
+ if config.typ == "response" {
+ return types.ActionContinue
+ }
+
+ log.Debug("on http request headers ...")
+
+ headers, err := proxywasm.GetHttpRequestHeaders()
+ if err != nil {
+ log.Warn("failed to get request headers")
+ return types.ActionContinue
+ }
+ hs := convertHeaders(headers)
+ if hs[":authority"] == nil {
+ log.Warn(errGetRequestHost.Error())
+ return types.ActionContinue
+ }
+ if hs[":path"] == nil {
+ log.Warn(errGetRequestPath.Error())
+ return types.ActionContinue
+ }
+ contentType := ""
+ if hs["content-type"] != nil {
+ contentType = hs["content-type"][0]
+ }
+ if config.trans.IsBodyChange() && isValidRequestContentType(contentType) {
+ delete(hs, "content-length")
+ ctx.SetContext("content-type", contentType)
+ } else {
+ ctx.DontReadRequestBody()
+ }
+
+ if config.trans.IsHeaderChange() {
+ if err = config.trans.TransformHeaders(host, path, hs); err != nil {
+ log.Warnf("failed to transform request headers: %v", err)
+ return types.ActionContinue
+ }
+ }
+
+ if config.trans.IsQueryChange() {
+ qs, err := parseQueryByPath(path)
+ if err != nil {
+ log.Warnf("failed to parse query params by path: %v", err)
+ return types.ActionContinue
+ }
+ if err = config.trans.TransformQuerys(host, path, qs); err != nil {
+ log.Warnf("failed to transform request query params: %v", err)
+ return types.ActionContinue
+ }
+ path, err = constructPath(path, qs)
+ if err != nil {
+ log.Warnf("failed to construct path: %v", err)
+ return types.ActionContinue
+ }
+ hs[":path"] = []string{path}
+ }
+
+ headers = reconvertHeaders(hs)
+ if err = proxywasm.ReplaceHttpRequestHeaders(headers); err != nil {
+ log.Warnf("failed to replace request headers: %v", err)
+ return types.ActionContinue
+ }
+
+ return types.ActionContinue
+}
+
+func onHttpRequestBody(ctx wrapper.HttpContext, config TransformerConfig, body []byte, log wrapper.Log) types.Action {
+ if config.typ == "response" || !config.trans.IsBodyChange() {
+ return types.ActionContinue
+ }
+
+ log.Debug("on http request body ...")
+
+ host, path, err := getHostAndPathFromHttpCtx(ctx)
+ if err != nil {
+ log.Warn(err.Error())
+ return types.ActionContinue
+ }
+ contentType, ok := ctx.GetContext("content-type").(string)
+ if !ok {
+ log.Warn(errGetContentType.Error())
+ return types.ActionContinue
+ }
+ structuredBody, err := parseBody(contentType, body)
+ if err != nil {
+ if !errors.Is(err, errEmptyBody) {
+ log.Warnf("failed to parse request body: %v", err)
+ }
+ log.Debug("request body is empty")
+ return types.ActionContinue
+ }
+
+ if err = config.trans.TransformBody(host, path, structuredBody); err != nil {
+ log.Warnf("failed to transform request body: %v", err)
+ return types.ActionContinue
+ }
+
+ body, err = constructBody(contentType, structuredBody)
+ if err != nil {
+ log.Warnf("failed to construct request body: %v", err)
+ return types.ActionContinue
+ }
+ if err = proxywasm.ReplaceHttpRequestBody(body); err != nil {
+ log.Warnf("failed to replace request body: %v", err)
+ return types.ActionContinue
+ }
+
+ return types.ActionContinue
+}
+
+func onHttpResponseHeaders(ctx wrapper.HttpContext, config TransformerConfig, log wrapper.Log) types.Action {
+ if config.typ == "request" {
+ return types.ActionContinue
+ }
+
+ log.Debug("on http response headers ...")
+
+ host, path, err := getHostAndPathFromHttpCtx(ctx)
+ if err != nil {
+ log.Warn(err.Error())
+ return types.ActionContinue
+ }
+ headers, err := proxywasm.GetHttpResponseHeaders()
+ if err != nil {
+ log.Warnf("failed to get response headers: %v", err)
+ return types.ActionContinue
+ }
+ hs := convertHeaders(headers)
+ contentType := ""
+ if hs["content-type"] != nil {
+ contentType = hs["content-type"][0]
+ }
+ if config.trans.IsBodyChange() && isValidResponseContentType(contentType) {
+ delete(hs, "content-length")
+ ctx.SetContext("content-type", contentType)
+ } else {
+ ctx.DontReadResponseBody()
+ }
+
+ if config.trans.IsHeaderChange() {
+ if err = config.trans.TransformHeaders(host, path, hs); err != nil {
+ log.Warnf("failed to transform response headers: %v", err)
+ return types.ActionContinue
+ }
+ }
+
+ headers = reconvertHeaders(hs)
+ if err = proxywasm.ReplaceHttpResponseHeaders(headers); err != nil {
+ log.Warnf("failed to replace response headers: %v", err)
+ return types.ActionContinue
+ }
+
+ return types.ActionContinue
+}
+
+func onHttpResponseBody(ctx wrapper.HttpContext, config TransformerConfig, body []byte, log wrapper.Log) types.Action {
+ if config.typ == "request" || !config.trans.IsBodyChange() {
+ return types.ActionContinue
+ }
+
+ log.Debug("on http response body ...")
+
+ host, path, err := getHostAndPathFromHttpCtx(ctx)
+ if err != nil {
+ log.Warn(err.Error())
+ return types.ActionContinue
+ }
+ contentType, ok := ctx.GetContext("content-type").(string)
+ if !ok {
+ log.Warn(errGetContentType.Error())
+ return types.ActionContinue
+ }
+ structuredBody, err := parseBody(contentType, body)
+ if err != nil {
+ if !errors.Is(err, errEmptyBody) {
+ log.Warnf("failed to parse response body: %v", err)
+ }
+ log.Debug("response body is empty")
+ return types.ActionContinue
+ }
+
+ if err = config.trans.TransformBody(host, path, structuredBody); err != nil {
+ log.Warnf("failed to transform response body: %v", err)
+ return types.ActionContinue
+ }
+
+ body, err = constructBody(contentType, structuredBody)
+ if err != nil {
+ log.Warnf("failed to construct response body: %v", err)
+ return types.ActionContinue
+ }
+ if err = proxywasm.ReplaceHttpResponseBody(body); err != nil {
+ log.Warnf("failed to replace response body: %v", err)
+ return types.ActionContinue
+ }
+
+ return types.ActionContinue
+}
+
+func getHostAndPathFromHttpCtx(ctx wrapper.HttpContext) (host, path string, err error) {
+ host, ok := ctx.GetContext("host").(string)
+ if !ok {
+ return "", "", errGetRequestHost
+ }
+ path, ok = ctx.GetContext("path").(string)
+ if !ok {
+ return "", "", errGetRequestPath
+ }
+ return host, path, nil
+}
+
+type Transformer interface {
+ TransformHeaders(host, path string, hs map[string][]string) error
+ TransformQuerys(host, path string, qs map[string][]string) error
+ TransformBody(host, path string, body interface{}) error
+ IsHeaderChange() bool
+ IsQueryChange() bool
+ IsBodyChange() bool
+}
+
+var _ Transformer = (*requestTransformer)(nil)
+var _ Transformer = (*responseTransformer)(nil)
+
+type requestTransformer struct {
+ headerHandler *kvHandler
+ queryHandler *kvHandler
+ bodyHandler *requestBodyHandler
+ isHeaderChange bool
+ isQueryChange bool
+ isBodyChange bool
+}
+
+func newRequestTransformer(config *TransformerConfig) (Transformer, error) {
+ headerKvtGroup, isHeaderChange, err := newKvtGroup(config.rules, "headers")
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to new kvt group for headers")
+ }
+ queryKvtGroup, isQueryChange, err := newKvtGroup(config.rules, "querys")
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to new kvt group for querys")
+ }
+ bodyKvtGroup, isBodyChange, err := newKvtGroup(config.rules, "body")
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to new kvt group for body")
+ }
+ return &requestTransformer{
+ headerHandler: &kvHandler{headerKvtGroup},
+ queryHandler: &kvHandler{queryKvtGroup},
+ bodyHandler: &requestBodyHandler{
+ formDataHandler: &kvHandler{bodyKvtGroup},
+ jsonHandler: &jsonHandler{bodyKvtGroup},
+ },
+ isHeaderChange: isHeaderChange,
+ isQueryChange: isQueryChange,
+ isBodyChange: isBodyChange,
+ }, nil
+}
+
+func (t requestTransformer) TransformHeaders(host, path string, hs map[string][]string) error {
+ return t.headerHandler.handle(host, path, hs)
+}
+
+func (t requestTransformer) TransformQuerys(host, path string, qs map[string][]string) error {
+ return t.queryHandler.handle(host, path, qs)
+}
+
+func (t requestTransformer) TransformBody(host, path string, body interface{}) error {
+ switch body.(type) {
+ case map[string][]string:
+ return t.bodyHandler.formDataHandler.handle(host, path, body.(map[string][]string))
+
+ case map[string]interface{}:
+ m := body.(map[string]interface{})
+ newBody, err := t.bodyHandler.handle(host, path, m["body"].([]byte))
+ if err != nil {
+ return err
+ }
+ m["body"] = newBody
+
+ default:
+ return errBodyType
+ }
+
+ return nil
+}
+
+func (t requestTransformer) IsHeaderChange() bool { return t.isHeaderChange }
+func (t requestTransformer) IsQueryChange() bool { return t.isQueryChange }
+func (t requestTransformer) IsBodyChange() bool { return t.isBodyChange }
+
+type responseTransformer struct {
+ headerHandler *kvHandler
+ bodyHandler *responseBodyHandler
+ isHeaderChange bool
+ isBodyChange bool
+}
+
+func newResponseTransformer(config *TransformerConfig) (Transformer, error) {
+ headerKvtGroup, isHeaderChange, err := newKvtGroup(config.rules, "headers")
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to new kvt group for headers")
+ }
+ bodyKvtGroup, isBodyChange, err := newKvtGroup(config.rules, "body")
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to new kvt group for body")
+ }
+ return &responseTransformer{
+ headerHandler: &kvHandler{headerKvtGroup},
+ bodyHandler: &responseBodyHandler{&jsonHandler{bodyKvtGroup}},
+ isHeaderChange: isHeaderChange,
+ isBodyChange: isBodyChange,
+ }, nil
+}
+
+func (t responseTransformer) TransformHeaders(host, path string, hs map[string][]string) error {
+ return t.headerHandler.handle(host, path, hs)
+}
+
+func (t responseTransformer) TransformQuerys(host, path string, qs map[string][]string) error {
+ // the response does not need to transform the query params, always returns nil
+ return nil
+}
+
+func (t responseTransformer) TransformBody(host, path string, body interface{}) error {
+ switch body.(type) {
+ case map[string]interface{}:
+ m := body.(map[string]interface{})
+ newBody, err := t.bodyHandler.handle(host, path, m["body"].([]byte))
+ if err != nil {
+ return err
+ }
+ m["body"] = newBody
+
+ default:
+ return errBodyType
+ }
+
+ return nil
+}
+
+func (t responseTransformer) IsHeaderChange() bool { return t.isHeaderChange }
+func (t responseTransformer) IsQueryChange() bool { return false } // the response does not need to transform the query params, always returns false
+func (t responseTransformer) IsBodyChange() bool { return t.isBodyChange }
+
+type requestBodyHandler struct {
+ formDataHandler *kvHandler
+ *jsonHandler
+}
+
+type responseBodyHandler struct {
+ *jsonHandler
+}
+
+type kvHandler struct {
+ *kvtGroup
+}
+
+type jsonHandler struct {
+ *kvtGroup
+}
+
+func (h kvHandler) handle(host, path string, kvs map[string][]string) error {
+ // order: remove → rename → replace → add → append → map → dedupe
+
+ // remove
+ for _, key := range h.remove {
+ delete(kvs, key)
+ }
+
+ // rename: 若指定 oldKey 不存在则无操作;否则将 oldKey 的值追加给 newKey,并删除 oldKey:value
+ for _, item := range h.rename {
+ oldKey, newKey := item.key, item.value
+ if ovs, ok := kvs[oldKey]; ok {
+ kvs[newKey] = append(kvs[newKey], ovs...)
+ delete(kvs, oldKey)
+ }
+ }
+
+ // replace: 若指定 key 不存在,则无操作;否则替换 value 为 newValue
+ for _, item := range h.replace {
+ key, newValue := item.key, item.value
+ if _, ok := kvs[key]; !ok {
+ continue
+ }
+ if item.reg != nil {
+ newValue = item.reg.matchAndReplace(newValue, host, path)
+ }
+ kvs[item.key] = []string{newValue}
+ }
+
+ // add: 若指定 key 存在则无操作;否则添加 key:value
+ for _, item := range h.add {
+ key, value := item.key, item.value
+ if _, ok := kvs[key]; ok {
+ continue
+ }
+ if item.reg != nil {
+ value = item.reg.matchAndReplace(value, host, path)
+ }
+ kvs[key] = []string{value}
+ }
+
+ // append: 若指定 key 存在,则追加同名 kv;否则相当于添加操作
+ for _, item := range h.append {
+ key, appendValue := item.key, item.value
+ if item.reg != nil {
+ appendValue = item.reg.matchAndReplace(appendValue, host, path)
+ }
+ kvs[key] = append(kvs[key], appendValue)
+ }
+
+ // map: 若指定 fromKey 不存在则无操作;否则将 fromKey 的值映射给 toKey 的值
+ for _, item := range h.map_ {
+ fromKey, toKey := item.key, item.value
+ if vs, ok := kvs[fromKey]; ok {
+ kvs[toKey] = vs
+ }
+ }
+
+ // dedupe: 根据 strategy 去重:RETAIN_UNIQUE 保留所有唯一值,RETAIN_LAST 保留最后一个值,RETAIN_FIRST 保留第一个值 (default)
+ for _, item := range h.dedupe {
+ key, strategy := item.key, item.value
+ switch strings.ToUpper(strategy) {
+ case "RETAIN_UNIQUE":
+ uniSet, uniques := make(map[string]struct{}), make([]string, 0)
+ for _, v := range kvs[key] {
+ if _, ok := uniSet[v]; !ok {
+ uniSet[v] = struct{}{}
+ uniques = append(uniques, v)
+ }
+ }
+ kvs[key] = uniques
+
+ case "RETAIN_LAST":
+ if vs, ok := kvs[key]; ok && len(vs) >= 1 {
+ kvs[key] = vs[len(vs)-1:]
+ }
+
+ case "RETAIN_FIRST":
+ fallthrough
+ default:
+ if vs, ok := kvs[key]; ok && len(vs) >= 1 {
+ kvs[key] = vs[:1]
+ }
+ }
+ }
+
+ return nil
+}
+
+func (h jsonHandler) handle(host, path string, oriData []byte) (data []byte, err error) {
+ // order: remove → rename → replace → add → append → map → dedupe
+ if !gjson.ValidBytes(oriData) {
+ return nil, errors.New("invalid json body")
+ }
+ data = oriData
+
+ // remove
+ for _, key := range h.remove {
+ if data, err = sjson.DeleteBytes(data, key); err != nil {
+ return nil, errors.Wrap(err, errRemove.Error())
+ }
+ }
+
+ // rename: 若指定 oldKey 不存在则无操作;否则将 oldKey 的值追加给 newKey,并删除 oldKey:value
+ for _, item := range h.rename {
+ oldKey, newKey := item.key, item.value
+ value := gjson.GetBytes(data, oldKey)
+ if !value.Exists() {
+ continue
+ }
+ if data, err = sjson.SetBytes(data, newKey, value.Value()); err != nil {
+ return nil, errors.Wrap(err, errRename.Error())
+ }
+ if data, err = sjson.DeleteBytes(data, oldKey); err != nil {
+ return nil, errors.Wrap(err, errRename.Error())
+ }
+ }
+
+ // replace: 若指定 key 不存在,则无操作;否则替换 value 为 newValue
+ for _, item := range h.replace {
+ key, value, valueType := item.key, item.value, item.typ
+ if !gjson.GetBytes(data, key).Exists() {
+ continue
+ }
+ if valueType == "string" && item.reg != nil {
+ value = item.reg.matchAndReplace(value, host, path)
+ }
+ newValue, err := convertByJsonType(valueType, value)
+ if err != nil {
+ return nil, errors.Wrap(err, errReplace.Error())
+ }
+ if data, err = sjson.SetBytes(data, key, newValue); err != nil {
+ return nil, errors.Wrap(err, errReplace.Error())
+ }
+ }
+
+ // add: 若指定 key 存在则无操作;否则添加 key:value
+ for _, item := range h.add {
+ key, value, valueType := item.key, item.value, item.typ
+ if gjson.GetBytes(data, key).Exists() {
+ continue
+ }
+ if valueType == "string" && item.reg != nil {
+ value = item.reg.matchAndReplace(value, host, path)
+ }
+ newValue, err := convertByJsonType(valueType, value)
+ if err != nil {
+ return nil, errors.Wrap(err, errAdd.Error())
+ }
+ if data, err = sjson.SetBytes(data, key, newValue); err != nil {
+ return nil, errors.Wrap(err, errAdd.Error())
+ }
+ }
+
+ // append: 若指定 key 存在,则追加同名 kv;否则相当于添加操作
+ // 当原本的 value 为数组时,追加;当原本的 value 不为数组时,将原本的 value 和 appendValue 组成数组
+ for _, item := range h.append {
+ key, value, valueType := item.key, item.value, item.typ
+ if valueType == "string" && item.reg != nil {
+ value = item.reg.matchAndReplace(value, host, path)
+ }
+ appendValue, err := convertByJsonType(valueType, value)
+ if err != nil {
+ return nil, errors.Wrapf(err, errAppend.Error())
+ }
+ oldValue := gjson.GetBytes(data, key)
+ if !oldValue.Exists() {
+ if data, err = sjson.SetBytes(data, key, appendValue); err != nil { // key: appendValue
+ return nil, errors.Wrap(err, errAppend.Error())
+ }
+ continue
+ }
+
+ // oldValue exists
+ if oldValue.IsArray() {
+ if len(oldValue.Array()) == 0 {
+ if data, err = sjson.SetBytes(data, key, []interface{}{appendValue}); err != nil { // key: [appendValue]
+ return nil, errors.Wrap(err, errAppend.Error())
+ }
+ continue
+ }
+
+ // len(oldValue.Array()) != 0
+ oldValues := make([]interface{}, 0, len(oldValue.Array())+1)
+ for _, val := range oldValue.Array() {
+ oldValues = append(oldValues, val.Value())
+ }
+ if data, err = sjson.SetBytes(data, key, append(oldValues, appendValue)); err != nil { // key: [oldValue..., appendValue]
+ return nil, errors.Wrap(err, errAppend.Error())
+ }
+ continue
+ }
+
+ // oldValue is not array
+ if data, err = sjson.SetBytes(data, key, []interface{}{oldValue.Value(), appendValue}); err != nil { // key: [oldValue, appendValue]
+ return nil, errors.Wrap(err, errAppend.Error())
+ }
+ }
+
+ // map: 若指定 fromKey 不存在则无操作;否则将 fromKey 的值映射给 toKey 的值
+ for _, item := range h.map_ {
+ fromKey, toKey := item.key, item.value
+ fromValue := gjson.GetBytes(data, fromKey)
+ if !fromValue.Exists() {
+ continue
+ }
+ if data, err = sjson.SetBytes(data, toKey, fromValue.Value()); err != nil {
+ return nil, errors.Wrap(err, errMap.Error())
+ }
+ }
+
+ // dedupe: 根据 strategy 去重:RETAIN_UNIQUE 保留所有唯一值,RETAIN_LAST 保留最后一个值,RETAIN_FIRST 保留第一个值 (default)
+ for _, item := range h.dedupe {
+ key, strategy := item.key, item.value
+ value := gjson.GetBytes(data, key)
+ if !value.Exists() || !value.IsArray() {
+ continue
+ }
+
+ // value is array
+ values := value.Array()
+ if len(values) == 0 {
+ continue
+ }
+
+ var dedupedVal interface{}
+ switch strings.ToUpper(strategy) {
+ case "RETAIN_UNIQUE":
+ uniSet, uniques := make(map[string]struct{}), make([]interface{}, 0)
+ for _, v := range values {
+ vstr := v.String()
+ if _, ok := uniSet[vstr]; !ok {
+ uniSet[vstr] = struct{}{}
+ uniques = append(uniques, v.Value())
+ }
+ }
+ if len(uniques) == 1 {
+ dedupedVal = uniques[0] // key: uniques[0]
+ } else if len(uniques) > 1 {
+ dedupedVal = uniques // key: [uniques...]
+ }
+
+ case "RETAIN_LAST":
+ dedupedVal = values[len(values)-1].Value() // key: last
+
+ case "RETAIN_FIRST":
+ fallthrough
+ default:
+ dedupedVal = values[0].Value() // key: first
+ }
+
+ if dedupedVal == nil {
+ continue
+ }
+ if data, err = sjson.SetBytes(data, key, dedupedVal); err != nil {
+ return nil, errors.Wrap(err, errDedupe.Error())
+ }
+ }
+
+ return data, nil
+}
+
+type kvtGroup struct {
+ remove []string // key
+ rename []kvt // oldKey:newKey
+ replace []kvtReg // key:newValue
+ add []kvtReg // newKey:newValue
+ append []kvtReg // key:appendValue
+ map_ []kvt // fromKey:toKey
+ dedupe []kvt // key:strategy
+}
+
+func newKvtGroup(rules []TransformRule, typ string) (g *kvtGroup, isChange bool, err error) {
+ g = &kvtGroup{}
+ for _, r := range rules {
+ var prams []Param
+ switch typ {
+ case "headers":
+ prams = r.headers
+ case "querys":
+ prams = r.querys
+ case "body":
+ prams = r.body
+ }
+
+ for _, p := range prams {
+ switch r.operate {
+ case "remove":
+ key := p.key
+ if typ == "headers" {
+ key = strings.ToLower(key)
+ }
+ g.remove = append(g.remove, key)
+
+ case "rename", "map", "dedupe":
+ var kt kvt
+ kt.key, kt.value = p.key, p.value
+ if typ == "headers" {
+ kt.key = strings.ToLower(kt.key)
+ if r.operate == "rename" || r.operate == "map" {
+ kt.value = strings.ToLower(kt.value)
+ }
+ }
+ if typ == "body" {
+ kt.typ = p.valueType
+ }
+ switch r.operate {
+ case "rename":
+ g.rename = append(g.rename, kt)
+ case "map":
+ g.map_ = append(g.map_, kt)
+ case "dedupe":
+ g.dedupe = append(g.dedupe, kt)
+ }
+
+ case "replace", "add", "append":
+ var kr kvtReg
+ kr.key, kr.value = p.key, p.value
+ if typ == "headers" {
+ kr.key = strings.ToLower(kr.key)
+ }
+ if p.hostPattern != "" || p.pathPattern != "" {
+ kr.reg, err = newReg(p.hostPattern, p.pathPattern)
+ if err != nil {
+ return nil, false, errors.Wrap(err, "failed to new reg")
+ }
+ }
+ if typ == "body" {
+ kr.typ = p.valueType
+ }
+ switch r.operate {
+ case "replace":
+ g.replace = append(g.replace, kr)
+ case "add":
+ g.add = append(g.add, kr)
+ case "append":
+ g.append = append(g.append, kr)
+ }
+ }
+ }
+
+ }
+
+ isChange = len(g.remove) != 0 ||
+ len(g.rename) != 0 || len(g.replace) != 0 ||
+ len(g.add) != 0 || len(g.append) != 0 ||
+ len(g.map_) != 0 || len(g.dedupe) != 0
+
+ return g, isChange, nil
+}
+
+type kvtReg struct {
+ kvt
+ *reg
+}
+
+type kvt struct {
+ key string
+ value string
+ typ string
+}
+
+type reg struct {
+ hostReg *regexp.Regexp
+ pathReg *regexp.Regexp
+}
+
+// you can only choose one between host and path
+func newReg(hostPatten, pathPatten string) (r *reg, err error) {
+ r = ®{}
+ if hostPatten != "" {
+ r.hostReg, err = regexp.Compile(hostPatten)
+ return
+ }
+ if pathPatten != "" {
+ r.pathReg, err = regexp.Compile(pathPatten)
+ return
+ }
+ return
+}
+
+func (r reg) matchAndReplace(value, host, path string) string {
+ if r.hostReg != nil && r.hostReg.MatchString(host) {
+ return r.hostReg.ReplaceAllString(host, value)
+ }
+ if r.pathReg != nil && r.pathReg.MatchString(path) {
+ return r.pathReg.ReplaceAllString(path, value)
+ }
+ return value
+}
diff --git a/plugins/wasm-go/extensions/transformer/utils.go b/plugins/wasm-go/extensions/transformer/utils.go
new file mode 100644
index 000000000..4769230b1
--- /dev/null
+++ b/plugins/wasm-go/extensions/transformer/utils.go
@@ -0,0 +1,258 @@
+// 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 (
+ "bytes"
+ "encoding/json"
+ "io"
+ "mime"
+ "mime/multipart"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "github.com/pkg/errors"
+ "github.com/tidwall/pretty"
+)
+
+const (
+ ContentTypeApplicationJson = "application/json"
+ ContentTypeFormUrlencoded = "application/x-www-form-urlencoded"
+ ContentTypeMultipartForm = "multipart/form-data"
+)
+
+var (
+ errGetRequestHost = errors.New("failed to get request host")
+ errGetRequestPath = errors.New("failed to get request path")
+ errEmptyBody = errors.New("body is empty")
+ errBodyType = errors.New("unsupported body type")
+ errGetContentType = errors.New("failed to get content-type from http context")
+ errRemove = errors.New("failed to remove")
+ errRename = errors.New("failed to rename")
+ errReplace = errors.New("failed to replace")
+ errAdd = errors.New("failed to add")
+ errAppend = errors.New("failed to append")
+ errMap = errors.New("failed to map")
+ errDedupe = errors.New("failed to dedupe")
+ errContentTypeFmt = "unsupported content-type: %s"
+)
+
+func isValidOperation(op string) bool {
+ switch op {
+ case "remove", "rename", "replace", "add", "append", "map", "dedupe":
+ return true
+ default:
+ return false
+ }
+}
+
+func parseQueryByPath(path string) (map[string][]string, error) {
+ u, err := url.Parse(path)
+ if err != nil {
+ return nil, err
+ }
+
+ qs := make(map[string][]string)
+ for k, vs := range u.Query() {
+ qs[k] = vs
+ }
+ return qs, nil
+}
+
+func constructPath(path string, qs map[string][]string) (string, error) {
+ u, err := url.Parse(path)
+ if err != nil {
+ return path, err
+ }
+
+ query := url.Values{}
+ for k, vs := range qs {
+ for _, v := range vs {
+ query.Add(k, v)
+ }
+ }
+ u.RawQuery = query.Encode()
+ return u.String(), nil
+}
+
+// 返回值为 map[string]interface{} 或 map[string][]string,使用时断言即可
+func parseBody(contentType string, body []byte) (interface{}, error) {
+ if len(body) == 0 {
+ return nil, errEmptyBody
+ }
+
+ typ, params, err := mime.ParseMediaType(contentType)
+ if err != nil {
+ return nil, err
+ }
+ switch typ {
+ case ContentTypeApplicationJson:
+ return map[string]interface{}{"body": body}, nil
+
+ case ContentTypeFormUrlencoded:
+ ret := make(map[string][]string)
+ kvs, err := url.ParseQuery(string(body))
+ if err != nil {
+ return nil, err
+ }
+ for k, vs := range kvs {
+ ret[k] = vs
+ }
+ return ret, nil
+
+ case ContentTypeMultipartForm:
+ ret := make(map[string][]string)
+ mr := multipart.NewReader(bytes.NewReader(body), params["boundary"])
+ for {
+ p, err := mr.NextPart()
+ if err != nil {
+ if err == io.EOF {
+ break
+ }
+ return nil, err
+ }
+ formName := p.FormName()
+ fileName := p.FileName()
+ if formName == "" || fileName != "" {
+ continue
+ }
+ formValue, err := io.ReadAll(p)
+ if err != nil {
+ return nil, err
+ }
+ ret[formName] = append(ret[formName], string(formValue))
+ }
+ return ret, nil
+
+ default:
+ return nil, errors.Errorf(errContentTypeFmt, contentType)
+ }
+}
+
+func constructBody(contentType string, body interface{}) ([]byte, error) {
+ typ, params, err := mime.ParseMediaType(contentType)
+ if err != nil {
+ return nil, err
+ }
+ switch typ {
+ case ContentTypeApplicationJson:
+ bd, ok := body.(map[string]interface{})["body"].([]byte)
+ if !ok {
+ return nil, errBodyType
+ }
+ return pretty.Pretty(bd), nil
+
+ case ContentTypeFormUrlencoded:
+ bd, ok := body.(map[string][]string)
+ if !ok {
+ return nil, errBodyType
+ }
+ query := url.Values{}
+ for k, vs := range bd {
+ for _, v := range vs {
+ query.Add(k, v)
+ }
+ }
+ return []byte(query.Encode()), nil
+
+ case ContentTypeMultipartForm:
+ bd, ok := body.(map[string][]string)
+ if !ok {
+ return nil, errBodyType
+ }
+ buf := new(bytes.Buffer)
+ w := multipart.NewWriter(buf)
+ if err = w.SetBoundary(params["boundary"]); err != nil {
+ return nil, err
+ }
+ for k, vs := range bd {
+ for _, v := range vs {
+ if err = w.WriteField(k, v); err != nil {
+ return nil, err
+ }
+ }
+ }
+ if err = w.Close(); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+
+ default:
+ return nil, errors.Errorf(errContentTypeFmt, contentType)
+ }
+}
+
+func isValidRequestContentType(contentType string) bool {
+ typ, _, err := mime.ParseMediaType(contentType)
+ if err != nil {
+ return false
+ }
+ return typ == ContentTypeApplicationJson || typ == ContentTypeFormUrlencoded || typ == ContentTypeMultipartForm
+}
+
+func isValidResponseContentType(contentType string) bool {
+ typ, _, err := mime.ParseMediaType(contentType)
+ if err != nil {
+ return false
+ }
+ return typ == ContentTypeApplicationJson
+}
+
+func convertByJsonType(typ string, value string) (ret interface{}, err error) {
+ switch strings.ToLower(typ) {
+ case "object":
+ err = json.Unmarshal([]byte(value), &ret)
+ case "boolean":
+ ret, err = strconv.ParseBool(value)
+ case "number":
+ ret, err = strconv.ParseFloat(value, 64)
+ case "string":
+ fallthrough
+ default:
+ ret = value
+ }
+ return
+}
+
+func isValidJsonType(typ string) bool {
+ switch typ {
+ case "object", "boolean", "number", "string":
+ return true
+ default:
+ return false
+ }
+}
+
+// headers: [][2]string -> map[string][]string
+func convertHeaders(hs [][2]string) map[string][]string {
+ ret := make(map[string][]string)
+ for _, h := range hs {
+ k, v := strings.ToLower(h[0]), h[1]
+ ret[k] = append(ret[k], v)
+ }
+ return ret
+}
+
+// headers: map[string][]string -> [][2]string
+func reconvertHeaders(hs map[string][]string) [][2]string {
+ var ret [][2]string
+ for k, vs := range hs {
+ for _, v := range vs {
+ ret = append(ret, [2]string{k, v})
+ }
+ }
+ return ret
+}
diff --git a/plugins/wasm-go/extensions/transformer/utils_test.go b/plugins/wasm-go/extensions/transformer/utils_test.go
new file mode 100644
index 000000000..88dfec490
--- /dev/null
+++ b/plugins/wasm-go/extensions/transformer/utils_test.go
@@ -0,0 +1,328 @@
+// 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"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestParseQueryByPath(t *testing.T) {
+ cases := []struct {
+ name string
+ path string
+ expected map[string][]string
+ errMsg string
+ }{
+ {
+ name: "common",
+ path: "/get?k1=v1&k2=v2&k3=v3",
+ expected: map[string][]string{
+ "k1": {"v1"},
+ "k2": {"v2"},
+ "k3": {"v3"},
+ },
+ },
+ {
+ name: "empty query",
+ path: "www.example.com/get",
+ expected: map[string][]string{},
+ },
+ {
+ name: "multiple values",
+ path: "www.example.com/get?k1=v11&k1=v12&k2=v2&k1=v13",
+ expected: map[string][]string{
+ "k1": {"v11", "v12", "v13"},
+ "k2": {"v2"},
+ },
+ },
+ {
+ name: "encoded url",
+ path: "/get%20with%3Freserved%20characters?key=Hello+World",
+ expected: map[string][]string{
+ "key": {"Hello World"},
+ },
+ },
+ }
+
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ actual, err := parseQueryByPath(c.path)
+ if c.errMsg != "" {
+ require.EqualError(t, err, c.errMsg)
+ return
+ }
+ require.NoError(t, err)
+ require.Equal(t, c.expected, actual)
+ })
+ }
+}
+
+func TestConstructPath(t *testing.T) {
+ cases := []struct {
+ name string
+ path string
+ qs map[string][]string
+ expected string
+ errMsg string
+ }{
+ {
+ name: "common",
+ path: "/get",
+ qs: map[string][]string{
+ "k1": {"v1"},
+ "k2": {"v2"},
+ "k3": {"v3"},
+ },
+ expected: "/get?k1=v1&k2=v2&k3=v3",
+ },
+ {
+ name: "empty query",
+ path: "www.example.com/get",
+ qs: map[string][]string{},
+ expected: "www.example.com/get",
+ },
+ {
+ name: "encoded url",
+ path: "/get with?",
+ qs: map[string][]string{
+ "key": {"Hello World"},
+ },
+ expected: "/get%20with?key=Hello+World",
+ },
+ }
+
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ actual, err := constructPath(c.path, c.qs)
+ if c.errMsg != "" {
+ require.EqualError(t, err, c.errMsg)
+ return
+ }
+ require.NoError(t, err)
+ require.Equal(t, c.expected, actual)
+ })
+ }
+}
+
+func TestParseBody(t *testing.T) {
+ cases := []struct {
+ name string
+ mediaType string
+ body []byte
+ expected interface{}
+ errMsg string
+ }{
+ {
+ name: "application/json",
+ mediaType: "application/json",
+ body: []byte(`{
+ "k1": "v2",
+ "k2": 20,
+ "k3": true,
+ "k4": [1, 2, 3],
+ "k5": {
+ "k6": "v6"
+ }
+}`),
+ expected: map[string]interface{}{"body": []byte(`{
+ "k1": "v2",
+ "k2": 20,
+ "k3": true,
+ "k4": [1, 2, 3],
+ "k5": {
+ "k6": "v6"
+ }
+}`)},
+ },
+ {
+ name: "application/x-www-form-urlencoded",
+ mediaType: "application/x-www-form-urlencoded",
+ body: []byte("k1=v11&k1=v12&k2=v2&k3=v3"),
+ expected: map[string][]string{
+ "k1": {"v11", "v12"},
+ "k2": {"v2"},
+ "k3": {"v3"},
+ },
+ },
+ {
+ name: "multipart/form-data",
+ mediaType: "multipart/form-data; boundary=--------------------------962785348548682888818907",
+ body: []byte("----------------------------962785348548682888818907\r\nContent-Disposition: form-data; name=\"k1\"\r\n\r\nv11\r\n----------------------------962785348548682888818907\r\nContent-Disposition: form-data; name=\"k1\"\r\n\r\nv12\r\n----------------------------962785348548682888818907\r\nContent-Disposition: form-data; name=\"k2\"\r\n\r\nv2\r\n----------------------------962785348548682888818907--\r\n"),
+ expected: map[string][]string{
+ "k1": {"v11", "v12"},
+ "k2": {"v2"},
+ },
+ },
+ {
+ name: "unsupported content type",
+ mediaType: "plain/text",
+ body: []byte(`qwe`),
+ errMsg: fmt.Sprintf(errContentTypeFmt, "plain/text"),
+ },
+ {
+ name: "empty body",
+ mediaType: "application/json",
+ body: []byte(``),
+ errMsg: errEmptyBody.Error(),
+ },
+ }
+
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ actual, err := parseBody(c.mediaType, c.body)
+ if c.errMsg != "" {
+ require.EqualError(t, err, c.errMsg)
+ return
+ }
+ require.NoError(t, err)
+ require.Equal(t, c.expected, actual)
+ })
+ }
+}
+
+func TestConstructBody(t *testing.T) {
+ cases := []struct {
+ name string
+ mediaType string
+ body interface{}
+ expected []byte
+ errMsg string
+ }{
+ {
+ name: "application/json",
+ mediaType: "application/json",
+ body: map[string]interface{}{"body": []byte(`{
+ "k1": {
+ "k2": [1, 2, 3]
+ }
+}
+`)},
+ expected: []byte(`{
+ "k1": {
+ "k2": [1, 2, 3]
+ }
+}
+`),
+ },
+ {
+ name: "application/x-www-form-urlencoded",
+ mediaType: "application/x-www-form-urlencoded",
+ body: map[string][]string{
+ "k1": {"v11", "v12"},
+ },
+ expected: []byte("k1=v11&k1=v12"),
+ },
+ {
+ name: "multipart/form-data",
+ mediaType: "multipart/form-data; boundary=--------------------------962785348548682888818907",
+ body: map[string][]string{
+ "k1": {"v11", "v12"},
+ },
+ expected: []byte("----------------------------962785348548682888818907\r\nContent-Disposition: form-data; name=\"k1\"\r\n\r\nv11\r\n----------------------------962785348548682888818907\r\nContent-Disposition: form-data; name=\"k1\"\r\n\r\nv12\r\n----------------------------962785348548682888818907--\r\n"),
+ },
+ {
+ name: "unsupported media type",
+ mediaType: "plain/text",
+ body: []byte(`qwe`),
+ errMsg: fmt.Sprintf(errContentTypeFmt, "plain/text"),
+ },
+ {
+ name: "empty body",
+ mediaType: "application/json",
+ body: map[string]interface{}{"body": []byte{}},
+ expected: []byte{},
+ },
+ }
+
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ actual, err := constructBody(c.mediaType, c.body)
+ if c.errMsg != "" {
+ require.EqualError(t, err, c.errMsg)
+ return
+ }
+ require.NoError(t, err)
+ require.Equal(t, c.expected, actual)
+ })
+ }
+}
+
+func TestConvertByJsonType(t *testing.T) {
+ cases := []struct {
+ name string
+ valueTyp string
+ value string
+ expected interface{}
+ errMsg string
+ }{
+ {
+ name: "object",
+ valueTyp: "object",
+ value: "{\"array\": [1, 2, 3], \"object\": { \"first\": \"hello\", \"second\": \"world\" } }",
+ expected: map[string]interface{}{
+ "array": []interface{}{float64(1), float64(2), float64(3)},
+ "object": map[string]interface{}{
+ "first": "hello",
+ "second": "world",
+ },
+ },
+ },
+ {
+ name: "boolean",
+ valueTyp: "boolean",
+ value: "true",
+ expected: true,
+ },
+ {
+ name: "boolean: failed",
+ valueTyp: "boolean",
+ value: "null",
+ errMsg: "strconv.ParseBool: parsing \"null\": invalid syntax",
+ },
+ {
+ name: "number",
+ valueTyp: "number",
+ value: "10",
+ expected: float64(10),
+ },
+ {
+ name: "string",
+ valueTyp: "string",
+ value: "hello world",
+ expected: "hello world",
+ },
+ {
+ name: "unsupported type",
+ valueTyp: "integer",
+ value: "10",
+ expected: "10", // default string
+ },
+ }
+
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ actual, err := convertByJsonType(c.valueTyp, c.value)
+ if c.errMsg != "" {
+ require.EqualError(t, err, c.errMsg)
+ return
+ }
+ require.NoError(t, err)
+ require.Equal(t, c.expected, actual)
+ })
+ }
+}
diff --git a/test/e2e/conformance/tests/go-wasm-transformer.go b/test/e2e/conformance/tests/go-wasm-transformer.go
new file mode 100644
index 000000000..74a6788f2
--- /dev/null
+++ b/test/e2e/conformance/tests/go-wasm-transformer.go
@@ -0,0 +1,121 @@
+// 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 tests
+
+import (
+ "testing"
+
+ "github.com/alibaba/higress/test/e2e/conformance/utils/http"
+ "github.com/alibaba/higress/test/e2e/conformance/utils/suite"
+)
+
+func init() {
+ Register(WasmPluginsTransformer)
+}
+
+// TODO(WeixinX): Request and response body conformance check is not supported now
+var WasmPluginsTransformer = suite.ConformanceTest{
+ ShortName: "WasmPluginTransformer",
+ Description: "The Ingress in the higress-conformance-infra namespace test the transformer WASM plugin.",
+ Features: []suite.SupportedFeature{suite.WASMGoConformanceFeature},
+ Manifests: []string{"tests/go-wasm-transformer.yaml"},
+ Test: func(t *testing.T, suite *suite.ConformanceTestSuite) {
+ testcases := []http.Assertion{
+ {
+ Meta: http.AssertionMeta{
+ TestCaseName: "case 1: request transformer",
+ TargetBackend: "infra-backend-v1",
+ TargetNamespace: "higress-conformance-infra",
+ },
+ Request: http.AssertionRequest{
+ ActualRequest: http.Request{
+ Host: "foo1.com",
+ Path: "/get?k1=v11&k1=v12&k2=v2",
+ Headers: map[string]string{
+ "X-remove": "exist",
+ "X-not-renamed": "test",
+ "X-replace": "not-replaced",
+ "X-dedupe-first": "1,2,3",
+ "X-dedupe-last": "a,b,c",
+ "X-dedupe-unique": "1,2,3,3,2,1",
+ },
+ },
+ ExpectedRequest: &http.ExpectedRequest{
+ Request: http.Request{
+ Host: "foo1.com",
+ Path: "/get?k2-new=v2-new&k3=v31&k3=v32&k4=v31", // url.Value.Encode() is ordered by key
+ Headers: map[string]string{
+ "X-renamed": "test",
+ "X-replace": "replaced",
+ "X-add-append": "add,append", // header with same name
+ "X-map": "add,append",
+ "X-dedupe-first": "1",
+ "X-dedupe-last": "c",
+ "X-dedupe-unique": "1,2,3",
+ },
+ },
+ AbsentHeaders: []string{"X-remove"},
+ },
+ },
+ Response: http.AssertionResponse{
+ ExpectedResponse: http.Response{
+ StatusCode: 200,
+ },
+ },
+ },
+ {
+ Meta: http.AssertionMeta{
+ TestCaseName: "case 2: response transformer",
+ TargetBackend: "infra-backend-v1",
+ TargetNamespace: "higress-conformance-infra",
+ },
+ Request: http.AssertionRequest{
+ ActualRequest: http.Request{
+ Host: "foo2.com",
+ Path: "/get/index.html",
+ },
+ ExpectedRequest: &http.ExpectedRequest{
+ Request: http.Request{
+ Host: "foo2.com",
+ Path: "/get/index.html",
+ },
+ },
+ },
+ Response: http.AssertionResponse{
+ AdditionalResponseHeaders: map[string]string{
+ "X-remove": "exist",
+ "X-not-renamed": "test",
+ "X-replace": "not-replaced",
+ },
+ ExpectedResponse: http.Response{
+ StatusCode: 200,
+ Headers: map[string]string{
+ "X-renamed": "test",
+ "X-replace": "replace-get", // regexp matches path and replace "replace-$1"
+ "X-add-append": "add-foo2,append-index", // regexp matches host and replace "add-$1"
+ "X-map": "add-foo2,append-index",
+ },
+ AbsentHeaders: []string{"X-remove"},
+ },
+ },
+ },
+ }
+ t.Run("WasmPlugin transformer", func(t *testing.T) {
+ for _, testcase := range testcases {
+ http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, suite.GatewayAddress, testcase)
+ }
+ })
+ },
+}
diff --git a/test/e2e/conformance/tests/go-wasm-transformer.yaml b/test/e2e/conformance/tests/go-wasm-transformer.yaml
new file mode 100644
index 000000000..51e56cf46
--- /dev/null
+++ b/test/e2e/conformance/tests/go-wasm-transformer.yaml
@@ -0,0 +1,155 @@
+# 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.
+
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ annotations:
+ name: wasmplugin-transform-request
+ namespace: higress-conformance-infra
+spec:
+ ingressClassName: higress
+ rules:
+ - host: "foo1.com"
+ http:
+ paths:
+ - pathType: Prefix
+ path: "/"
+ backend:
+ service:
+ name: infra-backend-v1
+ port:
+ number: 8080
+---
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ annotations:
+ name: wasmplugin-transform-response
+ namespace: higress-conformance-infra
+spec:
+ ingressClassName: higress
+ rules:
+ - host: "foo2.com"
+ http:
+ paths:
+ - pathType: Prefix
+ path: "/"
+ backend:
+ service:
+ name: infra-backend-v1
+ port:
+ number: 8080
+---
+apiVersion: extensions.higress.io/v1alpha1
+kind: WasmPlugin
+metadata:
+ name: transformer
+ namespace: higress-system
+spec:
+ matchRules:
+ # request transformer
+ - ingress:
+ - higress-conformance-infra/wasmplugin-transform-request
+ configDisable: false
+ config:
+ type: request
+ rules:
+ - operate: remove
+ headers:
+ - key: X-remove
+ querys:
+ - key: k1
+ - operate: rename
+ headers:
+ - key: X-not-renamed
+ value: X-renamed
+ querys:
+ - key: k2
+ value: k2-new
+ - operate: replace
+ headers:
+ - key: X-replace
+ value: replaced
+ querys:
+ - key: k2-new
+ value: v2-new
+ - operate: add
+ headers:
+ - key: X-add-append
+ value: add
+ querys:
+ - key: k3
+ value: v31
+ - operate: append
+ headers:
+ - key: X-add-append
+ value: append
+ querys:
+ - key: k3
+ value: v32
+ - operate: map
+ headers:
+ - key: X-add-append
+ value: X-map
+ querys:
+ - key: k3
+ value: k4
+ - operate: dedupe
+ headers:
+ - key: X-dedupe-first
+ value: RETAIN_FIRST
+ - key: X-dedupe-last
+ value: RETAIN_LAST
+ - key: X-dedupe-unique
+ value: RETAIN_UNIQUE
+ querys:
+ - key: k4
+ value: RETAIN_FIRST
+
+ # response transformer
+ - ingress:
+ - higress-conformance-infra/wasmplugin-transform-response
+ configDisable: false
+ config:
+ type: response
+ rules:
+ - operate: remove
+ headers:
+ - key: X-remove
+ - operate: rename
+ headers:
+ - key: X-not-renamed
+ value: X-renamed
+ - operate: replace
+ headers:
+ - key: X-replace
+ value: replace-$1
+ path_pattern: ^.*?\/(\w+)[\?]{0,1}.*$
+ - operate: add
+ headers:
+ - key: X-add-append
+ value: add-$1
+ host_pattern: ^(.*)\.com$
+ - operate: append
+ headers:
+ - key: X-add-append
+ value: append-$1
+ path_pattern: ^\/get\/(.*)\.html$
+ - operate: map
+ headers:
+ - key: X-add-append
+ value: X-map
+
+ url: file:///opt/plugins/wasm-go/extensions/transformer/plugin.wasm
\ No newline at end of file
diff --git a/test/e2e/conformance/utils/http/http.go b/test/e2e/conformance/utils/http/http.go
index 84f4935d7..8a3d8b5e5 100644
--- a/test/e2e/conformance/utils/http/http.go
+++ b/test/e2e/conformance/utils/http/http.go
@@ -189,7 +189,10 @@ func MakeRequestAndExpectEventuallyConsistentResponse(t *testing.T, r roundtripp
if expected.Request.ActualRequest.Headers != nil {
for name, value := range expected.Request.ActualRequest.Headers {
- req.Headers[name] = []string{value}
+ vals := strings.Split(value, ",")
+ for _, val := range vals {
+ req.Headers[name] = append(req.Headers[name], strings.TrimSpace(val))
+ }
}
}
diff --git a/test/e2e/conformance/utils/roundtripper/roundtripper.go b/test/e2e/conformance/utils/roundtripper/roundtripper.go
index e1d96e052..1ff6c02d5 100644
--- a/test/e2e/conformance/utils/roundtripper/roundtripper.go
+++ b/test/e2e/conformance/utils/roundtripper/roundtripper.go
@@ -164,8 +164,10 @@ func (d *DefaultRoundTripper) CaptureRoundTrip(request Request) (*CapturedReques
}
if request.Headers != nil {
- for name, value := range request.Headers {
- req.Header.Set(name, value[0])
+ for name, values := range request.Headers {
+ for _, value := range values {
+ req.Header.Add(name, value)
+ }
}
}