This is an automated email from the ASF dual-hosted git repository.
AlinsRan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git
The following commit(s) were added to refs/heads/master by this push:
new 8e3e72b39 feat(plugin): add graphql-proxy-cache plugin (#13435)
8e3e72b39 is described below
commit 8e3e72b3922239e54d9e326714189959db899cb3
Author: AlinsRan <[email protected]>
AuthorDate: Mon Jun 1 14:59:03 2026 +0800
feat(plugin): add graphql-proxy-cache plugin (#13435)
---
apisix/cli/config.lua | 1 +
apisix/cli/ngx_tpl.lua | 5 +-
apisix/plugins/graphql-proxy-cache.lua | 417 +++++++++++++++++++
conf/config.yaml.example | 1 +
docs/en/latest/config.json | 1 +
docs/en/latest/plugins/graphql-proxy-cache.md | 232 +++++++++++
docs/zh/latest/config.json | 1 +
docs/zh/latest/plugins/graphql-proxy-cache.md | 232 +++++++++++
t/admin/plugins.t | 1 +
t/plugin/graphql-proxy-cache/disk.t | 233 +++++++++++
t/plugin/graphql-proxy-cache/graphql.t | 538 +++++++++++++++++++++++++
t/plugin/graphql-proxy-cache/memory.t | 555 ++++++++++++++++++++++++++
12 files changed, 2214 insertions(+), 3 deletions(-)
diff --git a/apisix/cli/config.lua b/apisix/cli/config.lua
index 637ad84f6..8088f1bdc 100644
--- a/apisix/cli/config.lua
+++ b/apisix/cli/config.lua
@@ -246,6 +246,7 @@ local _M = {
"ai-aws-content-moderation",
"ai-aliyun-content-moderation",
"proxy-mirror",
+ "graphql-proxy-cache",
"proxy-rewrite",
"workflow",
"api-breaker",
diff --git a/apisix/cli/ngx_tpl.lua b/apisix/cli/ngx_tpl.lua
index 969deb6c6..f14a7c4fb 100644
--- a/apisix/cli/ngx_tpl.lua
+++ b/apisix/cli/ngx_tpl.lua
@@ -679,7 +679,7 @@ http {
{% if deployment_role ~= "control_plane" then %}
- {% if enabled_plugins["proxy-cache"] then %}
+ {% if enabled_plugins["proxy-cache"] or
enabled_plugins["graphql-proxy-cache"] then %}
# for proxy cache
{% for _, cache in ipairs(proxy_cache.zones) do %}
{% if cache.disk_path and cache.cache_levels and cache.disk_size then %}
@@ -858,9 +858,8 @@ http {
proxy_set_header X-Forwarded-Host $var_x_forwarded_host;
proxy_set_header X-Forwarded-Port $var_x_forwarded_port;
- {% if enabled_plugins["proxy-cache"] then %}
+ {% if enabled_plugins["proxy-cache"] or
enabled_plugins["graphql-proxy-cache"] then %}
### the following configuration is to cache response content from
upstream server
-
set $upstream_cache_zone off;
set $upstream_cache_key '';
set $upstream_cache_bypass '';
diff --git a/apisix/plugins/graphql-proxy-cache.lua
b/apisix/plugins/graphql-proxy-cache.lua
new file mode 100644
index 000000000..2926471ec
--- /dev/null
+++ b/apisix/plugins/graphql-proxy-cache.lua
@@ -0,0 +1,417 @@
+--
+-- Licensed to the Apache Software Foundation (ASF) under one or more
+-- contributor license agreements. See the NOTICE file distributed with
+-- this work for additional information regarding copyright ownership.
+-- The ASF licenses this file to You under the Apache License, Version 2.0
+-- (the "License"); you may not use this file except in compliance with
+-- the License. You may obtain a copy of the License at
+--
+-- http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing, software
+-- distributed under the License is distributed on an "AS IS" BASIS,
+-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+-- See the License for the specific language governing permissions and
+-- limitations under the License.
+--
+
+local memory_handler = require("apisix.plugins.proxy-cache.memory_handler")
+local disk_handler = require("apisix.plugins.proxy-cache.disk_handler")
+local memory_strategy = require("apisix.plugins.proxy-cache.memory").new
+local util = require("apisix.plugins.proxy-cache.util")
+local apisix_plugin = require("apisix.plugin")
+local core = require("apisix.core")
+local router = require("apisix.router")
+local get_service = require("apisix.http.service").get
+local get_plugin_config = require("apisix.plugin_config").get
+local gq_parse = require("graphql").parse
+local ngx_re = require("ngx.re")
+local ipairs = ipairs
+local pcall = pcall
+local os = os
+local ngx = ngx
+local type = type
+local tostring = tostring
+local ngx_var = ngx.var
+local ngx_md5 = ngx.md5
+
+local plugin_name = "graphql-proxy-cache"
+
+local STRATEGY_DISK = "disk"
+local STRATEGY_MEMORY = "memory"
+
+local schema = {
+ type = "object",
+ properties = {
+ cache_zone = {
+ type = "string",
+ minLength = 1,
+ maxLength = 100,
+ default = "disk_cache_one",
+ },
+ cache_strategy = {
+ type = "string",
+ enum = {STRATEGY_DISK, STRATEGY_MEMORY},
+ default = STRATEGY_DISK,
+ },
+ cache_ttl = {
+ type = "integer",
+ minimum = 1,
+ default = 300,
+ },
+ consumer_isolation = {
+ type = "boolean",
+ default = true,
+ },
+ cache_set_cookie = {
+ type = "boolean",
+ default = false,
+ },
+ },
+}
+
+
+local _M = {
+ version = 0.1,
+ priority = 1009,
+ name = plugin_name,
+ schema = schema,
+}
+
+
+function _M.check_schema(conf)
+ local ok, err = core.schema.check(schema, conf)
+ if not ok then
+ return false, err
+ end
+
+ local local_conf = core.config.local_conf()
+ if local_conf.apisix.proxy_cache then
+ for _, cache in ipairs(local_conf.apisix.proxy_cache.zones) do
+ if cache.name == conf.cache_zone then
+ return true
+ end
+ end
+
+ return false, "cache_zone " .. conf.cache_zone .. " not found"
+ end
+
+ return true
+end
+
+
+local GRAPHQL_DEFAULT_MAX_SIZE = 1048576 -- 1MiB
+local GRAPHQL_REQ_QUERY = "query"
+local GRAPHQL_REQ_MIME_JSON = "application/json"
+local GRAPHQL_REQ_MIME_GRAPHQL = "application/graphql"
+
+
+local fetch_graphql_body = {
+ ["GET"] = function(ctx, max_size)
+ local body = ctx.var.args
+ if not body or body == "" then
+ return nil, "failed to read graphql data, args has zero size"
+ end
+ if #body > max_size then
+ return nil, "failed to read graphql data, args size " .. #body
+ .. " is greater than the "
+ .. "maximum size " .. max_size .. " allowed"
+ end
+
+ return ctx.var.args
+ end,
+
+ ["POST"] = function(ctx, max_size)
+ local body, err = core.request.get_body(max_size, ctx)
+ if not body then
+ return nil, "failed to read graphql data, " .. (err or "request
body has zero size")
+ end
+
+ return body
+ end
+}
+
+
+local check_graphql_request = {
+ ["GET"] = function(ctx, body)
+ local args, err = core.string.decode_args(body)
+ if not args then
+ return false, "invalid graphql request, args " .. err
+ end
+
+ local query = args[GRAPHQL_REQ_QUERY]
+ if type(query) == "table" then
+ query = query[1]
+ end
+ if not query then
+ return false, "invalid graphql request, args[" ..
+ GRAPHQL_REQ_QUERY .. "] is nil"
+ end
+ return true, query
+ end,
+
+ ["POST"] = function(ctx, body)
+ local content_type = core.request.header(ctx, "Content-Type") or ""
+ if core.string.has_prefix(content_type, GRAPHQL_REQ_MIME_JSON) then
+ local res, err = core.json.decode(body, {null_as_nil = true})
+ if not res then
+ return false, "invalid graphql request, " .. err
+ end
+
+ if not res[GRAPHQL_REQ_QUERY] then
+ return false, "invalid graphql request, json body[" ..
+ GRAPHQL_REQ_QUERY .. "] is nil"
+ end
+
+ return true, res[GRAPHQL_REQ_QUERY]
+ end
+
+ if core.string.has_prefix(content_type, GRAPHQL_REQ_MIME_GRAPHQL) then
+ return true, body
+ end
+
+ return false, "invalid graphql request, error content-type: " ..
content_type
+ end
+}
+
+
+local function graphql_cache_conf(ctx, conf)
+ if ctx.graphql_cache_conf then
+ return ctx.graphql_cache_conf
+ end
+
+ local cache_conf = {
+ cache_strategy = conf.cache_strategy,
+ cache_zone = conf.cache_zone,
+ cache_method = {"GET", "POST"},
+ cache_http_status = {200},
+ cache_ttl = conf.cache_ttl,
+ cache_set_cookie = conf.cache_set_cookie,
+ }
+
+ ctx.graphql_cache_conf = cache_conf
+
+ return cache_conf
+end
+
+
+function _M.access(conf, ctx)
+ local method = core.request.get_method()
+ -- TODO: support PURGE method
+ if method ~= "POST" and method ~= "GET" then
+ return 405
+ end
+
+ local local_conf = core.config.local_conf()
+ local max_size = GRAPHQL_DEFAULT_MAX_SIZE
+ local size = core.table.try_read_attr(local_conf, "graphql", "max_size")
+ if size then
+ max_size = size
+ end
+ local body, err = fetch_graphql_body[method](ctx, max_size)
+ if not body then
+ core.log.error(err)
+ return 400, {message = "Invalid graphql request: can't get graphql
request body"}
+ end
+
+ local is_graphql_req, query_or_err = check_graphql_request[method](ctx,
body)
+ if not is_graphql_req then
+ core.log.error(query_or_err)
+ return 400, {message = query_or_err}
+ end
+
+ local ok, res = pcall(gq_parse, query_or_err)
+ if not ok then
+ core.log.error("failed to parse graphql: ", res)
+ return 400, {message = "Invalid graphql request: failed to parse
graphql query"}
+ end
+
+ local n = #res.definitions
+ if n == 0 then
+ core.log.error("failed to parse graphql: empty query")
+ return 400, {message = "Invalid graphql request: empty graphql query"}
+ end
+
+ for i = 1, n do
+ if res.definitions[i].operation == "mutation" then
+ -- mutation operations are not cached
+ ctx.var.upstream_cache_bypass = "1"
+ ctx.var.upstream_no_cache = "1"
+ core.response.set_header("Apisix-Cache-Status", "BYPASS")
+ return
+ end
+ end
+
+ core.log.debug("graphql-proxy-cache plugin access phase, body_size: ",
#body)
+
+ -- Bind the cache key to the route/service/host so two routes that share
+ -- the same plugin config and receive the same query body do not collide.
+ -- When consumer_isolation is on (default), prepend the authenticated
+ -- identity so each consumer gets its own cache namespace. The control
+ -- character separator is outside the charset permitted by the consumer
+ -- username schema, keeping the components unambiguous.
+ local route_id = ctx.var.route_id or ""
+ local service_id = ctx.var.service_id or ""
+ local host = ctx.var.host or ""
+
+ local identity = ""
+ if conf.consumer_isolation then
+ identity = ctx.consumer_name
+ if not identity or identity == "" then
+ identity = ctx.var.remote_user or ""
+ end
+ end
+
+ local conf_version = apisix_plugin.conf_version(conf)
+ local value = ngx_md5(conf_version .. "\1" .. host .. "\1"
+ .. route_id .. "\1" .. service_id .. "\1"
+ .. identity .. "\1" .. body)
+ ctx.var.upstream_cache_key = value
+
+ core.response.set_header("APISIX-Cache-Key", value)
+
+ core.log.debug("graphql-proxy-cache cache key value:", value)
+
+ local handler
+ if conf.cache_strategy == STRATEGY_MEMORY then
+ handler = memory_handler
+ else
+ handler = disk_handler
+ end
+
+ return handler.access(graphql_cache_conf(ctx, conf), ctx)
+end
+
+
+function _M.header_filter(conf, ctx)
+ if not ctx.var.upstream_cache_key or ctx.var.upstream_cache_key == "" then
+ return
+ end
+
+ local cache_conf = graphql_cache_conf(ctx, conf)
+ core.log.debug("graphql-proxy-cache plugin header filter phase, conf: ",
+ core.json.delay_encode(cache_conf))
+
+ local handler
+ if ctx.graphql_cache_conf.cache_strategy == STRATEGY_MEMORY then
+ handler = memory_handler
+ else
+ handler = disk_handler
+ end
+
+ handler.header_filter(cache_conf, ctx)
+end
+
+
+function _M.body_filter(conf, ctx)
+ if not ctx.var.upstream_cache_key or ctx.var.upstream_cache_key == "" then
+ return
+ end
+
+ local cache_conf = graphql_cache_conf(ctx, conf)
+ core.log.debug("graphql-proxy-cache plugin body filter phase, conf: ",
+ core.json.delay_encode(cache_conf))
+
+ if ctx.graphql_cache_conf.cache_strategy == STRATEGY_MEMORY then
+ memory_handler.body_filter(cache_conf, ctx)
+ end
+end
+
+
+local function find_graphql_proxy_cache_conf(route_id)
+ local routes = router.http_routes()
+ if not routes then
+ return nil
+ end
+
+ local route_value
+ for _, route in ipairs(routes) do
+ if type(route) == "table" and type(route.value) == "table"
+ and tostring(route.value.id) == route_id then
+ route_value = route.value
+ break
+ end
+ end
+
+ if not route_value then
+ return nil
+ end
+
+ if route_value.plugins then
+ return core.table.try_read_attr(route_value, "plugins", plugin_name)
+ end
+
+ if route_value.plugin_config_id then
+ local plugin_config = get_plugin_config(route_value.plugin_config_id)
+ return core.table.try_read_attr(plugin_config, "value", plugin_name)
+ end
+
+ if route_value.service_id then
+ local service = get_service(route_value.service_id)
+ return core.table.try_read_attr(service, "value", "plugins",
plugin_name)
+ end
+ return nil
+end
+
+
+local function purge_hander()
+ local uri_segs = core.utils.split_uri(ngx_var.uri)
+ local strategy, route_id, cache_key = uri_segs[5], uri_segs[6], uri_segs[7]
+
+ if strategy ~= STRATEGY_DISK and strategy ~= STRATEGY_MEMORY then
+ core.log.error("invalid strategy in purge request: ", strategy)
+ return core.response.exit(400)
+ end
+
+ if not route_id or route_id == "" or not cache_key or cache_key == "" then
+ core.log.error("missing route_id or cache_key in purge request")
+ return core.response.exit(400)
+ end
+
+ local conf = find_graphql_proxy_cache_conf(route_id)
+ if not conf then
+ core.log.error("failed to find graphql-proxy-cache conf, route_id: ",
route_id)
+ return core.response.exit(404)
+ end
+
+ if strategy ~= conf.cache_strategy then
+ core.log.error("strategy mismatch: request strategy is ", strategy,
+ " but route is configured with ", conf.cache_strategy)
+ return core.response.exit(400)
+ end
+
+ ngx_var.upstream_cache_key = cache_key
+
+ if strategy == "disk" then
+ ngx_var.upstream_cache_zone = conf.cache_zone
+ local cache_zone_info = ngx_re.split(ngx_var.upstream_cache_zone_info,
",")
+
+ local filename = util.generate_cache_filename(cache_zone_info[1],
cache_zone_info[2],
+ ngx.var.upstream_cache_key)
+
+ if not util.file_exists(filename) then
+ core.log.error("failed to purge graphql cache, file not exists: ",
filename)
+ return core.response.exit(404)
+ end
+ os.remove(filename)
+ else
+ local memory_handler = memory_strategy({shdict_name = conf.cache_zone})
+ memory_handler:purge(ngx_var.upstream_cache_key)
+ end
+
+ return core.response.exit(200)
+end
+
+
+function _M.api()
+ return {
+ {
+ methods = {"PURGE"},
+ uri = "/apisix/plugin/graphql-proxy-cache/*",
+ handler = purge_hander,
+ }
+ }
+end
+
+
+return _M
diff --git a/conf/config.yaml.example b/conf/config.yaml.example
index cf8e7da7e..3a29d3dc0 100644
--- a/conf/config.yaml.example
+++ b/conf/config.yaml.example
@@ -532,6 +532,7 @@ plugins: # plugin list (sorted by
priority)
- ai-proxy # priority: 1040
- ai-rate-limiting # priority: 1030
- proxy-mirror # priority: 1010
+ - graphql-proxy-cache # priority: 1009
- proxy-rewrite # priority: 1008
- workflow # priority: 1006
- api-breaker # priority: 1005
diff --git a/docs/en/latest/config.json b/docs/en/latest/config.json
index 84d5fcb46..ca3f544f4 100644
--- a/docs/en/latest/config.json
+++ b/docs/en/latest/config.json
@@ -169,6 +169,7 @@
"plugins/limit-count",
"plugins/graphql-limit-count",
"plugins/proxy-cache",
+ "plugins/graphql-proxy-cache",
"plugins/request-validation",
"plugins/oas-validator",
"plugins/proxy-mirror",
diff --git a/docs/en/latest/plugins/graphql-proxy-cache.md
b/docs/en/latest/plugins/graphql-proxy-cache.md
new file mode 100644
index 000000000..f3d3dae35
--- /dev/null
+++ b/docs/en/latest/plugins/graphql-proxy-cache.md
@@ -0,0 +1,232 @@
+---
+title: graphql-proxy-cache
+keywords:
+ - Apache APISIX
+ - API Gateway
+ - GraphQL
+ - Proxy Cache
+description: The graphql-proxy-cache Plugin caches GraphQL query responses on
disk or in memory, bypassing the cache for mutation operations.
+---
+
+<!--
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+-->
+
+<head>
+ <link rel="canonical" href="https://docs.api7.ai/hub/graphql-proxy-cache" />
+</head>
+
+## Description
+
+The `graphql-proxy-cache` Plugin provides caching for GraphQL query responses.
It supports both disk-based and memory-based caching strategies for `GET` and
`POST` requests.
+
+The cache key is derived from the plugin configuration version,
route/service/host identifiers, and the GraphQL query body:
+
+```
+key = md5(conf_version + host + route_id + service_id + identity + body)
+```
+
+Requests containing `mutation` operations are never cached — they always
bypass the cache and reach the upstream directly.
+
+The Plugin reuses the caching infrastructure of the
[`proxy-cache`](./proxy-cache.md) Plugin. Cache zones must be configured in
`config.yaml` before enabling this Plugin.
+
+## Attributes
+
+| Name | Type | Required | Default | Valid values
| Description
|
+|--------------------|---------|----------|----------------|---------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| cache_strategy | string | False | disk | ["disk",
"memory"] | Caching strategy. Use `disk` to cache responses on disk (via
NGINX's native `proxy_cache`), or `memory` to cache in a shared memory
dictionary.
|
+| cache_zone | string | False | disk_cache_one |
| Cache zone to use. The value must match one of the zones defined in the
[static configurations](#static-configurations). Use a disk zone with the
`disk` strategy and a memory zone with the `memory` strategy.
|
+| cache_ttl | integer | False | 300 | >= 1
| Cache time to live (TTL) in seconds for the `memory` strategy. For the
`disk` strategy, TTL is controlled by the upstream `Expires` or `Cache-Control`
response headers; if neither is present, the `cache_ttl` configured in
`config.yaml` is used. |
+| consumer_isolation | boolean | False | true |
| If `true`, partition the cache by authenticated identity. When the request
resolves to an APISIX consumer (`ctx.consumer_name`) or carries a remote user
(`ctx.var.remote_user`), the identity is prepended to the effective cache key
so each consumer gets its own cache namespace. Set to `false` if you want
different consumers to share cached responses. |
+| cache_set_cookie | boolean | False | false |
| If `true`, cache responses that include a `Set-Cookie` header. Only valid
for the `memory` strategy — the `disk` strategy never caches responses with
`Set-Cookie` (NGINX enforces this). Enable only when the upstream's
`Set-Cookie` is not user-specific.
|
+
+## Static Configurations
+
+The `graphql-proxy-cache` Plugin reuses the `proxy_cache` zones defined in
`config.yaml`. Configure at least one cache zone before enabling this Plugin:
+
+```yaml title="config.yaml"
+apisix:
+ proxy_cache:
+ cache_ttl: 10s # default TTL for disk cache when Expires/Cache-Control
are absent
+ zones:
+ - name: disk_cache_one
+ memory_size: 50m
+ disk_size: 1G
+ disk_path: /tmp/disk_cache_one
+ cache_levels: 1:2
+ - name: memory_cache
+ memory_size: 50m
+```
+
+Reload APISIX for changes to take effect.
+
+## Examples
+
+The examples below demonstrate how you can configure `graphql-proxy-cache` for
different scenarios.
+
+:::note
+
+You can fetch the `admin_key` from `config.yaml` and save it to an environment
variable with the following command:
+
+```bash
+admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed
's/"//g')
+```
+
+:::
+
+### Cache GraphQL Queries
+
+The following example shows how to enable `graphql-proxy-cache` on a route
with the default disk strategy:
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/routes/1 \
+ -H "X-API-KEY: $admin_key" -X PUT -d '
+{
+ "plugins": {
+ "graphql-proxy-cache": {}
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:8080": 1
+ },
+ "type": "roundrobin"
+ },
+ "uri": "/graphql"
+}'
+```
+
+Send a GraphQL `POST` request:
+
+```shell
+curl http://127.0.0.1:9080/graphql \
+ -H "Content-Type: application/json" \
+ -d '{"query": "query { persons { name } }"}'
+```
+
+The first request results in a cache miss:
+
+```text
+HTTP/1.1 200 OK
+Apisix-Cache-Status: MISS
+APISIX-Cache-Key: <cache-key>
+```
+
+Sending the same request again returns a cache hit:
+
+```text
+HTTP/1.1 200 OK
+Apisix-Cache-Status: HIT
+APISIX-Cache-Key: <cache-key>
+```
+
+### Bypass Cache for Mutation Operations
+
+`graphql-proxy-cache` automatically bypasses the cache for GraphQL requests
containing `mutation` operations:
+
+```shell
+curl http://127.0.0.1:9080/graphql \
+ -H "Content-Type: application/json" \
+ -d '{"query": "mutation { addPerson(name: \"Alice\") { id } }"}'
+```
+
+The response includes `Apisix-Cache-Status: BYPASS`, and the request is
forwarded directly to the upstream:
+
+```text
+HTTP/1.1 200 OK
+Apisix-Cache-Status: BYPASS
+```
+
+### Use In-Memory Cache
+
+The following example enables the `memory` strategy with a 60-second TTL:
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/routes/1 \
+ -H "X-API-KEY: $admin_key" -X PUT -d '
+{
+ "plugins": {
+ "graphql-proxy-cache": {
+ "cache_strategy": "memory",
+ "cache_zone": "memory_cache",
+ "cache_ttl": 60
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:8080": 1
+ },
+ "type": "roundrobin"
+ },
+ "uri": "/graphql"
+}'
+```
+
+### Purge Cached Responses
+
+The Plugin exposes a `PURGE` endpoint for cache invalidation:
+
+```
+PURGE /apisix/plugin/graphql-proxy-cache/:strategy/:route_id/:cache_key
+```
+
+Where:
+
+- `:strategy` — `disk` or `memory`
+- `:route_id` — the ID of the route
+- `:cache_key` — the value returned in the `APISIX-Cache-Key` response header
+
+To expose the purge endpoint, create a route using the
[`public-api`](./public-api.md) Plugin:
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/routes/graphql-cache-purge \
+ -H "X-API-KEY: $admin_key" -X PUT -d '
+{
+ "plugins": {
+ "public-api": {}
+ },
+ "uri": "/apisix/plugin/graphql-proxy-cache/*"
+}'
+```
+
+Then send a purge request using the cache key from a previous response:
+
+```shell
+curl
http://127.0.0.1:9080/apisix/plugin/graphql-proxy-cache/disk/1/<cache-key> \
+ -X PURGE
+```
+
+A successful purge returns HTTP `200`. If the cache entry does not exist, HTTP
`404` is returned.
+
+## Disable Plugin
+
+To disable the `graphql-proxy-cache` Plugin, remove it from the route
configuration:
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/routes/1 \
+ -H "X-API-KEY: $admin_key" -X PUT -d '
+{
+ "uri": "/graphql",
+ "plugins": {},
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:8080": 1
+ }
+ }
+}'
+```
diff --git a/docs/zh/latest/config.json b/docs/zh/latest/config.json
index ea8fc969a..ace1bcac7 100644
--- a/docs/zh/latest/config.json
+++ b/docs/zh/latest/config.json
@@ -157,6 +157,7 @@
"plugins/limit-count",
"plugins/graphql-limit-count",
"plugins/proxy-cache",
+ "plugins/graphql-proxy-cache",
"plugins/request-validation",
"plugins/oas-validator",
"plugins/proxy-mirror",
diff --git a/docs/zh/latest/plugins/graphql-proxy-cache.md
b/docs/zh/latest/plugins/graphql-proxy-cache.md
new file mode 100644
index 000000000..8c8341538
--- /dev/null
+++ b/docs/zh/latest/plugins/graphql-proxy-cache.md
@@ -0,0 +1,232 @@
+---
+title: graphql-proxy-cache
+keywords:
+ - Apache APISIX
+ - API 网关
+ - GraphQL
+ - Proxy Cache
+description: graphql-proxy-cache 插件缓存 GraphQL 查询的响应,支持磁盘和内存两种缓存策略,对包含 mutation
操作的请求自动绕过缓存。
+---
+
+<!--
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+-->
+
+<head>
+ <link rel="canonical" href="https://docs.api7.ai/hub/graphql-proxy-cache" />
+</head>
+
+## 描述
+
+`graphql-proxy-cache` 插件为 GraphQL 查询响应提供缓存能力,支持磁盘和内存两种缓存策略,适用于 `GET` 和 `POST`
请求。
+
+缓存键由插件配置版本、路由/服务/Host 标识符以及 GraphQL 请求体共同生成:
+
+```
+key = md5(conf_version + host + route_id + service_id + identity + body)
+```
+
+包含 `mutation` 操作的请求永远不会被缓存,始终直接透传到上游。
+
+本插件复用 [`proxy-cache`](./proxy-cache.md) 插件的缓存基础设施。启用本插件前,需要先在 `config.yaml`
中配置缓存区域。
+
+## 属性
+
+| 名称 | 类型 | 必选项 | 默认值 | 有效值 | 描述
|
+|--------------------|---------|--------|----------------|------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| cache_strategy | string | 否 | disk | ["disk", "memory"]
| 缓存策略。`disk` 使用 NGINX 原生 `proxy_cache` 将响应缓存到磁盘;`memory` 使用共享内存字典缓存响应。
|
+| cache_zone | string | 否 | disk_cache_one |
| 使用的缓存区域,值必须与[静态配置](#静态配置)中定义的某个区域名称一致。使用磁盘策略时应指定磁盘缓存区域,使用内存策略时应指定内存缓存区域。
|
+| cache_ttl | integer | 否 | 300 | >= 1
| 内存策略的缓存生存时间(TTL),单位为秒。对于磁盘策略,TTL 由上游响应的 `Expires` 或 `Cache-Control`
头控制;若两者均不存在,则使用 `config.yaml` 中配置的 `cache_ttl`。
|
+| consumer_isolation | boolean | 否 | true |
| 为 `true` 时,按已认证身份对缓存进行分区。当请求解析为 APISIX 消费者(`ctx.consumer_name`)或携带 remote
user(`ctx.var.remote_user`)时,身份会作为前缀加入有效缓存键,使每个消费者拥有独立的缓存命名空间。若希望不同消费者共享缓存,可设置为
`false`。 |
+| cache_set_cookie | boolean | 否 | false |
| 为 `true` 时,缓存包含 `Set-Cookie` 响应头的响应。仅对内存策略有效——磁盘策略由 NGINX 原生处理,始终不缓存带
`Set-Cookie` 的响应。仅当上游的 `Set-Cookie` 与具体用户无关时才启用。
|
+
+## 静态配置
+
+`graphql-proxy-cache` 插件复用 `config.yaml` 中定义的 `proxy_cache`
缓存区域。启用本插件前,需要至少配置一个缓存区域:
+
+```yaml title="config.yaml"
+apisix:
+ proxy_cache:
+ cache_ttl: 10s # 磁盘缓存时若 Expires/Cache-Control 均不存在时使用的默认 TTL
+ zones:
+ - name: disk_cache_one
+ memory_size: 50m
+ disk_size: 1G
+ disk_path: /tmp/disk_cache_one
+ cache_levels: 1:2
+ - name: memory_cache
+ memory_size: 50m
+```
+
+修改后重新加载 APISIX 以使配置生效。
+
+## 示例
+
+以下示例演示了如何为不同场景配置 `graphql-proxy-cache`。
+
+:::note
+
+您可以使用以下命令从 `config.yaml` 中获取 `admin_key` 并存入环境变量:
+
+```bash
+admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed
's/"//g')
+```
+
+:::
+
+### 缓存 GraphQL 查询
+
+以下示例演示如何在路由上启用 `graphql-proxy-cache`,使用默认的磁盘缓存策略:
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/routes/1 \
+ -H "X-API-KEY: $admin_key" -X PUT -d '
+{
+ "plugins": {
+ "graphql-proxy-cache": {}
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:8080": 1
+ },
+ "type": "roundrobin"
+ },
+ "uri": "/graphql"
+}'
+```
+
+发送 GraphQL `POST` 请求:
+
+```shell
+curl http://127.0.0.1:9080/graphql \
+ -H "Content-Type: application/json" \
+ -d '{"query": "query { persons { name } }"}'
+```
+
+首次请求会产生缓存未命中:
+
+```text
+HTTP/1.1 200 OK
+Apisix-Cache-Status: MISS
+APISIX-Cache-Key: <cache-key>
+```
+
+再次发送相同请求则会命中缓存:
+
+```text
+HTTP/1.1 200 OK
+Apisix-Cache-Status: HIT
+APISIX-Cache-Key: <cache-key>
+```
+
+### 对 Mutation 操作绕过缓存
+
+`graphql-proxy-cache` 对包含 `mutation` 操作的 GraphQL 请求自动绕过缓存:
+
+```shell
+curl http://127.0.0.1:9080/graphql \
+ -H "Content-Type: application/json" \
+ -d '{"query": "mutation { addPerson(name: \"Alice\") { id } }"}'
+```
+
+响应中包含 `Apisix-Cache-Status: BYPASS`,请求直接转发到上游:
+
+```text
+HTTP/1.1 200 OK
+Apisix-Cache-Status: BYPASS
+```
+
+### 使用内存缓存
+
+以下示例启用内存缓存策略,TTL 设置为 60 秒:
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/routes/1 \
+ -H "X-API-KEY: $admin_key" -X PUT -d '
+{
+ "plugins": {
+ "graphql-proxy-cache": {
+ "cache_strategy": "memory",
+ "cache_zone": "memory_cache",
+ "cache_ttl": 60
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:8080": 1
+ },
+ "type": "roundrobin"
+ },
+ "uri": "/graphql"
+}'
+```
+
+### 清除缓存
+
+本插件提供 `PURGE` 接口用于缓存失效:
+
+```
+PURGE /apisix/plugin/graphql-proxy-cache/:strategy/:route_id/:cache_key
+```
+
+其中:
+
+- `:strategy` — `disk` 或 `memory`
+- `:route_id` — 路由 ID
+- `:cache_key` — 响应头 `APISIX-Cache-Key` 返回的值
+
+首先使用 [`public-api`](./public-api.md) 插件创建一个路由来暴露清除接口:
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/routes/graphql-cache-purge \
+ -H "X-API-KEY: $admin_key" -X PUT -d '
+{
+ "plugins": {
+ "public-api": {}
+ },
+ "uri": "/apisix/plugin/graphql-proxy-cache/*"
+}'
+```
+
+然后使用之前响应中的缓存键发送清除请求:
+
+```shell
+curl
http://127.0.0.1:9080/apisix/plugin/graphql-proxy-cache/disk/1/<cache-key> \
+ -X PURGE
+```
+
+清除成功返回 HTTP `200`,缓存条目不存在则返回 HTTP `404`。
+
+## 禁用插件
+
+从路由配置中移除 `graphql-proxy-cache` 插件即可禁用:
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/routes/1 \
+ -H "X-API-KEY: $admin_key" -X PUT -d '
+{
+ "uri": "/graphql",
+ "plugins": {},
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:8080": 1
+ }
+ }
+}'
+```
diff --git a/t/admin/plugins.t b/t/admin/plugins.t
index b71b0711a..1887ed5ff 100644
--- a/t/admin/plugins.t
+++ b/t/admin/plugins.t
@@ -110,6 +110,7 @@ ai-proxy
ai-rate-limiting
ai-aliyun-content-moderation
proxy-mirror
+graphql-proxy-cache
proxy-rewrite
workflow
api-breaker
diff --git a/t/plugin/graphql-proxy-cache/disk.t
b/t/plugin/graphql-proxy-cache/disk.t
new file mode 100644
index 000000000..b44d95543
--- /dev/null
+++ b/t/plugin/graphql-proxy-cache/disk.t
@@ -0,0 +1,233 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+use t::APISIX 'no_plan';
+
+repeat_each(1);
+no_long_string();
+no_root_location();
+
+add_block_preprocessor(sub {
+ my ($block) = @_;
+
+ my $http_config = $block->http_config // <<_EOC_;
+
+ # for proxy cache
+ proxy_cache_path /tmp/disk_cache_one levels=1:2
keys_zone=disk_cache_one:50m inactive=1d max_size=1G;
+ proxy_cache_path /tmp/disk_cache_two levels=1:2
keys_zone=disk_cache_two:50m inactive=1d max_size=1G;
+
+ # for proxy cache
+ map \$upstream_cache_zone \$upstream_cache_zone_info {
+ disk_cache_one /tmp/disk_cache_one,1:2;
+ disk_cache_two /tmp/disk_cache_two,1:2;
+ }
+_EOC_
+
+ $block->set_value("http_config", $http_config);
+
+ # setup default conf.yaml
+ my $extra_yaml_config = $block->extra_yaml_config // <<_EOC_;
+plugins:
+ - graphql-proxy-cache
+_EOC_
+
+ $block->set_value("extra_yaml_config", $extra_yaml_config);
+
+ if (!$block->request) {
+ $block->set_value("request", "GET /t");
+ }
+
+ if ((!defined $block->error_log) && (!defined $block->no_error_log)) {
+ $block->set_value("no_error_log", "[error]");
+ }
+});
+
+run_tests;
+
+__DATA__
+
+=== TEST 1: set route
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local code, body = t('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ [[{
+ "plugins": {
+ "graphql-proxy-cache": {
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:8888": 1
+ },
+ "type": "roundrobin"
+ },
+ "uri": "/graphql"
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 2: post method: cache miss
+--- request
+POST /graphql
+{
+ "query": "query{persons{id}}"
+}
+--- more_headers
+Content-Type: application/json
+--- response_headers
+Apisix-Cache-Status: MISS
+--- response_body chomp
+{"data":{"persons":[{"id":"7"},{"id":"8"},{"id":"9"},{"id":"10"},{"id":"11"},{"id":"12"},{"id":"13"},{"id":"14"},{"id":"15"},{"id":"16"},{"id":"17"},{"id":"18"}]}}
+
+
+
+=== TEST 3: post method: cache hit
+--- request
+POST /graphql
+{
+ "query": "query{persons{id}}"
+}
+--- more_headers
+Content-Type: application/json
+--- response_headers
+Apisix-Cache-Status: HIT
+--- response_body chomp
+{"data":{"persons":[{"id":"7"},{"id":"8"},{"id":"9"},{"id":"10"},{"id":"11"},{"id":"12"},{"id":"13"},{"id":"14"},{"id":"15"},{"id":"16"},{"id":"17"},{"id":"18"}]}}
+
+
+
+=== TEST 4: get method: cache miss
+--- request
+GET /graphql?query=query%7Bpersons%7Bid%7D%7D
+--- response_headers
+Apisix-Cache-Status: MISS
+--- response_body chomp
+{"data":{"persons":[{"id":"7"},{"id":"8"},{"id":"9"},{"id":"10"},{"id":"11"},{"id":"12"},{"id":"13"},{"id":"14"},{"id":"15"},{"id":"16"},{"id":"17"},{"id":"18"}]}}
+
+
+
+=== TEST 5: get method: cache hit
+--- request
+GET /graphql?query=query%7Bpersons%7Bid%7D%7D
+--- response_headers
+Apisix-Cache-Status: HIT
+--- response_body chomp
+{"data":{"persons":[{"id":"7"},{"id":"8"},{"id":"9"},{"id":"10"},{"id":"11"},{"id":"12"},{"id":"13"},{"id":"14"},{"id":"15"},{"id":"16"},{"id":"17"},{"id":"18"}]}}
+
+
+
+=== TEST 6: get with variables: cache miss
+--- request
+GET
/graphql?query=query%20(%24name%3A%20String!)%20%7B%0A%20%20persons(filter%3A%20%7B%20name%3A%20%24name%20%7D)%20%7B%0A%20%20%20%20name%0A%20%20%20%20blog%0A%20%20%20%20githubAccount%0A%20%20%7D%0A%7D%0A&variables=%7B%22name%22%3A%22Niek%22%7D
+--- response_headers
+Apisix-Cache-Status: MISS
+--- response_body chomp
+{"data":{"persons":[{"name":"Niek","blog":"https://040code.github.io","githubAccount":"npalm"}]}}
+
+
+
+=== TEST 7: get with variables: cache hit
+--- request
+GET
/graphql?query=query%20(%24name%3A%20String!)%20%7B%0A%20%20persons(filter%3A%20%7B%20name%3A%20%24name%20%7D)%20%7B%0A%20%20%20%20name%0A%20%20%20%20blog%0A%20%20%20%20githubAccount%0A%20%20%7D%0A%7D%0A&variables=%7B%22name%22%3A%22Niek%22%7D
+--- response_headers
+Apisix-Cache-Status: HIT
+--- response_body chomp
+{"data":{"persons":[{"name":"Niek","blog":"https://040code.github.io","githubAccount":"npalm"}]}}
+
+
+
+=== TEST 8: post with variables: cache miss
+--- request
+POST /graphql
+{
+ "query":
"query($name:String!){persons(filter:{name:$name}){name\nblog\ngithubAccount}}",
+ "variables": "{\"name\": \"Niek\"}"
+}
+--- more_headers
+Content-Type: application/json
+--- response_headers
+Apisix-Cache-Status: MISS
+--- response_body chomp
+{"data":{"persons":[{"name":"Niek","blog":"https://040code.github.io","githubAccount":"npalm"}]}}
+
+
+
+=== TEST 9: post with variables: cache hit
+--- request
+POST /graphql
+{
+ "query":
"query($name:String!){persons(filter:{name:$name}){name\nblog\ngithubAccount}}",
+ "variables": "{\"name\": \"Niek\"}"
+}
+--- more_headers
+Content-Type: application/json
+--- response_headers
+Apisix-Cache-Status: HIT
+--- response_body chomp
+{"data":{"persons":[{"name":"Niek","blog":"https://040code.github.io","githubAccount":"npalm"}]}}
+
+
+
+=== TEST 10: post by grapgql data: cache miss
+--- request
+POST /graphql
+query {
+ persons(filter: { name: "Niek" }) {
+ name
+ blog
+ githubAccount
+ }
+}
+--- more_headers
+Content-Type: application/graphql
+--- response_headers
+Apisix-Cache-Status: MISS
+--- response_body chomp
+{"data":{"persons":[{"name":"Niek","blog":"https://040code.github.io","githubAccount":"npalm"}]}}
+
+
+
+=== TEST 11: post by grapgql data: cache hit
+--- request
+POST /graphql
+query {
+ persons(filter: { name: "Niek" }) {
+ name
+ blog
+ githubAccount
+ }
+}
+--- more_headers
+Content-Type: application/graphql
+--- response_headers
+Apisix-Cache-Status: HIT
+--- response_body chomp
+{"data":{"persons":[{"name":"Niek","blog":"https://040code.github.io","githubAccount":"npalm"}]}}
diff --git a/t/plugin/graphql-proxy-cache/graphql.t
b/t/plugin/graphql-proxy-cache/graphql.t
new file mode 100644
index 000000000..9d7d0aad5
--- /dev/null
+++ b/t/plugin/graphql-proxy-cache/graphql.t
@@ -0,0 +1,538 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+use t::APISIX 'no_plan';
+
+repeat_each(1);
+no_long_string();
+no_root_location();
+
+add_block_preprocessor(sub {
+ my ($block) = @_;
+
+ my $http_config = $block->http_config // <<_EOC_;
+
+ # for proxy cache
+ lua_shared_dict memory_cache 50m;
+ # for proxy cache
+ proxy_cache_path /tmp/disk_cache_one levels=1:2
keys_zone=disk_cache_one:50m inactive=1d max_size=1G;
+ proxy_cache_path /tmp/disk_cache_two levels=1:2
keys_zone=disk_cache_two:50m inactive=1d max_size=1G;
+
+ # for proxy cache
+ map \$upstream_cache_zone \$upstream_cache_zone_info {
+ disk_cache_one /tmp/disk_cache_one,1:2;
+ disk_cache_two /tmp/disk_cache_two,1:2;
+ }
+_EOC_
+
+ $block->set_value("http_config", $http_config);
+
+ # setup default conf.yaml
+ my $extra_yaml_config = $block->extra_yaml_config // <<_EOC_;
+plugins:
+ - graphql-proxy-cache
+ - public-api
+_EOC_
+
+ $block->set_value("extra_yaml_config", $extra_yaml_config);
+
+ if (!$block->request) {
+ $block->set_value("request", "GET /t");
+ }
+
+ if ((!defined $block->error_log) && (!defined $block->no_error_log)) {
+ $block->set_value("no_error_log", "[error]");
+ }
+});
+
+run_tests;
+
+__DATA__
+
+=== TEST 1: set route: zone not exists
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local code, body = t('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ [[{
+ "plugins": {
+ "graphql-proxy-cache": {
+ "cache_zone": "fake_zone"
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:8888": 1
+ },
+ "type": "roundrobin"
+ },
+ "uri": "/graphql"
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- request
+GET /t
+--- error_code: 400
+--- response_body eval
+qr/"failed to check the configuration of plugin graphql-proxy-cache err:
cache_zone fake_zone not found"/
+
+
+
+=== TEST 2: set route: wrong cache_strategy
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local code, body = t('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ [[{
+ "plugins": {
+ "graphql-proxy-cache": {
+ "cache_zone": "disk_cache_one",
+ "cache_strategy": "test"
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:8888": 1
+ },
+ "type": "roundrobin"
+ },
+ "uri": "/graphql"
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- request
+GET /t
+--- error_code: 400
+--- response_body eval
+qr/"failed to check the configuration of plugin graphql-proxy-cache err:
property \\\"cache_strategy\\\" validation failed: matches none of the enum
values"/
+
+
+
+=== TEST 3: set route: normal
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local code, body = t('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ [[{
+ "plugins": {
+ "graphql-proxy-cache": {
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:8888": 1
+ },
+ "type": "roundrobin"
+ },
+ "uri": "/graphql"
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 4: invalid graphql request: wrong method
+--- request
+HEAD /graphql
+--- error_code: 405
+
+
+
+=== TEST 5: invalid graphql request: get method without args
+--- request
+GET /graphql
+--- error_code: 400
+--- error_log
+failed to read graphql data, args has zero size
+--- response_body eval
+qr/Invalid graphql request: can't get graphql request body/
+
+
+
+=== TEST 6: invalid graphql request: get method without args
+--- request
+GET
/graphql?query=query%20(%24name%3A%20String!)%20%7B%0A%20%20persons(filter%3A%20%7B%20name%3A%20%24name%20%7D)%20%7B%0A%20%20%20%20name%0A%20%20%20%20blog%0A%20%20%20%20githubAccount%0A%20%20%7D%0A%7D%0A&variables=%7B%22name%22%3A%22Niek%22%7D
+--- yaml_config
+graphql:
+ max_size: 20
+--- error_code: 400
+--- error_log
+failed to read graphql data, args size 234 is greater than the maximum size 20
allowed
+--- response_body eval
+qr/Invalid graphql request: can't get graphql request body/
+
+
+
+=== TEST 7: invalid graphql request: no query
+--- request
+GET /graphql?test=test
+--- error_code: 400
+--- error_log
+invalid graphql request, args[query] is nil
+--- response_body eval
+qr/invalid graphql request, args\[query\] is nil/
+
+
+
+=== TEST 8: invalid graphql request: post method without body
+--- request
+POST /graphql
+--- error_code: 400
+--- error_log
+failed to read graphql data, request body has zero size
+--- response_body eval
+qr/Invalid graphql request: can't get graphql request body/
+
+
+
+=== TEST 9: invalid graphql request: wrong content-type
+--- request
+POST /graphql
+{
+ "query": "query{persons{id}}"
+}
+--- error_code: 400
+--- error_log
+invalid graphql request, error content-type
+--- response_body eval
+qr/invalid graphql request, error content-type/
+
+
+
+=== TEST 10: invalid graphql request: wrong json
+--- request
+POST /graphql
+{
+ "query": "query{persons{id}}",
+}
+--- more_headers
+Content-Type: application/json
+--- error_code: 400
+--- error_log
+invalid graphql request, Expected object key string but found T_OBJ_END at
character 38
+--- response_body eval
+qr/invalid graphql request, Expected/
+
+
+
+=== TEST 11: invalid graphql request: no query
+--- request
+POST /graphql
+{
+ "test": "query{persons{id}}"
+}
+--- more_headers
+Content-Type: application/json
+--- error_code: 400
+--- error_log
+invalid graphql request, json body[query] is nil
+--- response_body eval
+qr/invalid graphql request, json body/
+
+
+
+=== TEST 12: invalid graphql request: graphql data no query
+--- request
+POST /graphql
+test {
+ persons(filter: { name: "Niek" }) {
+ name
+ blog
+ githubAccount
+ }
+}
+--- more_headers
+Content-Type: application/graphql
+--- error_code: 400
+--- error_log eval
+qr/failed to parse graphql: Syntax error near line 1/
+--- response_body eval
+qr/Invalid graphql request: failed to parse graphql query/
+
+
+
+=== TEST 13: invalid graphql request: failed to parse graphql
+--- request
+POST /graphql
+{
+ "query": "query{persons(filter){id}}"
+}
+--- more_headers
+Content-Type: application/json
+--- error_code: 400
+--- error_log eval
+qr/failed to parse graphql: Syntax error near line 1/
+--- response_body eval
+qr/Invalid graphql request: failed to parse graphql query/
+
+
+
+=== TEST 14: invalid graphql request: empty query
+--- request
+POST /graphql
+{
+ "query": ""
+}
+--- more_headers
+Content-Type: application/json
+--- error_code: 400
+--- error_log eval
+qr/failed to parse graphql: empty query/
+--- response_body eval
+qr/Invalid graphql request: empty graphql query/
+
+
+
+=== TEST 15: query contains mutation will bypass
+--- request
+POST /graphql
+{
+ "query": "query{persons{id}}
mutation{addTalk(talk:{title:\"apisix\"}){id}}"
+}
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- response_headers
+Apisix-Cache-Status: BYPASS
+
+
+
+=== TEST 16: purge memory cache: normal
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local code, body = t('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ [[{
+ "plugins": {
+ "graphql-proxy-cache": {
+ "cache_zone": "memory_cache",
+ "cache_strategy": "memory",
+ "cache_ttl": 5
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:8888": 1
+ },
+ "type": "roundrobin"
+ },
+ "uri": "/graphql"
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ return ngx.say(body)
+ end
+
+ local code, body = t('/apisix/admin/routes/graphql-purge',
+ ngx.HTTP_PUT,
+ [[{
+ "plugins": {
+ "public-api": {}
+ },
+ "uri": "/apisix/plugin/graphql-proxy-cache/*"
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ return ngx.say(body)
+ end
+
+ local request = function()
+ return t('/graphql', ngx.HTTP_POST,
+ [[
+ {
+ "query": "query{persons{id}}"
+ }
+ ]],
+ nil,
+ {["Content-Type"] = "application/json"}
+ )
+ end
+
+ local code, _, _, headers = request()
+ assert(code == 200, "request to graphql server failed")
+ assert(headers["Apisix-Cache-Status"] == "MISS", "request should
miss")
+ local cache_key = headers["APISIX-Cache-Key"]
+
+ local code, _, _, headers = request()
+ assert(code == 200, "request to graphql server failed")
+ assert(headers["Apisix-Cache-Status"] == "HIT", "request should
hit")
+
+
+ local code, body =
t('/apisix/plugin/graphql-proxy-cache/memory/1/'..cache_key, "PURGE")
+ assert(code == 200, "purge failed")
+
+ local code, _, _, headers = request()
+ assert(code == 200, "request to graphql server failed")
+ assert(headers["Apisix-Cache-Status"] == "MISS", "cache should
MISS after purge")
+
+ ngx.say("passed")
+ }
+ }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 17: purge disk cache: normal
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local code, body = t('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ [[{
+ "plugins": {
+ "graphql-proxy-cache": {}
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:8888": 1
+ },
+ "type": "roundrobin"
+ },
+ "uri": "/graphql"
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ return ngx.say(body)
+ end
+
+ local code, body = t('/apisix/admin/routes/graphql-purge',
+ ngx.HTTP_PUT,
+ [[{
+ "plugins": {
+ "public-api": {}
+ },
+ "uri": "/apisix/plugin/graphql-proxy-cache/*"
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ return ngx.say(body)
+ end
+
+ local request = function()
+ return t('/graphql', ngx.HTTP_POST,
+ [[
+ {
+ "query": "query{persons{id}}"
+ }
+ ]],
+ nil,
+ {["Content-Type"] = "application/json"}
+ )
+ end
+
+ -- Notice: should rm /tmp/disk_cache*
+ local code, _, _, headers = request()
+ assert(code == 200, "request to graphql server failed")
+ assert(headers["Apisix-Cache-Status"] == "MISS", "request should
miss")
+ local cache_key = headers["APISIX-Cache-Key"]
+
+ local code, _, _, headers = request()
+ assert(code == 200, "request to graphql server failed")
+ assert(headers["Apisix-Cache-Status"] == "HIT", "request should
hit")
+
+
+ local code, body =
t('/apisix/plugin/graphql-proxy-cache/disk/1/'..cache_key, "PURGE")
+ assert(code == 200, "purge failed")
+
+ local code, _, _, headers = request()
+ assert(code == 200, "request to graphql server failed")
+ assert(headers["Apisix-Cache-Status"] == "MISS", "cache should
MISS after purge")
+
+ ngx.say("passed")
+ }
+ }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 18: purge cache: wrong route_id
+--- request
+PURGE /apisix/plugin/graphql-proxy-cache/disk/xxx/abc
+--- error_code: 404
+--- error_log eval
+qr/failed to find graphql-proxy-cache conf, route_id: /
+
+
+
+=== TEST 19: purge cache: wrong cache key
+--- request
+PURGE /apisix/plugin/graphql-proxy-cache/disk/1/abc
+--- error_code: 404
+--- error_log eval
+qr/failed to purge graphql cache, file not exists: /
+
+
+
+=== TEST 20: purge cache: invalid strategy
+--- request
+PURGE /apisix/plugin/graphql-proxy-cache/invalid/1/abc
+--- error_code: 400
+--- error_log
+invalid strategy in purge request: invalid
+
+
+
+=== TEST 21: purge cache: strategy mismatch
+--- request
+PURGE /apisix/plugin/graphql-proxy-cache/memory/1/abc
+--- error_code: 400
+--- error_log
+strategy mismatch: request strategy is memory but route is configured with disk
diff --git a/t/plugin/graphql-proxy-cache/memory.t
b/t/plugin/graphql-proxy-cache/memory.t
new file mode 100644
index 000000000..5545cef0c
--- /dev/null
+++ b/t/plugin/graphql-proxy-cache/memory.t
@@ -0,0 +1,555 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+BEGIN {
+ $ENV{TEST_NGINX_FORCE_RESTART_ON_TEST} = 0;
+}
+
+use t::APISIX 'no_plan';
+
+repeat_each(1);
+no_shuffle();
+no_long_string();
+no_root_location();
+
+add_block_preprocessor(sub {
+ my ($block) = @_;
+
+ my $http_config = $block->http_config // <<_EOC_;
+
+ # for proxy cache
+ lua_shared_dict memory_cache 50m;
+_EOC_
+
+ $block->set_value("http_config", $http_config);
+
+ # setup default conf.yaml
+ my $extra_yaml_config = $block->extra_yaml_config // <<_EOC_;
+plugins:
+ - graphql-proxy-cache
+_EOC_
+
+ $block->set_value("extra_yaml_config", $extra_yaml_config);
+
+ if (!$block->request) {
+ $block->set_value("request", "GET /t");
+ }
+
+ if ((!defined $block->error_log) && (!defined $block->no_error_log)) {
+ $block->set_value("no_error_log", "[error]");
+ }
+});
+
+run_tests;
+
+__DATA__
+
+=== TEST 1: set route
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local code, body = t('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ [[{
+ "plugins": {
+ "graphql-proxy-cache": {
+ "cache_zone": "memory_cache",
+ "cache_strategy": "memory",
+ "cache_ttl": 3
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:8888": 1
+ },
+ "type": "roundrobin"
+ },
+ "uri": "/graphql"
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 2: post method: cache miss
+--- request
+POST /graphql
+{
+ "query": "query{persons{id}}"
+}
+--- more_headers
+Content-Type: application/json
+--- response_headers
+Apisix-Cache-Status: MISS
+--- response_body chomp
+{"data":{"persons":[{"id":"7"},{"id":"8"},{"id":"9"},{"id":"10"},{"id":"11"},{"id":"12"},{"id":"13"},{"id":"14"},{"id":"15"},{"id":"16"},{"id":"17"},{"id":"18"}]}}
+
+
+
+=== TEST 3: post method: cache hit
+--- request
+POST /graphql
+{
+ "query": "query{persons{id}}"
+}
+--- more_headers
+Content-Type: application/json
+--- response_headers
+Apisix-Cache-Status: HIT
+--- response_body chomp
+{"data":{"persons":[{"id":"7"},{"id":"8"},{"id":"9"},{"id":"10"},{"id":"11"},{"id":"12"},{"id":"13"},{"id":"14"},{"id":"15"},{"id":"16"},{"id":"17"},{"id":"18"}]}}
+
+
+
+=== TEST 4: get method: cache miss
+--- request
+GET /graphql?query=query%7Bpersons%7Bid%7D%7D
+--- response_headers
+Apisix-Cache-Status: MISS
+--- response_body chomp
+{"data":{"persons":[{"id":"7"},{"id":"8"},{"id":"9"},{"id":"10"},{"id":"11"},{"id":"12"},{"id":"13"},{"id":"14"},{"id":"15"},{"id":"16"},{"id":"17"},{"id":"18"}]}}
+
+
+
+=== TEST 5: get method: cache hit
+--- request
+GET /graphql?query=query%7Bpersons%7Bid%7D%7D
+--- response_headers
+Apisix-Cache-Status: HIT
+--- response_body chomp
+{"data":{"persons":[{"id":"7"},{"id":"8"},{"id":"9"},{"id":"10"},{"id":"11"},{"id":"12"},{"id":"13"},{"id":"14"},{"id":"15"},{"id":"16"},{"id":"17"},{"id":"18"}]}}
+
+
+
+=== TEST 6: get with variables: cache miss
+--- request
+GET
/graphql?query=query%20(%24name%3A%20String!)%20%7B%0A%20%20persons(filter%3A%20%7B%20name%3A%20%24name%20%7D)%20%7B%0A%20%20%20%20name%0A%20%20%20%20blog%0A%20%20%20%20githubAccount%0A%20%20%7D%0A%7D%0A&variables=%7B%22name%22%3A%22Niek%22%7D
+--- response_headers
+Apisix-Cache-Status: MISS
+--- response_body chomp
+{"data":{"persons":[{"name":"Niek","blog":"https://040code.github.io","githubAccount":"npalm"}]}}
+
+
+
+=== TEST 7: get with variables: cache hit
+--- request
+GET
/graphql?query=query%20(%24name%3A%20String!)%20%7B%0A%20%20persons(filter%3A%20%7B%20name%3A%20%24name%20%7D)%20%7B%0A%20%20%20%20name%0A%20%20%20%20blog%0A%20%20%20%20githubAccount%0A%20%20%7D%0A%7D%0A&variables=%7B%22name%22%3A%22Niek%22%7D
+--- response_headers
+Apisix-Cache-Status: HIT
+--- response_body chomp
+{"data":{"persons":[{"name":"Niek","blog":"https://040code.github.io","githubAccount":"npalm"}]}}
+
+
+
+=== TEST 8: post with variables: cache miss
+--- request
+POST /graphql
+{
+ "query":
"query($name:String!){persons(filter:{name:$name}){name\nblog\ngithubAccount}}",
+ "variables": "{\"name\": \"Niek\"}"
+}
+--- more_headers
+Content-Type: application/json
+--- response_headers
+Apisix-Cache-Status: MISS
+--- response_body chomp
+{"data":{"persons":[{"name":"Niek","blog":"https://040code.github.io","githubAccount":"npalm"}]}}
+
+
+
+=== TEST 9: post with variables: cache hit
+--- request
+POST /graphql
+{
+ "query":
"query($name:String!){persons(filter:{name:$name}){name\nblog\ngithubAccount}}",
+ "variables": "{\"name\": \"Niek\"}"
+}
+--- more_headers
+Content-Type: application/json
+--- response_headers
+Apisix-Cache-Status: HIT
+--- response_body chomp
+{"data":{"persons":[{"name":"Niek","blog":"https://040code.github.io","githubAccount":"npalm"}]}}
+
+
+
+=== TEST 10: post by grapgql data: cache miss
+--- request
+POST /graphql
+query {
+ persons(filter: { name: "Niek" }) {
+ name
+ blog
+ githubAccount
+ }
+}
+--- more_headers
+Content-Type: application/graphql
+--- response_headers
+Apisix-Cache-Status: MISS
+--- response_body chomp
+{"data":{"persons":[{"name":"Niek","blog":"https://040code.github.io","githubAccount":"npalm"}]}}
+
+
+
+=== TEST 11: post by grapgql data: cache hit
+--- request
+POST /graphql
+query {
+ persons(filter: { name: "Niek" }) {
+ name
+ blog
+ githubAccount
+ }
+}
+--- more_headers
+Content-Type: application/graphql
+--- response_headers
+Apisix-Cache-Status: HIT
+--- response_body chomp
+{"data":{"persons":[{"name":"Niek","blog":"https://040code.github.io","githubAccount":"npalm"}]}}
+
+
+
+=== TEST 12: consumer_isolation partitions the cache key by consumer
+--- extra_yaml_config
+plugins:
+ - key-auth
+ - proxy-rewrite
+ - graphql-proxy-cache
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local http = require("resty.http")
+
+ local function setup(path, method, body)
+ local code, res_body = t(path, method, body)
+ if code >= 300 then
+ ngx.status = code
+ ngx.say(res_body)
+ return false
+ end
+ return true
+ end
+
+ if not setup('/apisix/admin/consumers', ngx.HTTP_PUT, [[{
+ "username": "gql_alice",
+ "plugins": {
+ "key-auth": {
+ "key": "alice-gql-key"
+ }
+ }
+ }]]) then
+ return
+ end
+
+ if not setup('/apisix/admin/consumers', ngx.HTTP_PUT, [[{
+ "username": "gql_bob",
+ "plugins": {
+ "key-auth": {
+ "key": "bob-gql-key"
+ }
+ }
+ }]]) then
+ return
+ end
+
+ if not setup('/apisix/admin/routes/gql-isolation', ngx.HTTP_PUT,
[[{
+ "uri": "/graphql-auth",
+ "plugins": {
+ "key-auth": {},
+ "proxy-rewrite": {
+ "uri": "/graphql"
+ },
+ "graphql-proxy-cache": {
+ "cache_zone": "memory_cache",
+ "cache_strategy": "memory",
+ "cache_ttl": 300
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:8888": 1
+ },
+ "type": "roundrobin"
+ }
+ }]]) then
+ return
+ end
+
+ -- Wait for consumers + route to propagate to the data plane.
+ ngx.sleep(0.5)
+
+ local uri = "http://127.0.0.1:" .. ngx.var.server_port ..
"/graphql-auth"
+ local body = '{"query":"query{persons{id}}"}'
+
+ local function fetch(apikey)
+ local res, err = http.new():request_uri(uri, {
+ method = "POST",
+ body = body,
+ headers = {
+ apikey = apikey,
+ ["Content-Type"] = "application/json",
+ },
+ })
+ if not res then
+ return nil, err
+ end
+ return res.status .. "/" ..
(res.headers["Apisix-Cache-Status"] or "nil")
+ end
+
+ local alice_1 = fetch("alice-gql-key")
+ local alice_2 = fetch("alice-gql-key")
+ local bob_1 = fetch("bob-gql-key")
+ local bob_2 = fetch("bob-gql-key")
+
+ ngx.say("alice_1=", alice_1)
+ ngx.say("alice_2=", alice_2)
+ ngx.say("bob_1=", bob_1)
+ ngx.say("bob_2=", bob_2)
+ }
+ }
+--- request
+GET /t
+--- response_body
+alice_1=200/MISS
+alice_2=200/HIT
+bob_1=200/MISS
+bob_2=200/HIT
+
+
+
+=== TEST 13: consumer_isolation=false lets consumers share cached responses
+--- extra_yaml_config
+plugins:
+ - key-auth
+ - proxy-rewrite
+ - graphql-proxy-cache
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local http = require("resty.http")
+
+ local code, res_body = t('/apisix/admin/routes/gql-isolation',
ngx.HTTP_PUT, [[{
+ "uri": "/graphql-auth",
+ "plugins": {
+ "key-auth": {},
+ "proxy-rewrite": {
+ "uri": "/graphql"
+ },
+ "graphql-proxy-cache": {
+ "cache_zone": "memory_cache",
+ "cache_strategy": "memory",
+ "cache_ttl": 300,
+ "consumer_isolation": false
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:8888": 1
+ },
+ "type": "roundrobin"
+ }
+ }]])
+ if code >= 300 then
+ ngx.status = code
+ ngx.say(res_body)
+ return
+ end
+
+ local uri = "http://127.0.0.1:" .. ngx.var.server_port ..
"/graphql-auth"
+ local body = '{"query":"query{persons{name}}"}'
+
+ local function fetch(apikey)
+ local res, err = http.new():request_uri(uri, {
+ method = "POST",
+ body = body,
+ headers = {
+ apikey = apikey,
+ ["Content-Type"] = "application/json",
+ },
+ })
+ if not res then
+ return "request failed: " .. (err or "unknown")
+ end
+ return res.headers["Apisix-Cache-Status"]
+ end
+
+ ngx.say("alice_1=", fetch("alice-gql-key"))
+ ngx.say("bob_1=", fetch("bob-gql-key"))
+ }
+ }
+--- request
+GET /t
+--- response_body
+alice_1=MISS
+bob_1=HIT
+
+
+
+=== TEST 14: cache key includes host/route_id so two routes do not collide
+--- extra_yaml_config
+plugins:
+ - proxy-rewrite
+ - graphql-proxy-cache
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local http = require("resty.http")
+
+ local function put(path, body)
+ local code, res_body = t(path, ngx.HTTP_PUT, body)
+ if code >= 300 then
+ ngx.status = code
+ ngx.say(res_body)
+ return false
+ end
+ return true
+ end
+
+ local route_tpl = [[{
+ "uri": "/PATH",
+ "plugins": {
+ "proxy-rewrite": {
+ "uri": "/graphql"
+ },
+ "graphql-proxy-cache": {
+ "cache_zone": "memory_cache",
+ "cache_strategy": "memory",
+ "cache_ttl": 300
+ }
+ },
+ "upstream": {
+ "nodes": {"127.0.0.1:8888": 1},
+ "type": "roundrobin"
+ }
+ }]]
+
+ if not put('/apisix/admin/routes/gql-tenant-a',
+ (route_tpl:gsub("PATH", "graphql-tenant-a"))) then
return end
+ if not put('/apisix/admin/routes/gql-tenant-b',
+ (route_tpl:gsub("PATH", "graphql-tenant-b"))) then
return end
+
+ local base = "http://127.0.0.1:" .. ngx.var.server_port
+ local body = '{"query":"query{persons{id}}"}'
+
+ local function fetch(path)
+ local res, err = http.new():request_uri(base .. path, {
+ method = "POST",
+ body = body,
+ headers = { ["Content-Type"] = "application/json" },
+ })
+ if not res then
+ return "request failed: " .. (err or "unknown")
+ end
+ return res.headers["Apisix-Cache-Status"]
+ end
+
+ ngx.say("tenant_a_1=", fetch("/graphql-tenant-a"))
+ ngx.say("tenant_a_2=", fetch("/graphql-tenant-a"))
+ ngx.say("tenant_b_1=", fetch("/graphql-tenant-b"))
+ ngx.say("tenant_b_2=", fetch("/graphql-tenant-b"))
+ }
+ }
+--- request
+GET /t
+--- response_body
+tenant_a_1=MISS
+tenant_a_2=HIT
+tenant_b_1=MISS
+tenant_b_2=HIT
+
+
+
+=== TEST 15: cache key includes $host so different Host headers do not share
cache
+--- extra_yaml_config
+plugins:
+ - proxy-rewrite
+ - graphql-proxy-cache
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local http = require("resty.http")
+
+ local code, res_body = t('/apisix/admin/routes/gql-host-iso',
ngx.HTTP_PUT, [[{
+ "uri": "/graphql-host-iso",
+ "plugins": {
+ "proxy-rewrite": {
+ "uri": "/graphql"
+ },
+ "graphql-proxy-cache": {
+ "cache_zone": "memory_cache",
+ "cache_strategy": "memory",
+ "cache_ttl": 300
+ }
+ },
+ "upstream": {
+ "nodes": {"127.0.0.1:8888": 1},
+ "type": "roundrobin"
+ }
+ }]])
+ if code >= 300 then
+ ngx.status = code
+ ngx.say(res_body)
+ return
+ end
+
+ local uri = "http://127.0.0.1:" .. ngx.var.server_port ..
"/graphql-host-iso"
+ local body = '{"query":"query{persons{id}}"}'
+
+ local function fetch(host)
+ local res, err = http.new():request_uri(uri, {
+ method = "POST",
+ body = body,
+ headers = {
+ host = host,
+ ["Content-Type"] = "application/json",
+ },
+ })
+ if not res then
+ return "request failed: " .. (err or "unknown")
+ end
+ return res.headers["Apisix-Cache-Status"]
+ end
+
+ ngx.say("a_1=", fetch("tenant-a.example"))
+ ngx.say("a_2=", fetch("tenant-a.example"))
+ ngx.say("b_1=", fetch("tenant-b.example"))
+ ngx.say("b_2=", fetch("tenant-b.example"))
+ }
+ }
+--- request
+GET /t
+--- response_body
+a_1=MISS
+a_2=HIT
+b_1=MISS
+b_2=HIT