diff --git a/plugins/wasm-go/extensions/response-cache/README.md b/plugins/wasm-go/extensions/response-cache/README.md new file mode 100644 index 000000000..395adb64a --- /dev/null +++ b/plugins/wasm-go/extensions/response-cache/README.md @@ -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,不会读取请求body;cacheKeyFromHeader和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" + +# 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 diff --git a/plugins/wasm-go/extensions/response-cache/README_EN.md b/plugins/wasm-go/extensions/response-cache/README_EN.md new file mode 100644 index 000000000..28d906c5c --- /dev/null +++ b/plugins/wasm-go/extensions/response-cache/README_EN.md @@ -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" + +# 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 \ No newline at end of file diff --git a/plugins/wasm-go/extensions/response-cache/cache/provider.go b/plugins/wasm-go/extensions/response-cache/cache/provider.go new file mode 100644 index 000000000..06ed0ac78 --- /dev/null +++ b/plugins/wasm-go/extensions/response-cache/cache/provider.go @@ -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 +} diff --git a/plugins/wasm-go/extensions/response-cache/cache/redis.go b/plugins/wasm-go/extensions/response-cache/cache/redis.go new file mode 100644 index 000000000..41473a0fb --- /dev/null +++ b/plugins/wasm-go/extensions/response-cache/cache/redis.go @@ -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 +} diff --git a/plugins/wasm-go/extensions/response-cache/config/config.go b/plugins/wasm-go/extensions/response-cache/config/config.go new file mode 100644 index 000000000..7188580c2 --- /dev/null +++ b/plugins/wasm-go/extensions/response-cache/config/config.go @@ -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 +} diff --git a/plugins/wasm-go/extensions/response-cache/core.go b/plugins/wasm-go/extensions/response-cache/core.go new file mode 100644 index 000000000..c3f6305fa --- /dev/null +++ b/plugins/wasm-go/extensions/response-cache/core.go @@ -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)) + } +} diff --git a/plugins/wasm-go/extensions/response-cache/go.mod b/plugins/wasm-go/extensions/response-cache/go.mod new file mode 100644 index 000000000..4c4ee70b5 --- /dev/null +++ b/plugins/wasm-go/extensions/response-cache/go.mod @@ -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 +) diff --git a/plugins/wasm-go/extensions/response-cache/go.sum b/plugins/wasm-go/extensions/response-cache/go.sum new file mode 100644 index 000000000..f5c1e7c45 --- /dev/null +++ b/plugins/wasm-go/extensions/response-cache/go.sum @@ -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= diff --git a/plugins/wasm-go/extensions/response-cache/main.go b/plugins/wasm-go/extensions/response-cache/main.go new file mode 100644 index 000000000..cf0977c67 --- /dev/null +++ b/plugins/wasm-go/extensions/response-cache/main.go @@ -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 + +} diff --git a/plugins/wasm-go/extensions/response-cache/main_test.go b/plugins/wasm-go/extensions/response-cache/main_test.go new file mode 100644 index 000000000..d67f60376 --- /dev/null +++ b/plugins/wasm-go/extensions/response-cache/main_test.go @@ -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")) + }) + }) +} diff --git a/plugins/wasm-go/extensions/response-cache/option.yaml b/plugins/wasm-go/extensions/response-cache/option.yaml new file mode 100644 index 000000000..8be6bfd1c --- /dev/null +++ b/plugins/wasm-go/extensions/response-cache/option.yaml @@ -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//' + 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