feat: Add response-cache plugin (#3061)

Co-authored-by: mirror58229 <674958229@qq.com>
This commit is contained in:
Jingze
2025-12-26 17:22:03 +08:00
committed by jingze
parent 659d136bfe
commit f342f50ca4
11 changed files with 1649 additions and 0 deletions

View File

@@ -0,0 +1,131 @@
## 简介
---
title: 通用响应缓存
keywords: [higress,response cache]
description: 通用响应缓存插件配置参考
---
## 功能说明
通用响应缓存插件,支持从请求头/请求体中提取key从响应体中提取value并缓存起来下次请求时如果请求头/请求体中携带了相同的key则直接返回缓存中的value而不会请求后端服务。
**提示**
携带请求头`x-higress-skip-response-cache: on`时,当前请求将不会使用缓存中的内容,而是直接转发给后端服务,同时也不会缓存该请求返回响应的内容
## 运行属性
插件执行阶段:`认证阶段`
插件执行优先级:`10`
## 配置说明
配置包括 缓存数据库cache配置部分以及配置缓存内容部分
## 配置说明
## 缓存服务cache
| cache.type | string | required | "" | 缓存服务类型,例如 redis |
| --- | --- | --- | --- | --- |
| cache.serviceName | string | required | "" | 缓存服务名称 |
| cache.serviceHost | string | required | "" | 缓存服务域名 |
| cache.servicePort | int64 | optional | 6379 | 缓存服务端口 |
| cache.username | string | optional | "" | 缓存服务用户名 |
| cache.password | string | optional | "" | 缓存服务密码 |
| cache.timeout | uint32 | optional | 10000 | 缓存服务的超时时间单位为毫秒。默认值是10000即10秒 |
| cache.cacheTTL | int | optional | 0 | 缓存过期时间,单位为秒。默认值是 0即 永不过期|
| cacheKeyPrefix | string | optional | "higress-response-cache:" | 缓存 Key 的前缀,默认值为 "higress-response-cache:" |
## 其他配置
| Name | Type | Requirement | Default | Description |
| --- | --- | --- | --- | --- |
| cacheResponseCode | array of number | optional | 200 | 表示支持缓存的响应状态码列表默认为200|
| cacheKeyFromHeader | string | optional | "" | 表示提取header中的固定字段的值作为缓存key配置此项时会从请求头提取key不会读取请求bodycacheKeyFromHeader和cacheKeyFromBody**非空情况下只支持配置一项**|
| cacheKeyFromBody | string | optional | "" | 配置为空时表示提取所有body作为缓存key否则按JSON响应格式从请求 Body 中基于 [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) 语法提取字符串仅在cacheKeyFromHeader为空或未配置时生效 |
| cacheValueFromBodyType | string | optional | "application/json" | 表示缓存body的类型命中cache时content-type会返回该值默认为"application/json" |
| cacheValueFromBody | string | optional | "" | 配置为空时表示缓存所有body当cacheValueFromBodyType为"application/json"时,支持从响应 Body 中基于 [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) 语法提取字符串 |
其中缓存key的拼接逻辑为以下中一个
1. `cacheKeyPrefix` + 从请求头中`cacheKeyFromHeader`对应字段提取的内容
2. `cacheKeyPrefix` + 从请求体中`cacheKeyFromBody`对应字段提取的内容
**注意**`cacheKeyFromHeader``cacheKeyFromBody` 不能同时配置(非空情况下只支持配置一项)。如果同时配置,插件在配置解析阶段会报错。
命中缓存插件的情况下,返回的响应头中有三种状态:
- `x-cache-status: hit` ,表示命中缓存,直接返回缓存内容
- `x-cache-status: miss` ,表示未命中缓存,返回后端响应结果
- `x-cache-status: skip` ,表示跳过缓存检查
## 配置示例
### 基础配置
```yaml
cache:
type: redis
serviceName: my-redis.dns
servicePort: 6379
timeout: 2000
cacheKeyFromHeader: "x-http-cache-key"
cacheValueFromBodyType: "application/json"
cacheValueFromBody: "messages.@reverse.0.content"
```
假设请求为
```bash
# Request
curl -H "x-http-cache-key: abcd" <url>
# Response
{"messages":[{"content":"1"}, {"content":"2"}, {"content":"3"}]}
```
则缓存的key为`higress-response-cache:abcd`缓存的value为`3`
后续请求命中缓存时响应Content-type返回为 `application/json`
### 响应body作为value
如果缓存所有响应body则可以配置为
```yaml
cacheValueFromBodyType: "text/html"
cacheValueFromBody: ""
```
后续请求命中缓存时响应Content-type返回为 `text/html`
### 请求body作为key
使用请求body作为key则可以配置为
```yaml
cacheKeyFromBody: ""
```
配置支持GJSON PATH语法。
## 进阶用法
Body为`application/json`时,支持基于 GJSON PATH 语法:
比如表达式:`messages.@reverse.0.content` ,含义是把 messages 数组反转后取第一项的 content
GJSON PATH 也支持条件判断语法,例如希望取最后一个 role 为 user 的 content 作为 key可以写成 `messages.@reverse.#(role=="user").content`
如果希望将所有 role 为 user 的 content 拼成一个数组作为 key可以写成`messages.@reverse.#(role=="user")#.content`
还可以支持管道语法,例如希望取到数第二个 role 为 user 的 content 作为 key可以写成`messages.@reverse.#(role=="user")#.content|1`
更多用法可以参考[官方文档](https://github.com/tidwall/gjson/blob/master/SYNTAX.md),可以使用 [GJSON Playground](https://gjson.dev/) 进行语法测试。
## 常见问题
1. 如果返回的错误为 `error status returned by host: bad argument`,请检查:
- `serviceName`是否正确包含了服务的类型后缀(.dns等)
- `servicePort`配置是否正确,尤其是 `static` 类型的服务端口现在固定为 80

View File

@@ -0,0 +1,121 @@
---
title: Response Cache
keywords: [higress,response cache]
description: Response Cache Plugin Configuration Reference
---
## Function Description
Response caching plugin supports extracting keys from request headers/request bodies and caching values extracted from response bodies. On subsequent requests, if the request headers/request bodies contain the same key, it directly returns the cached value without forwarding the request to the backend service.
**Hint**
When carrying the request header `x-higress-skip-response-cache: on`, the current request will not use content from the cache but will be directly forwarded to the backend service. Additionally, the response content from this request will not be cached.
## Runtime Properties
Plugin Execution Phase: `Authentication Phase`
Plugin Execution Priority: `10`
## Configuration Description
### Cache Service (cache)
| Property | Type | Requirement | Default | Description |
| --- | --- | --- | --- | --- |
| cache.type | string | required | "" | Cache service type, e.g., redis |
| cache.serviceName | string | required | "" | Cache service name |
| cache.serviceHost | string | required | "" | Cache service domain |
| cache.servicePort | int64 | optional | 6379 | Cache service port |
| cache.username | string | optional | "" | Cache service username |
| cache.password | string | optional | "" | Cache service password |
| cache.timeout | uint32 | optional | 10000 | Timeout for cache service in milliseconds. Default is 10000, i.e., 10 seconds |
| cache.cacheTTL | int | optional | 0 | Cache expiration time in seconds. Default is 0, meaning never expires |
| cacheKeyPrefix | string | optional | "higress-response-cache:" | Prefix for cache keys, default is "higress-response-cache:" | |
### Other Configurations
| Name | Type | Requirement | Default | Description |
| --- | --- | --- | --- | --- |
| cacheResponseCode | array of number | optional | 200 | Indicates the list of response status codes that support caching; the default is 200.|
| cacheKeyFromHeader | string | optional | "" | Extracts a fixed field's value from headers as the cache key; when configured, extracts key from request headers without reading the request body; **only one of cacheKeyFromHeader and cacheKeyFromBody can be configured when both are non-empty**|
| cacheKeyFromBody | string | optional | "" | If empty, extracts all body as the cache key; otherwise, extracts a string from the request body in JSON format based on [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md); only takes effect when cacheKeyFromHeader is empty or not configured |
| cacheValueFromBodyType | string | optional | "application/json" | Indicates the type of cached body; the content-type returned on cache hit will be this value; default is "application/json" |
| cacheValueFromBody | string | optional | "" | If empty, caches all body; when cacheValueFromBodyType is "application/json", supports extracting a string from the response body based on [GJSON PATH](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) |
The logic for concatenating the cache key is one of the following:
1. `cacheKeyPrefix` + content extracted from the field corresponding to `cacheKeyFromHeader` in the request header
2. `cacheKeyPrefix` + content extracted from the field corresponding to `cacheKeyFromBody` in the request body
**Note**: `cacheKeyFromHeader` and `cacheKeyFromBody` cannot be configured at the same time (only one of them can be configured when both are non-empty). If both are configured, the plugin will return an error during the configuration parsing phase.
In the case of hitting the cache plugin, there are three statuses in the returned response headers:
- `x-cache-status: hit` , indicating a cache hit and cached content is returned directly
- `x-cache-status: miss` , indicating a cache miss and backend response results are returned
- `x-cache-status: skip` , indicating skipping the cache check
## Configuration Example
### Basic Configuration
```yaml
cache:
type: redis
serviceName: my-redis.dns
servicePort: 6379
timeout: 2000
cacheKeyFromHeader: "x-http-cache-key"
cacheValueFromBodyType: "application/json"
cacheValueFromBody: "messages.@reverse.0.content"
```
Assumed Request
```bash
# Request
curl -H "x-http-cache-key: abcd" <url>
# Response
{"messages":[{"content":"1"}, {"content":"2"}, {"content":"3"}]}
```
In this case, the cache key would be `higress-response-cache:abcd`, and the cached value would be `3`.
For subsequent requests that hit the cache, the response Content-Type returned is `application/json`.
### Response Body as Cache Value
To cache all response bodies, configure as follows:
```yaml
cacheValueFromBodyType: "text/html"
cacheValueFromBody: ""
```
For subsequent requests that hit the cache, the response Content-Type returned is `text/html`.
### Request Body as Cache Key
To use the request body as the key, configure as follows:
```yaml
cacheKeyFromBody: ""
```
The configuration supports GJSON PATH syntax.
## Advanced Usage
When the body is `application/json`, GJSON PATH syntax is supported:
For example, the expression `messages.@reverse.0.content` means taking the content of the first item after reversing the messages array.
GJSON PATH also supports conditional syntax. For instance, to take the content of the last message where role is "user", you can write: `messages.@reverse.#(role=="user").content`.
To concatenate all contents where role is "user" into an array, you can write: `messages.@reverse.#(role=="user")#.content`.
Pipeline syntax is also supported. For example, to take the second content where role is "user", you can write: `messages.@reverse.#(role=="user")#.content|1`.
Refer to the [official documentation](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) for more usage examples, and test the syntax using the [GJSON Playground](https://gjson.dev/).
## Common Issues
If the error `error status returned by host: bad argument` occurs, check:
- Whether `serviceName` correctly includes the service type suffix (.dns, etc.)
- Whether `servicePort` is configured correctly, especially that `static` type services now use a fixed port of 80

View File

@@ -0,0 +1,127 @@
package cache
import (
"errors"
"strings"
"github.com/higress-group/wasm-go/pkg/wrapper"
"github.com/tidwall/gjson"
)
const (
PROVIDER_TYPE_REDIS = "redis"
DEFAULT_CACHE_PREFIX = "higress-resp-cache:"
)
type providerInitializer interface {
ValidateConfig(ProviderConfig) error
CreateProvider(ProviderConfig) (Provider, error)
}
var (
providerInitializers = map[string]providerInitializer{
PROVIDER_TYPE_REDIS: &redisProviderInitializer{},
}
)
type ProviderConfig struct {
// @Title zh-CN redis 缓存服务提供者类型
// @Description zh-CN 缓存服务提供者类型,例如 redis
typ string
// @Title zh-CN redis 缓存服务名称
// @Description zh-CN 缓存服务名称
serviceName string
// @Title zh-CN redis 缓存服务端口
// @Description zh-CN 缓存服务端口默认值为6379
servicePort int
// @Title zh-CN redis 缓存服务地址
// @Description zh-CN Cache 缓存服务地址,非必填
serviceHost string
// @Title zh-CN 缓存服务用户名
// @Description zh-CN 缓存服务用户名,非必填
username string
// @Title zh-CN 缓存服务密码
// @Description zh-CN 缓存服务密码,非必填
password string
// @Title zh-CN 请求超时
// @Description zh-CN 请求缓存服务的超时时间单位为毫秒。默认值是10000即10秒
timeout uint32
// @Title zh-CN 缓存过期时间
// @Description zh-CN 缓存过期时间单位为秒。默认值是0即永不过期
cacheTTL int
// @Title 缓存 Key 前缀
// @Description 缓存 Key 的前缀,默认值为 "higress-resp-cache:"
cacheKeyPrefix string
}
func (c *ProviderConfig) GetProviderType() string {
return c.typ
}
func (c *ProviderConfig) FromJson(json gjson.Result) {
c.typ = json.Get("type").String()
c.serviceName = json.Get("serviceName").String()
c.servicePort = int(json.Get("servicePort").Int())
if !json.Get("servicePort").Exists() {
if strings.HasSuffix(c.serviceName, ".static") {
// use default logic port which is 80 for static service
c.servicePort = 80
} else {
c.servicePort = 6379
}
}
c.serviceHost = json.Get("serviceHost").String()
c.username = json.Get("username").String()
c.password = json.Get("password").String()
c.timeout = uint32(json.Get("timeout").Int())
if !json.Get("timeout").Exists() {
c.timeout = 10000
}
c.cacheTTL = int(json.Get("cacheTTL").Int())
if !json.Get("cacheTTL").Exists() {
c.cacheTTL = 0
// c.cacheTTL = 3600000
}
if json.Get("cacheKeyPrefix").Exists() {
c.cacheKeyPrefix = json.Get("cacheKeyPrefix").String()
} else {
c.cacheKeyPrefix = DEFAULT_CACHE_PREFIX
}
}
func (c *ProviderConfig) Validate() error {
if c.typ == "" {
return errors.New("cache service type is required")
}
if c.serviceName == "" {
return errors.New("cache service name is required")
}
if c.cacheTTL < 0 {
return errors.New("cache TTL must be greater than or equal to 0")
}
initializer, has := providerInitializers[c.typ]
if !has {
return errors.New("unknown cache service provider type: " + c.typ)
}
if err := initializer.ValidateConfig(*c); err != nil {
return err
}
return nil
}
func CreateProvider(pc ProviderConfig) (Provider, error) {
initializer, has := providerInitializers[pc.typ]
if !has {
return nil, errors.New("unknown provider type: " + pc.typ)
}
return initializer.CreateProvider(pc)
}
type Provider interface {
GetProviderType() string
Init(username string, password string, timeout uint32) error
Get(key string, cb wrapper.RedisResponseCallback) error
Set(key string, value string, cb wrapper.RedisResponseCallback) error
GetCacheKeyPrefix() string
}

View File

@@ -0,0 +1,65 @@
package cache
import (
"errors"
"github.com/higress-group/wasm-go/pkg/log"
"github.com/higress-group/wasm-go/pkg/wrapper"
)
type redisProviderInitializer struct {
}
func (r *redisProviderInitializer) ValidateConfig(cf ProviderConfig) error {
if len(cf.serviceName) == 0 {
return errors.New("cache service name is required")
}
return nil
}
func (r *redisProviderInitializer) CreateProvider(cf ProviderConfig) (Provider, error) {
rp := redisProvider{
config: cf,
client: wrapper.NewRedisClusterClient(wrapper.FQDNCluster{
FQDN: cf.serviceName,
Host: cf.serviceHost,
Port: int64(cf.servicePort)}),
}
err := rp.Init(cf.username, cf.password, cf.timeout)
return &rp, err
}
type redisProvider struct {
config ProviderConfig
client wrapper.RedisClient
}
func (rp *redisProvider) GetProviderType() string {
return PROVIDER_TYPE_REDIS
}
func (rp *redisProvider) Init(username string, password string, timeout uint32) error {
err := rp.client.Init(rp.config.username, rp.config.password, int64(rp.config.timeout))
if rp.client.Ready() {
log.Info("redis init successfully")
} else {
log.Error("redis init failed, will try later")
}
return err
}
func (rp *redisProvider) Get(key string, cb wrapper.RedisResponseCallback) error {
return rp.client.Get(key, cb)
}
func (rp *redisProvider) Set(key string, value string, cb wrapper.RedisResponseCallback) error {
if rp.config.cacheTTL == 0 {
return rp.client.Set(key, value, cb)
} else {
return rp.client.SetEx(key, value, rp.config.cacheTTL, cb)
}
}
func (rp *redisProvider) GetCacheKeyPrefix() string {
return rp.config.cacheKeyPrefix
}

View File

@@ -0,0 +1,89 @@
package config
import (
"fmt"
"strconv"
"github.com/alibaba/higress/plugins/wasm-go/extensions/response-cache/cache"
"github.com/higress-group/wasm-go/pkg/log"
"github.com/tidwall/gjson"
)
type PluginConfig struct {
cacheProvider cache.Provider
cacheProviderConfig cache.ProviderConfig
CacheKeyFromHeader string
CacheKeyFromBody string
CacheValueFromBodyType string
CacheValueFromBody string
CacheResponseCode []int32
}
func (c *PluginConfig) FromJson(json gjson.Result) {
c.cacheProviderConfig.FromJson(json.Get("cache"))
c.CacheKeyFromHeader = json.Get("cacheKeyFromHeader").String()
c.CacheKeyFromBody = json.Get("cacheKeyFromBody").String()
c.CacheValueFromBodyType = json.Get("cacheValueFromBodyType").String()
if c.CacheValueFromBodyType == "" {
c.CacheValueFromBodyType = "application/json"
}
c.CacheValueFromBody = json.Get("cacheValueFromBody").String()
cacheResponseCode := json.Get("cacheResponseCode").Array()
c.CacheResponseCode = make([]int32, 0, len(cacheResponseCode))
for _, v := range cacheResponseCode {
responseCode, err := strconv.Atoi(v.String())
if err != nil || responseCode < 100 || responseCode > 999 {
log.Errorf("Skip invalid response_code value: %s", v.String())
return
}
c.CacheResponseCode = append(c.CacheResponseCode, int32(responseCode))
}
if len(c.CacheResponseCode) == 0 {
c.CacheResponseCode = []int32{200}
}
}
func (c *PluginConfig) Validate() error {
// cache cannot be empty
if c.cacheProviderConfig.GetProviderType() == "" {
return fmt.Errorf("cache provider cannot be empty")
}
// if cache provider is configured, validate it
if c.cacheProviderConfig.GetProviderType() != "" {
if err := c.cacheProviderConfig.Validate(); err != nil {
return err
}
}
// cache key cannot be all set
if c.CacheKeyFromHeader != "" && c.CacheKeyFromBody != "" {
return fmt.Errorf("cacheKeyFromHeader and cacheKeyFromBody cannot be all set")
}
return nil
}
func (c *PluginConfig) Complete() error {
var err error
if c.cacheProviderConfig.GetProviderType() != "" {
log.Debugf("cache provider is set to %s", c.cacheProviderConfig.GetProviderType())
c.cacheProvider, err = cache.CreateProvider(c.cacheProviderConfig)
if err != nil {
return err
}
} else {
log.Info("cache provider is not configured")
c.cacheProvider = nil
}
return nil
}
func (c *PluginConfig) GetCacheProvider() cache.Provider {
return c.cacheProvider
}

View File

@@ -0,0 +1,100 @@
package main
import (
"errors"
"fmt"
"strings"
"github.com/alibaba/higress/plugins/wasm-go/extensions/response-cache/cache"
"github.com/alibaba/higress/plugins/wasm-go/extensions/response-cache/config"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
"github.com/higress-group/wasm-go/pkg/log"
"github.com/higress-group/wasm-go/pkg/wrapper"
"github.com/tidwall/resp"
)
// buildCacheKey constructs the full cache key by combining the prefix with the actual key.
func buildCacheKey(provider cache.Provider, key string) string {
return provider.GetCacheKeyPrefix() + key
}
// CheckCacheForKey checks if the key is in the cache, or triggers similarity search if not found.
func CheckCacheForKey(key string, ctx wrapper.HttpContext, c config.PluginConfig) error {
activeCacheProvider := c.GetCacheProvider()
if activeCacheProvider == nil {
return logAndReturnError("[CheckCacheForKey] no cache provider configured")
}
queryKey := buildCacheKey(activeCacheProvider, key)
log.Debugf("[%s] [CheckCacheForKey] querying cache with key: %s", PLUGIN_NAME, queryKey)
err := activeCacheProvider.Get(queryKey, func(response resp.Value) {
handleCacheResponse(key, response, ctx, c)
})
if err != nil {
log.Errorf("[%s] [CheckCacheForKey] failed to retrieve key: %s from cache, error: %v", PLUGIN_NAME, key, err)
return err
}
return nil
}
// handleCacheResponse processes cache response and handles cache hits and misses.
func handleCacheResponse(key string, response resp.Value, ctx wrapper.HttpContext, c config.PluginConfig) {
if err := response.Error(); err == nil && !response.IsNull() {
log.Infof("[%s] cache hit for key: %s", PLUGIN_NAME, key)
processCacheHit(key, response.String(), ctx, c)
return
}
log.Infof("[%s] [handleCacheResponse] cache miss for key: %s", PLUGIN_NAME, key)
if err := response.Error(); err != nil {
log.Errorf("[%s] [handleCacheResponse] error retrieving key: %s from cache, error: %v", PLUGIN_NAME, key, err)
}
proxywasm.ResumeHttpRequest()
}
// processCacheHit handles a successful cache hit.
func processCacheHit(key string, response string, ctx wrapper.HttpContext, c config.PluginConfig) {
if strings.TrimSpace(response) == "" {
log.Warnf("[%s] [processCacheHit] cached response for key %s is empty", PLUGIN_NAME, key)
proxywasm.ResumeHttpRequest()
return
}
log.Debugf("[%s] [processCacheHit] cached response for key %s: %s", PLUGIN_NAME, key, response)
ctx.SetContext(CACHE_KEY_CONTEXT_KEY, nil)
contentType := c.CacheValueFromBodyType
headers := [][2]string{
{"content-type", contentType},
{"x-cache-status", "hit"},
}
proxywasm.SendHttpResponseWithDetail(200, "response-cache.hit", headers, []byte(response), -1)
}
// logAndReturnError logs an error and returns it.
func logAndReturnError(message string) error {
message = fmt.Sprintf("[%s] %s", PLUGIN_NAME, message)
log.Errorf(message)
return errors.New(message)
}
// Caches the response value
func cacheResponse(ctx wrapper.HttpContext, c config.PluginConfig, key string, value string) {
if strings.TrimSpace(value) == "" {
log.Warnf("[%s] [cacheResponse] cached value for key %s is empty", PLUGIN_NAME, key)
return
}
activeCacheProvider := c.GetCacheProvider()
if activeCacheProvider != nil {
queryKey := buildCacheKey(activeCacheProvider, key)
_ = activeCacheProvider.Set(queryKey, value, nil)
log.Debugf("[%s] [cacheResponse] cache set success, key: %s, length of value: %d", PLUGIN_NAME, queryKey, len(value))
}
}

View File

@@ -0,0 +1,27 @@
// File generated by hgctl. Modify as required.
module github.com/alibaba/higress/plugins/wasm-go/extensions/response-cache
go 1.24.1
toolchain go1.24.5
require (
github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250822030947-8345453fddd0
github.com/higress-group/wasm-go v1.0.3-0.20251011083635-792cb1547bac
github.com/stretchr/testify v1.9.0
github.com/tidwall/gjson v1.18.0
github.com/tidwall/resp v0.1.1
// github.com/weaviate/weaviate-go-client/v4 v4.15.1
)
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.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -0,0 +1,32 @@
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/higress-group/wasm-go v1.0.3-0.20251011083635-792cb1547bac h1:tdJzS56Xa6BSHAi9P2omvb98bpI8qFGg6jnCPtPmDgA=
github.com/higress-group/wasm-go v1.0.3-0.20251011083635-792cb1547bac/go.mod h1:B8C6+OlpnyYyZUBEdUXA7tYZYD+uwZTNjfkE5FywA+A=
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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/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,202 @@
// 这个文件中主要将OnHttpRequestHeaders、OnHttpRequestBody、OnHttpResponseHeaders、OnHttpResponseBody这四个函数实现
// 其中的缓存思路调用cache.go中的逻辑
package main
import (
"strconv"
"strings"
"github.com/alibaba/higress/plugins/wasm-go/extensions/response-cache/config"
"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"
"github.com/tidwall/gjson"
)
const (
PLUGIN_NAME = "response-cache"
CACHE_KEY_CONTEXT_KEY = "cacheKey"
SKIP_CACHE_HEADER = "x-higress-skip-response-cache"
DEFAULT_MAX_BODY_BYTES uint32 = 10 * 1024 * 1024
)
func main() {}
func init() {
// CreateClient()
wrapper.SetCtx(
PLUGIN_NAME,
wrapper.ParseConfig(parseConfig),
wrapper.ProcessRequestHeaders(onHttpRequestHeaders),
wrapper.ProcessRequestBody(onHttpRequestBody),
wrapper.ProcessResponseHeaders(onHttpResponseHeaders),
wrapper.ProcessResponseBody(onHttpResponseBody),
)
}
func parseConfig(json gjson.Result, c *config.PluginConfig) error {
c.FromJson(json)
if err := c.Validate(); err != nil {
return err
}
if err := c.Complete(); err != nil {
log.Errorf("complete config failed: %v", err)
return err
}
return nil
}
func onHttpRequestHeaders(ctx wrapper.HttpContext, c config.PluginConfig) types.Action {
skipCache, _ := proxywasm.GetHttpRequestHeader(SKIP_CACHE_HEADER)
if skipCache == "on" {
ctx.SetContext(SKIP_CACHE_HEADER, struct{}{})
ctx.DontReadRequestBody()
return types.ActionContinue
}
// cache from request header
if c.CacheKeyFromHeader != "" {
key, _ := proxywasm.GetHttpRequestHeader(c.CacheKeyFromHeader)
if key == "" {
log.Warnf("[onHttpRequestHeaders] cache key from header: %s is empty, skip cache", c.CacheKeyFromHeader)
// Set skip cache flag to skip response processing
ctx.SetContext(SKIP_CACHE_HEADER, struct{}{})
ctx.DontReadRequestBody()
return types.ActionContinue
}
log.Debugf("[onHttpRequestHeaders] cache key from request header: %s, key: %s", c.CacheKeyFromHeader, key)
ctx.SetContext(CACHE_KEY_CONTEXT_KEY, key)
if err := CheckCacheForKey(key, ctx, c); err != nil {
log.Errorf("[onHttpRequestHeaders] check cache for key: %s failed, error: %v", key, err)
}
ctx.DisableReroute()
_ = proxywasm.RemoveHttpRequestHeader("Accept-Encoding")
ctx.DontReadRequestBody()
return types.ActionContinue
}
// cache from request body but does not have a body or not application/json format
contentType, _ := proxywasm.GetHttpRequestHeader("content-type")
if contentType == "" || !strings.Contains(contentType, "application/json") {
log.Warnf("[onHttpRequestHeaders] content is not application/json, can't process: %s", contentType)
// Set skip cache flag to skip response processing
ctx.SetContext(SKIP_CACHE_HEADER, struct{}{})
ctx.DontReadRequestBody()
return types.ActionContinue
}
ctx.SetRequestBodyBufferLimit(DEFAULT_MAX_BODY_BYTES)
ctx.DisableReroute()
_ = proxywasm.RemoveHttpRequestHeader("Accept-Encoding")
// The request has a body and requires delaying the header transmission until a cache miss occurs,
// at which point the header should be sent.
return types.HeaderStopIteration
}
func onHttpRequestBody(ctx wrapper.HttpContext, c config.PluginConfig, body []byte) types.Action {
var key string
if c.CacheKeyFromBody != "" {
bodyJson := gjson.ParseBytes(body)
log.Debugf("[onHttpRequestBody] cache key from requestBody: %s", c.CacheKeyFromBody)
key = bodyJson.Get(c.CacheKeyFromBody).String()
if key == "" {
log.Debug("[onHttpRequestBody] parse key from request body failed")
// Set skip cache flag to skip response processing
ctx.SetContext(SKIP_CACHE_HEADER, struct{}{})
ctx.DontReadResponseBody()
return types.ActionContinue
}
} else {
key = string(body)
log.Debugf("[onHttpRequestBody] cache key from requestWholeBody.")
}
log.Debugf("[onHttpRequestBody] key: %s", key)
ctx.SetContext(CACHE_KEY_CONTEXT_KEY, key)
if err := CheckCacheForKey(key, ctx, c); err != nil {
log.Errorf("[onHttpRequestBody] check cache for key: %s failed, error: %v", key, err)
return types.ActionContinue
}
return types.ActionPause
}
func onHttpResponseHeaders(ctx wrapper.HttpContext, c config.PluginConfig) types.Action {
status, err := proxywasm.GetHttpResponseHeader(":status")
if err != nil {
log.Errorf("[onHttpResponseBody] unable to load :status header from response: %v", err)
ctx.DontReadResponseBody()
return types.ActionContinue
}
// 状态码判断
found := false
respCode, _ := strconv.Atoi(status)
for _, element := range c.CacheResponseCode {
if element == int32(respCode) {
found = true
break
}
}
if !found {
log.Infof("[onHttpResponseBody] status not allow to cached: %s", status)
proxywasm.AddHttpResponseHeader("x-cache-status", "skip")
ctx.DontReadResponseBody()
return types.ActionContinue
}
skipCache := ctx.GetContext(SKIP_CACHE_HEADER)
if skipCache != nil {
proxywasm.AddHttpResponseHeader("x-cache-status", "skip")
ctx.DontReadResponseBody()
return types.ActionContinue
}
if ctx.GetContext(CACHE_KEY_CONTEXT_KEY) != nil {
proxywasm.AddHttpResponseHeader("x-cache-status", "miss")
}
ctx.SetResponseBodyBufferLimit(DEFAULT_MAX_BODY_BYTES)
return types.ActionContinue
}
func onHttpResponseBody(ctx wrapper.HttpContext, c config.PluginConfig, body []byte) types.Action {
key := ctx.GetContext(CACHE_KEY_CONTEXT_KEY)
if key == nil {
log.Debug("[onHttpResponseBody] key is nil, skip cache")
return types.ActionContinue
}
var value string
if c.CacheValueFromBody != "" {
if strings.Contains(c.CacheValueFromBodyType, "application/json") {
// cache application/json parse response body
bodyJson := gjson.ParseBytes(body)
if !bodyJson.Exists() {
log.Warnf("[onHttpResponseBody] parse application/json from non application/json response body: %s", body)
return types.ActionContinue
}
value = bodyJson.Get(c.CacheValueFromBody).String()
if strings.TrimSpace(value) == "" {
log.Warnf("[onHttpResponseBody] parse value from response body failed, body:%s", body)
return types.ActionContinue
}
}
//If there are other body types, add a parsing process here.
} else {
value = string(body)
}
cacheResponse(ctx, c, key.(string), value)
return types.ActionContinue
}

View File

@@ -0,0 +1,703 @@
package main
import (
"encoding/json"
"testing"
"github.com/alibaba/higress/plugins/wasm-go/extensions/response-cache/config"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
"github.com/higress-group/wasm-go/pkg/test"
"github.com/stretchr/testify/require"
)
// 测试配置使用header提取key
var configWithHeaderKey = func() json.RawMessage {
data, _ := json.Marshal(map[string]interface{}{
"cache": map[string]interface{}{
"type": "redis",
"serviceName": "redis.static",
"servicePort": 6379,
"timeout": 10000,
},
"cacheKeyFromHeader": "x-user-id",
"cacheValueFromBody": "data",
"cacheValueFromBodyType": "application/json",
"cacheResponseCode": []int{200},
})
return data
}()
// 测试配置使用body提取key
var configWithBodyKey = func() json.RawMessage {
data, _ := json.Marshal(map[string]interface{}{
"cache": map[string]interface{}{
"type": "redis",
"serviceName": "redis.static",
"servicePort": 6379,
"timeout": 10000,
},
"cacheKeyFromBody": "user_id",
"cacheValueFromBody": "message.content",
"cacheValueFromBodyType": "application/json",
"cacheResponseCode": []int{200},
})
return data
}()
// 测试配置使用整个body作为key
var configWithBodyAsKey = func() json.RawMessage {
data, _ := json.Marshal(map[string]interface{}{
"cache": map[string]interface{}{
"type": "redis",
"serviceName": "redis.static",
"servicePort": 6379,
"timeout": 10000,
},
"cacheKeyFromBody": "",
"cacheValueFromBody": "",
"cacheValueFromBodyType": "application/json",
"cacheResponseCode": []int{200},
})
return data
}()
// 测试配置配置冲突同时设置header和body key
var configConflict = func() json.RawMessage {
data, _ := json.Marshal(map[string]interface{}{
"cache": map[string]interface{}{
"type": "redis",
"serviceName": "redis.static",
"servicePort": 6379,
},
"cacheKeyFromHeader": "x-user-id",
"cacheKeyFromBody": "user_id",
})
return data
}()
// 测试配置:最小配置
var minimalConfig = func() json.RawMessage {
data, _ := json.Marshal(map[string]interface{}{
"cache": map[string]interface{}{
"type": "redis",
"serviceName": "redis.static",
"servicePort": 6379,
},
})
return data
}()
// 测试配置缺少cache provider
var configMissingCache = func() json.RawMessage {
data, _ := json.Marshal(map[string]interface{}{
"cacheKeyFromHeader": "x-user-id",
})
return data
}()
func TestParseConfig(t *testing.T) {
test.RunGoTest(t, func(t *testing.T) {
// 测试header key配置
t.Run("config with header key", func(t *testing.T) {
host, status := test.NewTestHost(configWithHeaderKey)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
configRaw, err := host.GetMatchConfig()
require.NoError(t, err)
require.NotNil(t, configRaw)
cfg, ok := configRaw.(*config.PluginConfig)
require.True(t, ok)
require.Equal(t, "x-user-id", cfg.CacheKeyFromHeader)
require.Equal(t, "", cfg.CacheKeyFromBody)
require.Equal(t, "data", cfg.CacheValueFromBody)
require.Equal(t, []int32{200}, cfg.CacheResponseCode)
})
// 测试body key配置
t.Run("config with body key", func(t *testing.T) {
host, status := test.NewTestHost(configWithBodyKey)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
configRaw, err := host.GetMatchConfig()
require.NoError(t, err)
require.NotNil(t, configRaw)
cfg, ok := configRaw.(*config.PluginConfig)
require.True(t, ok)
require.Equal(t, "", cfg.CacheKeyFromHeader)
require.Equal(t, "user_id", cfg.CacheKeyFromBody)
require.Equal(t, "message.content", cfg.CacheValueFromBody)
})
// 测试整个body作为key
t.Run("config with body as key", func(t *testing.T) {
host, status := test.NewTestHost(configWithBodyAsKey)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
configRaw, err := host.GetMatchConfig()
require.NoError(t, err)
require.NotNil(t, configRaw)
cfg, ok := configRaw.(*config.PluginConfig)
require.True(t, ok)
require.Equal(t, "", cfg.CacheKeyFromHeader)
require.Equal(t, "", cfg.CacheKeyFromBody)
})
// 测试配置冲突
t.Run("conflict config", func(t *testing.T) {
host, status := test.NewTestHost(configConflict)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusFailed, status)
})
// 测试缺少cache provider
t.Run("missing cache provider", func(t *testing.T) {
host, status := test.NewTestHost(configMissingCache)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusFailed, status)
})
// 测试最小配置
t.Run("minimal config", func(t *testing.T) {
host, status := test.NewTestHost(minimalConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
configRaw, err := host.GetMatchConfig()
require.NoError(t, err)
require.NotNil(t, configRaw)
cfg, ok := configRaw.(*config.PluginConfig)
require.True(t, ok)
require.Equal(t, []int32{200}, cfg.CacheResponseCode)
})
})
}
func TestOnHttpRequestHeaders(t *testing.T) {
test.RunTest(t, func(t *testing.T) {
// 测试使用header key的请求头处理
t.Run("request headers with header key", func(t *testing.T) {
host, status := test.NewTestHost(configWithHeaderKey)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
// 设置请求头包含cache key
action := host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/api/data"},
{":method", "GET"},
{"x-user-id", "user123"},
})
// 应该返回ActionContinue因为从header提取key后继续处理
require.Equal(t, types.ActionContinue, action)
})
// 测试header key为空
t.Run("request headers with empty header key", func(t *testing.T) {
host, status := test.NewTestHost(configWithHeaderKey)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
// 设置请求头不包含x-user-id
action := host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/api/data"},
{":method", "GET"},
})
// 应该返回ActionContinue跳过缓存
require.Equal(t, types.ActionContinue, action)
})
// 测试skip cache header
t.Run("skip cache header", func(t *testing.T) {
host, status := test.NewTestHost(configWithHeaderKey)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
// 设置跳过缓存的请求头
action := host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/api/data"},
{":method", "GET"},
{"x-user-id", "user123"},
{"x-higress-skip-response-cache", "on"},
})
// 应该返回ActionContinue
require.Equal(t, types.ActionContinue, action)
})
// 测试使用body key的content-type检查
t.Run("request headers for body key with content type", func(t *testing.T) {
host, status := test.NewTestHost(configWithBodyKey)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
// 设置请求头包含application/json content-type
action := host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/api/data"},
{":method", "POST"},
{"content-type", "application/json"},
})
// 应该返回HeaderStopIteration等待读取body
require.Equal(t, types.HeaderStopIteration, action)
})
// 测试content-type不匹配
t.Run("request headers with non-json content type", func(t *testing.T) {
host, status := test.NewTestHost(configWithBodyKey)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
// 设置请求头content-type不是json
action := host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/api/data"},
{":method", "POST"},
{"content-type", "text/plain"},
})
// 应该返回ActionContinue跳过缓存
require.Equal(t, types.ActionContinue, action)
})
// 测试无content-type
t.Run("request headers without content type", func(t *testing.T) {
host, status := test.NewTestHost(configWithBodyKey)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
// 设置请求头无content-type
action := host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/api/data"},
{":method", "POST"},
})
// 应该返回ActionContinue跳过缓存
require.Equal(t, types.ActionContinue, action)
})
})
}
func TestOnHttpRequestBody(t *testing.T) {
test.RunTest(t, func(t *testing.T) {
// 测试从body提取key
t.Run("request body with key extraction", func(t *testing.T) {
host, status := test.NewTestHost(configWithBodyKey)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
// 设置请求头
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/api/data"},
{":method", "POST"},
{"content-type", "application/json"},
})
// 构造请求体
requestBody := `{"user_id": "user123", "data": "test"}`
action := host.CallOnHttpRequestBody([]byte(requestBody))
// 应该返回ActionPause等待缓存检查结果
require.Equal(t, types.ActionPause, action)
})
// 测试从body提取key失败key为空
t.Run("request body with empty key", func(t *testing.T) {
host, status := test.NewTestHost(configWithBodyKey)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
// 设置请求头
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/api/data"},
{":method", "POST"},
{"content-type", "application/json"},
})
// 构造请求体不包含user_id字段
requestBody := `{"data": "test"}`
action := host.CallOnHttpRequestBody([]byte(requestBody))
// 应该返回ActionContinue跳过缓存
require.Equal(t, types.ActionContinue, action)
})
// 测试整个body作为key
t.Run("request body as key", func(t *testing.T) {
host, status := test.NewTestHost(configWithBodyAsKey)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
// 设置请求头
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/api/data"},
{":method", "POST"},
{"content-type", "application/json"},
})
// 构造请求体
requestBody := `{"data": "test"}`
action := host.CallOnHttpRequestBody([]byte(requestBody))
// 应该返回ActionPause
require.Equal(t, types.ActionPause, action)
})
})
}
func TestOnHttpResponseHeaders(t *testing.T) {
test.RunTest(t, func(t *testing.T) {
// 测试响应头处理 - 状态码200
t.Run("response headers with 200 status", func(t *testing.T) {
host, status := test.NewTestHost(configWithHeaderKey)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
// 设置请求头
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/api/data"},
{":method", "GET"},
{"x-user-id", "user123"},
})
// 设置响应头
action := host.CallOnHttpResponseHeaders([][2]string{
{":status", "200"},
{"content-type", "application/json"},
})
// 应该返回ActionContinue
require.Equal(t, types.ActionContinue, action)
})
// 测试响应头处理 - 状态码500不支持缓存
t.Run("response headers with 500 status", func(t *testing.T) {
host, status := test.NewTestHost(configWithHeaderKey)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
// 设置请求头
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/api/data"},
{":method", "GET"},
{"x-user-id", "user123"},
})
// 设置响应头状态码500
action := host.CallOnHttpResponseHeaders([][2]string{
{":status", "500"},
{"content-type", "application/json"},
})
// 应该返回ActionContinue但跳过缓存
require.Equal(t, types.ActionContinue, action)
})
// 测试skip cache header的处理
t.Run("response headers with skip cache", func(t *testing.T) {
host, status := test.NewTestHost(configWithHeaderKey)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
// 设置请求头包含skip cache标志
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/api/data"},
{":method", "GET"},
{"x-user-id", "user123"},
{"x-higress-skip-response-cache", "on"},
})
// 设置响应头
action := host.CallOnHttpResponseHeaders([][2]string{
{":status", "200"},
{"content-type", "application/json"},
})
// 应该返回ActionContinue
require.Equal(t, types.ActionContinue, action)
})
})
}
func TestOnHttpResponseBody(t *testing.T) {
test.RunTest(t, func(t *testing.T) {
// 测试响应体处理 - 提取特定字段
t.Run("response body with value extraction", func(t *testing.T) {
host, status := test.NewTestHost(configWithHeaderKey)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
// 设置请求头
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/api/data"},
{":method", "GET"},
{"x-user-id", "user123"},
})
// 设置响应头
host.CallOnHttpResponseHeaders([][2]string{
{":status", "200"},
{"content-type", "application/json"},
})
// 构造响应体
responseBody := `{"data": "cached value", "other": "ignored"}`
action := host.CallOnHttpResponseBody([]byte(responseBody))
// 应该返回ActionContinue
require.Equal(t, types.ActionContinue, action)
})
// 测试响应体处理 - 整个body作为value
t.Run("response body as value", func(t *testing.T) {
host, status := test.NewTestHost(configWithBodyAsKey)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
// 设置请求头
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/api/data"},
{":method", "POST"},
{"content-type", "application/json"},
})
// 设置请求体
host.CallOnHttpRequestBody([]byte(`{"test": "data"}`))
// 设置响应头
host.CallOnHttpResponseHeaders([][2]string{
{":status", "200"},
{"content-type", "application/json"},
})
// 构造响应体
responseBody := `{"data": "full response"}`
action := host.CallOnHttpResponseBody([]byte(responseBody))
// 应该返回ActionContinue
require.Equal(t, types.ActionContinue, action)
})
// 测试无key的响应体处理
t.Run("response body without key", func(t *testing.T) {
host, status := test.NewTestHost(configWithHeaderKey)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
// 设置响应头,不经过请求处理
host.CallOnHttpResponseHeaders([][2]string{
{":status", "200"},
{"content-type", "application/json"},
})
// 构造响应体
responseBody := `{"data": "test"}`
host.CallOnHttpResponseBody([]byte(responseBody))
})
})
}
// 测试缓存命中流程
func TestCacheHitFlow(t *testing.T) {
test.RunTest(t, func(t *testing.T) {
// 测试完整的缓存命中流程
t.Run("complete cache hit flow with header key", func(t *testing.T) {
host, status := test.NewTestHost(configWithHeaderKey)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
// 设置请求头
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/api/data"},
{":method", "GET"},
{"x-user-id", "user123"},
})
// 模拟Redis缓存命中 - 返回之前缓存的data字段值
cacheHitResp := test.CreateRedisRespString("cached value")
host.CallOnRedisCall(0, cacheHitResp)
// 完成HTTP请求
host.CompleteHttp()
// 验证缓存命中的响应
localResp := host.GetLocalResponse()
require.Equal(t, uint32(200), localResp.StatusCode)
require.Equal(t, "cached value", string(localResp.Data))
require.True(t, test.HasHeaderWithValue(localResp.Headers, "content-type", "application/json"))
require.True(t, test.HasHeaderWithValue(localResp.Headers, "x-cache-status", "hit"))
})
// 测试缓存未命中然后存储的流程
t.Run("cache miss and store flow", func(t *testing.T) {
host, status := test.NewTestHost(configWithHeaderKey)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
// 设置请求头
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/api/data"},
{":method", "GET"},
{"x-user-id", "user123"},
})
// 模拟Redis缓存未命中返回null
cacheMissResp := test.CreateRedisRespNull()
host.CallOnRedisCall(0, cacheMissResp)
// 设置响应头
host.CallOnHttpResponseHeaders([][2]string{
{":status", "200"},
{"content-type", "application/json"},
})
// 设置响应体
responseBody := `{"data": "new data", "other": "ignored"}`
action := host.CallOnHttpResponseBody([]byte(responseBody))
require.Equal(t, types.ActionContinue, action)
// 模拟Redis存储操作SET操作返回OK
storeResp := test.CreateRedisRespArray([]interface{}{"OK"})
host.CallOnRedisCall(0, storeResp)
// 完成HTTP请求
host.CompleteHttp()
})
// 测试两次请求第一次miss第二次hit
t.Run("first request miss then second request hit", func(t *testing.T) {
host, status := test.NewTestHost(configWithHeaderKey)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
// ========== 第一次请求:缓存未命中 ==========
// 设置请求头
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/api/data"},
{":method", "GET"},
{"x-user-id", "user123"},
})
// 模拟Redis缓存未命中第一次查询返回null
cacheMissResp := test.CreateRedisRespNull()
host.CallOnRedisCall(0, cacheMissResp)
// 设置响应头
host.CallOnHttpResponseHeaders([][2]string{
{":status", "200"},
{"content-type", "application/json"},
})
// 设置响应体
responseBody := `{"data": "first response"}`
action := host.CallOnHttpResponseBody([]byte(responseBody))
require.Equal(t, types.ActionContinue, action)
// 模拟Redis SET操作第一次请求后将数据存入缓存
storeResp := test.CreateRedisRespArray([]interface{}{"OK"})
host.CallOnRedisCall(0, storeResp)
host.CompleteHttp()
// 设置请求头相同的x-user-id
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/api/data"},
{":method", "GET"},
{"x-user-id", "user123"}, // 相同的user ID
})
// 模拟Redis缓存命中第二次查询返回缓存的数据
cacheHitResp := test.CreateRedisRespString("first response")
host.CallOnRedisCall(0, cacheHitResp)
// 完成HTTP请求
host.CompleteHttp()
// 验证第二次请求返回的是缓存的数据
localResp := host.GetLocalResponse()
require.Equal(t, uint32(200), localResp.StatusCode)
require.Equal(t, "first response", string(localResp.Data))
require.True(t, test.HasHeaderWithValue(localResp.Headers, "x-cache-status", "hit"))
require.True(t, test.HasHeaderWithValue(localResp.Headers, "content-type", "application/json"))
})
// 测试body key的两次请求流程
t.Run("body key first miss then second hit", func(t *testing.T) {
host, status := test.NewTestHost(configWithBodyKey)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
// 第一次请求
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/api/data"},
{":method", "POST"},
{"content-type", "application/json"},
})
requestBody := `{"user_id": "user123"}`
host.CallOnHttpRequestBody([]byte(requestBody))
// Redis缓存未命中
cacheMissResp := test.CreateRedisRespNull()
host.CallOnRedisCall(0, cacheMissResp)
// 响应
host.CallOnHttpResponseHeaders([][2]string{
{":status", "200"},
{"content-type", "application/json"},
})
responseBody := `{"message": {"content": "hello world"}}`
host.CallOnHttpResponseBody([]byte(responseBody))
// 存储到Redis
storeResp := test.CreateRedisRespArray([]interface{}{"OK"})
host.CallOnRedisCall(0, storeResp)
host.CompleteHttp()
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/api/data"},
{":method", "POST"},
{"content-type", "application/json"},
})
host.CallOnHttpRequestBody([]byte(`{"user_id": "user123"}`))
// 缓存命中
cacheHitResp := test.CreateRedisRespString("hello world")
host.CallOnRedisCall(0, cacheHitResp)
host.CompleteHttp()
// 验证第二次请求返回的是缓存的数据
localResp := host.GetLocalResponse()
require.Equal(t, uint32(200), localResp.StatusCode)
require.Equal(t, "hello world", string(localResp.Data))
require.True(t, test.HasHeaderWithValue(localResp.Headers, "x-cache-status", "hit"))
require.True(t, test.HasHeaderWithValue(localResp.Headers, "content-type", "application/json"))
})
})
}

