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

Reply via email to