You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@apisix.apache.org by "AlinsRan (via GitHub)" <gi...@apache.org> on 2026/05/25 21:02:36 UTC
[PR] feat(plugin): add graphql-proxy-cache plugin [apisix]
AlinsRan opened a new pull request, #13435:
URL: https://github.com/apache/apisix/pull/13435
## Summary
Add a new `graphql-proxy-cache` plugin that caches GraphQL query responses on disk or in memory, reusing the existing `proxy-cache` infrastructure.
## What does this PR do?
- Adds `apisix/plugins/graphql-proxy-cache.lua`
- Adds tests in `t/plugin/graphql-proxy-cache/graphql.t`
- Adds English documentation `docs/en/latest/plugins/graphql-proxy-cache.md`
- Registers the plugin in the default plugin list (priority: 1009)
## Features
- **Dual caching strategies**: disk (via NGINX `proxy_cache`) or memory (via shared dict)
- **Automatic mutation bypass**: requests containing `mutation` operations are never cached; the cache is bypassed and the upstream is reached directly
- **Deterministic cache key**: `md5(conf_version + host + route_id + service_id + identity + query_body)` — scoped to the route/service/host so two routes sharing the same plugin config never collide
- **Consumer isolation** (default on): each authenticated consumer (by `consumer_name` or `remote_user`) gets its own cache namespace, preventing data leakage across consumers
- **PURGE API**: `PURGE /apisix/plugin/graphql-proxy-cache/:strategy/:route_id/:cache_key` for cache invalidation, exposed via the `public-api` plugin
- **GraphQL over GET and POST**: handles both `application/json` and `application/graphql` content types for POST; query string for GET
## Testing
19 test cases covering:
- Schema validation (invalid zone, invalid strategy)
- Request validation (wrong method, empty body, oversized body, malformed JSON, missing query field)
- Mutation bypass
- Memory cache hit/miss and purge
- Disk cache hit/miss and purge
- Purge with wrong route ID or cache key
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@apisix.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(plugin): add graphql-proxy-cache plugin [apisix]
Posted by "Copilot (via GitHub)" <gi...@apache.org>.
Copilot commented on code in PR #13435:
URL: https://github.com/apache/apisix/pull/13435#discussion_r3306295144
##########
apisix/plugins/graphql-proxy-cache.lua:
##########
@@ -0,0 +1,400 @@
+--
+-- 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
+
+ if not args[GRAPHQL_REQ_QUERY] then
+ return false, "invalid graphql request, args[" ..
+ GRAPHQL_REQ_QUERY .. "] is nil"
+ end
+ return true, args[GRAPHQL_REQ_QUERY]
+ end,
+
+ ["POST"] = function(ctx, body)
+ local content_type = core.request.header(ctx, "Content-Type")
+ if content_type == GRAPHQL_REQ_MIME_JSON then
+ local res, err = core.json.decode(body)
+ 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 content_type == GRAPHQL_REQ_MIME_GRAPHQL then
+ if not core.string.find(body, GRAPHQL_REQ_QUERY) then
+ return false, "invalid graphql request, can't find '" ..
+ GRAPHQL_REQ_QUERY .. "' in request body"
+ end
+ return true, body
+ end
+
+ return false, "invalid graphql request, error content-type: " .. (content_type or "")
+ 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: cant'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 = "Invalid graphql request: no query"}
Review Comment:
When `check_graphql_request` fails, the response always returns `{message = "Invalid graphql request: no query"}` even for other errors (malformed JSON, unsupported content-type, etc.). This makes debugging harder and is inconsistent with other GraphQL plugins (e.g. `graphql-limit-count` returns the specific validation error). Consider returning `query_or_err` (possibly wrapped with a stable prefix) so clients get a meaningful reason.
##########
apisix/plugins/graphql-proxy-cache.lua:
##########
@@ -0,0 +1,400 @@
+--
+-- 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
+
+ if not args[GRAPHQL_REQ_QUERY] then
+ return false, "invalid graphql request, args[" ..
+ GRAPHQL_REQ_QUERY .. "] is nil"
+ end
+ return true, args[GRAPHQL_REQ_QUERY]
+ end,
+
+ ["POST"] = function(ctx, body)
+ local content_type = core.request.header(ctx, "Content-Type")
+ if content_type == GRAPHQL_REQ_MIME_JSON then
+ local res, err = core.json.decode(body)
+ 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 content_type == GRAPHQL_REQ_MIME_GRAPHQL then
+ if not core.string.find(body, GRAPHQL_REQ_QUERY) then
+ return false, "invalid graphql request, can't find '" ..
+ GRAPHQL_REQ_QUERY .. "' in request body"
+ end
+ return true, body
+ end
+
+ return false, "invalid graphql request, error content-type: " .. (content_type or "")
+ 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: cant't get graphql request body"}
Review Comment:
Typo in user-facing error message: `cant't` should be `can't`. This also propagates into tests and makes the API response look unpolished.
##########
t/plugin/graphql-proxy-cache/graphql.t:
##########
@@ -0,0 +1,520 @@
+#
+# 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 exits
+--- 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: cant'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: cant'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: no query/
+
+
+
+=== 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: cant'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: no query/
+
+
+
+=== 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: no query/
+
+
+
+=== 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: no query/
+
+
+
+=== 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
+invalid graphql request, can't find 'query' in request body
+--- response_body eval
+qr/Invalid graphql request: no 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")
Review Comment:
The post-purge assertion is incorrect: `assert(headers["Apisix-Cache-Status"], "MISS")` only checks that the header is present (and uses "MISS" as the failure message). This doesn't actually verify that the cache was missed after purge. Please compare the header value to `"MISS"` explicitly.
##########
apisix/plugins/graphql-proxy-cache.lua:
##########
@@ -0,0 +1,400 @@
+--
+-- 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
+
+ if not args[GRAPHQL_REQ_QUERY] then
+ return false, "invalid graphql request, args[" ..
+ GRAPHQL_REQ_QUERY .. "] is nil"
+ end
+ return true, args[GRAPHQL_REQ_QUERY]
+ end,
+
+ ["POST"] = function(ctx, body)
+ local content_type = core.request.header(ctx, "Content-Type")
+ if content_type == GRAPHQL_REQ_MIME_JSON then
+ local res, err = core.json.decode(body)
+ 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 content_type == GRAPHQL_REQ_MIME_GRAPHQL then
+ if not core.string.find(body, GRAPHQL_REQ_QUERY) then
+ return false, "invalid graphql request, can't find '" ..
+ GRAPHQL_REQ_QUERY .. "' in request body"
+ end
+ return true, body
+ end
+
+ return false, "invalid graphql request, error content-type: " .. (content_type or "")
+ 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: cant'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 = "Invalid graphql request: no query"}
+ end
+
+ local ok, res = pcall(gq_parse, query_or_err)
+ if not ok then
+ core.log.error("failed to parse graphql: ", res, ", body: ", body)
+ 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, body: ", body)
+ 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.info("graphql-proxy-cache plugin access phase, body: ", 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.info("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 ctx.var.upstream_cache_key == "" then
+ return
+ end
Review Comment:
`header_filter` only early-returns when `ctx.var.upstream_cache_key == ""`, but in the mutation-bypass path (and on early 4xx/405 returns) `upstream_cache_key` is never set and will be `nil`. This can cause the proxy-cache header/body filters to run anyway and potentially overwrite `Apisix-Cache-Status: BYPASS` (disk strategy) or apply cache headers unexpectedly. Consider guarding for `nil` as well (and do the same in `body_filter`).
##########
t/plugin/graphql-proxy-cache/graphql.t:
##########
@@ -0,0 +1,520 @@
+#
+# 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 exits
+--- 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: cant'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: cant'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: no query/
+
+
+
+=== 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: cant'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: no query/
+
+
+
+=== 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: no query/
+
+
+
+=== 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: no query/
+
+
+
+=== 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
+invalid graphql request, can't find 'query' in request body
+--- response_body eval
+qr/Invalid graphql request: no 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")
+
+ 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")
Review Comment:
The comment `-- Notice: should rm /tmp/disk_cache*` suggests the test may depend on manual cleanup of on-disk cache state, which can make CI runs flaky or non-reproducible. Consider making the test self-contained by clearing the cache directory programmatically in setup, using a unique cache path per test run, or ensuring the cache key is unique for the test.
##########
t/plugin/graphql-proxy-cache/graphql.t:
##########
@@ -0,0 +1,520 @@
+#
+# 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 exits
+--- 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: cant'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: cant'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: no query/
+
+
+
+=== 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: cant'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: no query/
+
+
+
+=== 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: no query/
+
+
+
+=== 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: no query/
+
+
+
+=== 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
+invalid graphql request, can't find 'query' in request body
+--- response_body eval
+qr/Invalid graphql request: no 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")
+
+ 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")
Review Comment:
Same issue as above: `assert(headers["Apisix-Cache-Status"], "MISS")` doesn't verify a miss after purge; it only checks header existence. Please assert equality to `"MISS"` so the test actually validates purge behavior.
##########
apisix/plugins/graphql-proxy-cache.lua:
##########
@@ -0,0 +1,400 @@
+--
+-- 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
Review Comment:
`check_schema` verifies that `cache_zone` exists but doesn't validate that the zone type matches `cache_strategy` (disk zone vs memory zone). `proxy-cache` rejects mismatches (based on presence of `disk_path`) with a clear error; aligning with that logic would prevent runtime failures like `cache_strategy: memory` + disk cache zone.
##########
apisix/plugins/graphql-proxy-cache.lua:
##########
@@ -0,0 +1,400 @@
+--
+-- 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
+
+ if not args[GRAPHQL_REQ_QUERY] then
+ return false, "invalid graphql request, args[" ..
+ GRAPHQL_REQ_QUERY .. "] is nil"
+ end
+ return true, args[GRAPHQL_REQ_QUERY]
+ end,
+
+ ["POST"] = function(ctx, body)
+ local content_type = core.request.header(ctx, "Content-Type")
+ if content_type == GRAPHQL_REQ_MIME_JSON then
+ local res, err = core.json.decode(body)
+ 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 content_type == GRAPHQL_REQ_MIME_GRAPHQL then
+ if not core.string.find(body, GRAPHQL_REQ_QUERY) then
+ return false, "invalid graphql request, can't find '" ..
+ GRAPHQL_REQ_QUERY .. "' in request body"
+ end
+ return true, body
+ end
+
+ return false, "invalid graphql request, error content-type: " .. (content_type or "")
+ 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: cant'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 = "Invalid graphql request: no query"}
+ end
+
+ local ok, res = pcall(gq_parse, query_or_err)
+ if not ok then
+ core.log.error("failed to parse graphql: ", res, ", body: ", body)
+ 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, body: ", body)
+ 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.info("graphql-proxy-cache plugin access phase, body: ", 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.info("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 ctx.var.upstream_cache_key == "" then
+ return
+ end
+
+ local cache_conf = graphql_cache_conf(ctx, conf)
+ core.log.info("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 ctx.var.upstream_cache_key == "" then
+ return
+ end
+
+ local cache_conf = graphql_cache_conf(ctx, conf)
+ core.log.info("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]
+ local conf = find_graphql_proxy_cache_conf(route_id)
+ if not conf then
+ core.log.error("failed to find graph-proxy-cache conf, route_id: ", route_id)
Review Comment:
Typo in log message: `graph-proxy-cache` should be `graphql-proxy-cache` for easier grepping and consistency with the plugin name.
##########
apisix/plugins/graphql-proxy-cache.lua:
##########
@@ -0,0 +1,400 @@
+--
+-- 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
+
+ if not args[GRAPHQL_REQ_QUERY] then
+ return false, "invalid graphql request, args[" ..
+ GRAPHQL_REQ_QUERY .. "] is nil"
+ end
+ return true, args[GRAPHQL_REQ_QUERY]
+ end,
+
+ ["POST"] = function(ctx, body)
+ local content_type = core.request.header(ctx, "Content-Type")
+ if content_type == GRAPHQL_REQ_MIME_JSON then
+ local res, err = core.json.decode(body)
+ 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 content_type == GRAPHQL_REQ_MIME_GRAPHQL then
+ if not core.string.find(body, GRAPHQL_REQ_QUERY) then
+ return false, "invalid graphql request, can't find '" ..
+ GRAPHQL_REQ_QUERY .. "' in request body"
+ end
+ return true, body
+ end
+
+ return false, "invalid graphql request, error content-type: " .. (content_type or "")
+ 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: cant'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 = "Invalid graphql request: no query"}
+ end
+
+ local ok, res = pcall(gq_parse, query_or_err)
+ if not ok then
+ core.log.error("failed to parse graphql: ", res, ", body: ", body)
+ 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, body: ", body)
+ 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.info("graphql-proxy-cache plugin access phase, body: ", 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.info("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 ctx.var.upstream_cache_key == "" then
+ return
+ end
+
+ local cache_conf = graphql_cache_conf(ctx, conf)
+ core.log.info("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 ctx.var.upstream_cache_key == "" then
+ return
+ end
+
+ local cache_conf = graphql_cache_conf(ctx, conf)
+ core.log.info("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]
Review Comment:
`purge_hander` does not validate that `strategy`, `route_id`, and `cache_key` are present, and treats any strategy other than the literal string `"disk"` as a memory purge. With a malformed URI this can set `upstream_cache_key` to nil and trigger errors (e.g. `ngx.md5(nil)` in filename generation) or purge an unexpected backend. Please validate `strategy` against `{disk,memory}` and return 400/404 on missing/invalid segments.
##########
apisix/plugins/graphql-proxy-cache.lua:
##########
@@ -0,0 +1,400 @@
+--
+-- 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
+
+ if not args[GRAPHQL_REQ_QUERY] then
+ return false, "invalid graphql request, args[" ..
+ GRAPHQL_REQ_QUERY .. "] is nil"
+ end
+ return true, args[GRAPHQL_REQ_QUERY]
+ end,
+
+ ["POST"] = function(ctx, body)
+ local content_type = core.request.header(ctx, "Content-Type")
+ if content_type == GRAPHQL_REQ_MIME_JSON then
+ local res, err = core.json.decode(body)
+ 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 content_type == GRAPHQL_REQ_MIME_GRAPHQL then
+ if not core.string.find(body, GRAPHQL_REQ_QUERY) then
+ return false, "invalid graphql request, can't find '" ..
+ GRAPHQL_REQ_QUERY .. "' in request body"
+ end
+ return true, body
+ end
+
+ return false, "invalid graphql request, error content-type: " .. (content_type or "")
+ 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: cant'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 = "Invalid graphql request: no query"}
+ end
+
+ local ok, res = pcall(gq_parse, query_or_err)
+ if not ok then
+ core.log.error("failed to parse graphql: ", res, ", body: ", body)
+ 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, body: ", body)
+ 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.info("graphql-proxy-cache plugin access phase, body: ", 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.info("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 ctx.var.upstream_cache_key == "" then
+ return
+ end
+
+ local cache_conf = graphql_cache_conf(ctx, conf)
+ core.log.info("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 ctx.var.upstream_cache_key == "" then
+ return
+ end
+
+ local cache_conf = graphql_cache_conf(ctx, conf)
+ core.log.info("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]
+ local conf = find_graphql_proxy_cache_conf(route_id)
+ if not conf then
+ core.log.error("failed to find graph-proxy-cache conf, route_id: ", route_id)
+ return core.response.exit(404)
+ 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 exits: ", filename)
Review Comment:
Typo in log message: `file not exits` should be `file not exists`.
##########
apisix/plugins/graphql-proxy-cache.lua:
##########
@@ -0,0 +1,400 @@
+--
+-- 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
+
+ if not args[GRAPHQL_REQ_QUERY] then
+ return false, "invalid graphql request, args[" ..
+ GRAPHQL_REQ_QUERY .. "] is nil"
+ end
+ return true, args[GRAPHQL_REQ_QUERY]
+ end,
+
+ ["POST"] = function(ctx, body)
+ local content_type = core.request.header(ctx, "Content-Type")
+ if content_type == GRAPHQL_REQ_MIME_JSON then
+ local res, err = core.json.decode(body)
+ 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 content_type == GRAPHQL_REQ_MIME_GRAPHQL then
+ if not core.string.find(body, GRAPHQL_REQ_QUERY) then
+ return false, "invalid graphql request, can't find '" ..
+ GRAPHQL_REQ_QUERY .. "' in request body"
+ end
+ return true, body
+ end
+
+ return false, "invalid graphql request, error content-type: " .. (content_type or "")
+ 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: cant'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 = "Invalid graphql request: no query"}
+ end
+
+ local ok, res = pcall(gq_parse, query_or_err)
+ if not ok then
+ core.log.error("failed to parse graphql: ", res, ", body: ", body)
+ 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, body: ", body)
+ 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.info("graphql-proxy-cache plugin access phase, body: ", 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.info("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 ctx.var.upstream_cache_key == "" then
+ return
+ end
+
+ local cache_conf = graphql_cache_conf(ctx, conf)
+ core.log.info("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 ctx.var.upstream_cache_key == "" then
+ return
+ end
+
+ local cache_conf = graphql_cache_conf(ctx, conf)
+ core.log.info("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]
+ local conf = find_graphql_proxy_cache_conf(route_id)
+ if not conf then
+ core.log.error("failed to find graph-proxy-cache conf, route_id: ", route_id)
+ return core.response.exit(404)
+ 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 exits: ", filename)
+ return core.response.exit(404)
+ end
+ os.remove(filename)
+ else
+ local memory_hanler = memory_strategy({shdict_name = conf.cache_zone})
+ memory_hanler:purge(ngx_var.upstream_cache_key)
Review Comment:
Variable name typo: `memory_hanler` should be `memory_handler` (or similar). This is minor but makes the purge handler harder to read and search.
##########
t/plugin/graphql-proxy-cache/graphql.t:
##########
@@ -0,0 +1,520 @@
+#
+# 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 exits
Review Comment:
Spelling in test title: `zone not exits` should be `zone not exists`. Minor, but these titles show up in test output and logs.
##########
apisix/plugins/graphql-proxy-cache.lua:
##########
@@ -0,0 +1,400 @@
+--
+-- 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
+
+ if not args[GRAPHQL_REQ_QUERY] then
+ return false, "invalid graphql request, args[" ..
+ GRAPHQL_REQ_QUERY .. "] is nil"
+ end
+ return true, args[GRAPHQL_REQ_QUERY]
+ end,
+
+ ["POST"] = function(ctx, body)
+ local content_type = core.request.header(ctx, "Content-Type")
+ if content_type == GRAPHQL_REQ_MIME_JSON then
+ local res, err = core.json.decode(body)
+ 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 content_type == GRAPHQL_REQ_MIME_GRAPHQL then
+ if not core.string.find(body, GRAPHQL_REQ_QUERY) then
+ return false, "invalid graphql request, can't find '" ..
+ GRAPHQL_REQ_QUERY .. "' in request body"
+ end
Review Comment:
For `application/graphql`, rejecting bodies that don't contain the substring `query` will incorrectly reject valid shorthand query documents (e.g. `{ users { id } }`). It should be enough to pass the raw body to `graphql.parse` and let the parser validate the document (similar to `graphql-limit-count`).
##########
apisix/plugins/graphql-proxy-cache.lua:
##########
@@ -0,0 +1,400 @@
+--
+-- 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
+
+ if not args[GRAPHQL_REQ_QUERY] then
+ return false, "invalid graphql request, args[" ..
+ GRAPHQL_REQ_QUERY .. "] is nil"
+ end
+ return true, args[GRAPHQL_REQ_QUERY]
Review Comment:
For GET requests, `core.string.decode_args` can return `args.query` as a table if the parameter appears multiple times. Returning `args["query"]` directly to `graphql.parse` can then error (expects a string). Consider normalizing to the first element when the decoded value is a table.
##########
apisix/plugins/graphql-proxy-cache.lua:
##########
@@ -0,0 +1,400 @@
+--
+-- 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
+
+ if not args[GRAPHQL_REQ_QUERY] then
+ return false, "invalid graphql request, args[" ..
+ GRAPHQL_REQ_QUERY .. "] is nil"
+ end
+ return true, args[GRAPHQL_REQ_QUERY]
+ end,
+
+ ["POST"] = function(ctx, body)
+ local content_type = core.request.header(ctx, "Content-Type")
+ if content_type == GRAPHQL_REQ_MIME_JSON then
+ local res, err = core.json.decode(body)
+ 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 content_type == GRAPHQL_REQ_MIME_GRAPHQL then
+ if not core.string.find(body, GRAPHQL_REQ_QUERY) then
+ return false, "invalid graphql request, can't find '" ..
+ GRAPHQL_REQ_QUERY .. "' in request body"
+ end
+ return true, body
+ end
+
+ return false, "invalid graphql request, error content-type: " .. (content_type or "")
+ 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: cant'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 = "Invalid graphql request: no query"}
+ end
+
+ local ok, res = pcall(gq_parse, query_or_err)
+ if not ok then
+ core.log.error("failed to parse graphql: ", res, ", body: ", body)
+ 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, body: ", body)
+ 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.info("graphql-proxy-cache plugin access phase, body: ", body)
+
Review Comment:
Logging the full GraphQL request body at `info` level can leak sensitive data (query text/variables) and adds significant log volume on hot paths. Consider removing the body from logs or moving it to `debug` with truncation/redaction.
##########
apisix/plugins/graphql-proxy-cache.lua:
##########
@@ -0,0 +1,400 @@
+--
+-- 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
+
+ if not args[GRAPHQL_REQ_QUERY] then
+ return false, "invalid graphql request, args[" ..
+ GRAPHQL_REQ_QUERY .. "] is nil"
+ end
+ return true, args[GRAPHQL_REQ_QUERY]
+ end,
+
+ ["POST"] = function(ctx, body)
+ local content_type = core.request.header(ctx, "Content-Type")
+ if content_type == GRAPHQL_REQ_MIME_JSON then
+ local res, err = core.json.decode(body)
Review Comment:
`Content-Type` handling is too strict: it compares for exact equality with `application/json`, so common values like `application/json; charset=utf-8` will be rejected. Other GraphQL plugins (e.g. `graphql-limit-count`) use `core.string.has_prefix` for this; consider switching to prefix matching (also for `application/graphql`).
##########
apisix/plugins/graphql-proxy-cache.lua:
##########
@@ -0,0 +1,400 @@
+--
+-- 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
+
+ if not args[GRAPHQL_REQ_QUERY] then
+ return false, "invalid graphql request, args[" ..
+ GRAPHQL_REQ_QUERY .. "] is nil"
+ end
+ return true, args[GRAPHQL_REQ_QUERY]
+ end,
+
+ ["POST"] = function(ctx, body)
+ local content_type = core.request.header(ctx, "Content-Type")
+ if content_type == GRAPHQL_REQ_MIME_JSON then
+ local res, err = core.json.decode(body)
+ 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 content_type == GRAPHQL_REQ_MIME_GRAPHQL then
+ if not core.string.find(body, GRAPHQL_REQ_QUERY) then
+ return false, "invalid graphql request, can't find '" ..
+ GRAPHQL_REQ_QUERY .. "' in request body"
+ end
+ return true, body
+ end
+
+ return false, "invalid graphql request, error content-type: " .. (content_type or "")
+ 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: cant'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 = "Invalid graphql request: no query"}
+ end
+
+ local ok, res = pcall(gq_parse, query_or_err)
+ if not ok then
+ core.log.error("failed to parse graphql: ", res, ", body: ", body)
+ 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, body: ", body)
+ 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.info("graphql-proxy-cache plugin access phase, body: ", 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.info("graphql-proxy-cache cache key value:", value)
Review Comment:
The cache key is logged at `info` level for every request. Even though it's a hash, it can still aid correlation of user activity and adds log volume; consider dropping it or moving to `debug` if needed for troubleshooting.
##########
t/plugin/graphql-proxy-cache/graphql.t:
##########
@@ -0,0 +1,520 @@
+#
+# 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 exits
+--- 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: cant't get graphql request body/
+
Review Comment:
The test currently expects the typo `cant't` in the error message. Once the plugin message is corrected to `can't`, update this regex accordingly to avoid locking in the misspelling.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@apisix.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(plugin): add graphql-proxy-cache plugin [apisix]
Posted by "AlinsRan (via GitHub)" <gi...@apache.org>.
AlinsRan merged PR #13435:
URL: https://github.com/apache/apisix/pull/13435
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@apisix.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] feat(plugin): add graphql-proxy-cache plugin [apisix]
Posted by "membphis (via GitHub)" <gi...@apache.org>.
membphis commented on PR #13435:
URL: https://github.com/apache/apisix/pull/13435#issuecomment-4561230280
Review notes from merge-risk check:
### [P1] `graphql-proxy-cache` can be enabled without the required NGINX proxy-cache variables/directives
- Problem: the new plugin writes/uses `upstream_cache_key`, `upstream_cache_zone`, `upstream_cache_bypass`, `upstream_no_cache`, and disk purge relies on `upstream_cache_zone_info`, but the OSS NGINX template only defines these variables and `proxy_cache*` directives when `enabled_plugins["proxy-cache"]` is true.
- Why this blocks merge: `graphql-proxy-cache` is registered as an independent plugin and the tests/docs show it being enabled directly. In custom deployments that include `graphql-proxy-cache` but not `proxy-cache`, the plugin can hit undefined NGINX variables or run without the required disk cache configuration.
- Impact: runtime failures or non-functional disk cache/purge for users with a minimal custom plugin list.
- Trigger: configure `plugins:` with `graphql-proxy-cache` but without `proxy-cache`, then enable the plugin on a route.
- Evidence: `apisix/plugins/graphql-proxy-cache.lua` sets `ctx.var.upstream_cache_key` and purge sets `ngx_var.upstream_cache_key`; `apisix/cli/ngx_tpl.lua` wraps cache variable/directive generation in `enabled_plugins["proxy-cache"]`; `t/plugin/graphql-proxy-cache/graphql.t` enables only `graphql-proxy-cache` and `public-api` in its minimal plugin list.
- Suggested fix: treat `graphql-proxy-cache` as a proxy-cache user in the template and startup validation, e.g. generate the cache variables/directives and require `apisix.proxy_cache` when either `proxy-cache` or `graphql-proxy-cache` is enabled. Add a startup/runtime test that enables only `graphql-proxy-cache`.
### [P2] Parse-error logging still writes the full GraphQL request body to error log
- Problem: malformed GraphQL requests and empty query paths still log the raw `body` value.
- Why this blocks merge: GraphQL bodies frequently contain variables or business data. A bad query should not cause request payloads to be written to `error.log`.
- Impact: sensitive request data can leak into logs for any route using this plugin.
- Trigger: send an invalid GraphQL query or an empty query body.
- Evidence: `apisix/plugins/graphql-proxy-cache.lua` logs `failed to parse graphql: ..., body: ...` and `failed to parse graphql: empty query, body: ...`.
- Suggested fix: log parser error plus `body_size`/request id only, not the raw body; add a regression test that malformed input is rejected without body content appearing in logs.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@apisix.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org