View File

@@ -0,0 +1,52 @@
# File generated by hgctl. Modify as required.
version: 1.0.0
build:
# The official builder image version
builder:
go: 1.19
tinygo: 0.28.1
oras: 1.0.0
# The WASM plugin project directory
input: ./
# The output of the build products
output:
# Choose between 'files' and 'image'
type: files
# Destination address: when type=files, specify the local directory path, e.g., './out' or
# type=image, specify the remote docker repository, e.g., 'docker.io/<your_username>/<your_image>'
dest: ./out
# The authentication configuration for pushing image to the docker repository
docker-auth: ~/.docker/config.json
# The directory for the WASM plugin configuration structure
model-dir: ./
# The WASM plugin configuration structure name
model: PluginConfig
# Enable debug mode
debug: false
test:
# Test environment name, that is a docker compose project name
name: wasm-test
# The output path to build products, that is the source of test configuration parameters
from-path: ./out
# The test configuration source
test-path: ./test
# Docker compose configuration, which is empty, looks for the following files from 'test-path':
# compose.yaml, compose.yml, docker-compose.yml, docker-compose.yaml
compose-file:
# Detached mode: Run containers in the background
detach: false
install:
# The namespace of the installation
namespace: higress-system
# Use to validate WASM plugin configuration when install by yaml
spec-yaml: ./out/spec.yaml
# Installation source. Choose between 'from-yaml' and 'from-go-project'
from-yaml: ./test/plugin-conf.yaml
# If 'from-go-src' is non-empty, the output type of the build option must be 'image'
from-go-src:
# Enable debug mode
debug: false