This is an automated email from the ASF dual-hosted git repository.

AlinsRan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git


The following commit(s) were added to refs/heads/master by this push:
     new 77b328c06 feat(plugin): add graphql-limit-count plugin (#13372)
77b328c06 is described below

commit 77b328c06b341b0af0a95c3b6f06c75a39f8af07
Author: AlinsRan <[email protected]>
AuthorDate: Tue May 26 13:33:39 2026 +0800

    feat(plugin): add graphql-limit-count plugin (#13372)
---
 apisix/cli/config.lua                         |   3 +
 apisix/cli/ngx_tpl.lua                        |   8 +
 apisix/plugins/graphql-limit-count.lua        | 205 +++++++++
 conf/config.yaml.example                      |   3 +
 docs/en/latest/config.json                    |   1 +
 docs/en/latest/plugins/graphql-limit-count.md | 179 ++++++++
 docs/zh/latest/config.json                    |   1 +
 docs/zh/latest/plugins/graphql-limit-count.md | 179 ++++++++
 t/APISIX.pm                                   |   2 +
 t/admin/plugins.t                             |   1 +
 t/plugin/graphql-limit-count.t                | 635 ++++++++++++++++++++++++++
 11 files changed, 1217 insertions(+)

diff --git a/apisix/cli/config.lua b/apisix/cli/config.lua
index 4a3fa3534..f7121e1d1 100644
--- a/apisix/cli/config.lua
+++ b/apisix/cli/config.lua
@@ -171,6 +171,8 @@ local _M = {
         ["plugin-limit-req-redis-cluster-slot-lock"] = "1m",
         ["plugin-limit-count-redis-cluster-slot-lock"] = "1m",
         ["plugin-limit-conn-redis-cluster-slot-lock"] = "1m",
+        ["plugin-graphql-limit-count"] = "10m",
+        ["plugin-graphql-limit-count-reset-header"] = "10m",
         ["plugin-ai-rate-limiting"] = "10m",
         ["plugin-ai-rate-limiting-reset-header"] = "10m",
         tracing_buffer = "10m",
@@ -245,6 +247,7 @@ local _M = {
     "proxy-rewrite",
     "workflow",
     "api-breaker",
+    "graphql-limit-count",
     "limit-conn",
     "limit-count",
     "limit-req",
diff --git a/apisix/cli/ngx_tpl.lua b/apisix/cli/ngx_tpl.lua
index b823bd182..969deb6c6 100644
--- a/apisix/cli/ngx_tpl.lua
+++ b/apisix/cli/ngx_tpl.lua
@@ -327,6 +327,14 @@ http {
     lua_shared_dict plugin-limit-count-reset-header {* 
http.lua_shared_dict["plugin-limit-count"] *};
     {% end %}
 
+    {% if enabled_plugins["graphql-limit-count"] then %}
+    lua_shared_dict plugin-graphql-limit-count {* 
http.lua_shared_dict["plugin-graphql-limit-count"] *};
+    lua_shared_dict plugin-graphql-limit-count-reset-header {* 
http.lua_shared_dict["plugin-graphql-limit-count-reset-header"] *};
+    {% if not enabled_plugins["limit-count"] then %}
+    lua_shared_dict plugin-limit-count-redis-cluster-slot-lock {* 
http.lua_shared_dict["plugin-limit-count-redis-cluster-slot-lock"] *};
+    {% end %}
+    {% end %}
+
     {% if enabled_plugins["prometheus"] and not 
enabled_stream_plugins["prometheus"] then %}
     lua_shared_dict prometheus-metrics {* 
http.lua_shared_dict["prometheus-metrics"] *};
     {% end %}
diff --git a/apisix/plugins/graphql-limit-count.lua 
b/apisix/plugins/graphql-limit-count.lua
new file mode 100644
index 000000000..af5a72e82
--- /dev/null
+++ b/apisix/plugins/graphql-limit-count.lua
@@ -0,0 +1,205 @@
+--
+-- 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 limit_count     = require("apisix.plugins.limit-count.init")
+local core            = require("apisix.core")
+local config_local    = require("apisix.core.config_local")
+local gq_parse        = require("graphql").parse
+local limit_count_ver = require("resty.limit.count")._VERSION
+
+local type    = type
+local pairs   = pairs
+local ipairs  = ipairs
+local pcall   = pcall
+local max     = math.max
+local tonumber = tonumber
+
+local GRAPHQL_DEFAULT_MAX_SIZE = 1048576
+
+local plugin_name = "graphql-limit-count"
+local _M = {
+    version = 0.1,
+    priority = 1004,
+    name = plugin_name,
+    schema = limit_count.schema,
+}
+
+
+function _M.check_schema(conf)
+    return limit_count.check_schema(conf)
+end
+
+
+local GRAPHQL_REQ_QUERY     = "query"
+local GRAPHQL_REQ_MIME_JSON = "application/json"
+local GRAPHQL_REQ_MIME_GQL  = "application/graphql"
+
+
+local fetch_graphql_body = {
+    ["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 = {
+    ["POST"] = function(ctx, body)
+        local content_type = core.request.header(ctx, "Content-Type") or ""
+
+        if core.string.has_prefix(content_type, GRAPHQL_REQ_MIME_JSON) then
+            local res, err = core.json.decode(body)
+            if not res then
+                return false, "invalid graphql request, " .. err
+            end
+
+            if not res[GRAPHQL_REQ_QUERY] then
+                return false, "invalid graphql request, json body[" ..
+                                GRAPHQL_REQ_QUERY .. "] is nil"
+            end
+
+            return true, res[GRAPHQL_REQ_QUERY]
+        end
+
+        if core.string.has_prefix(content_type, GRAPHQL_REQ_MIME_GQL) then
+            return true, body
+        end
+
+        return false, "invalid graphql request, error content-type: " .. 
content_type
+    end
+}
+
+
+-- Returns the maximum selection nesting depth of the GraphQL query AST.
+-- Fragment spreads are expanded in place using the provided fragment map;
+-- inline fragments are treated as transparent wrappers over their selections.
+-- The visited table guards against fragment definition cycles.
+local function node_depth(node, fragments, visited)
+    if type(node) ~= "table" then
+        return 0
+    end
+
+    if node.kind == "fragmentSpread" then
+        local name = node.name and node.name.value
+        if not name or visited[name] then
+            return 0
+        end
+        local frag = fragments[name]
+        if not frag or not frag.selectionSet then
+            return 0
+        end
+        visited[name] = true
+        local depth = node_depth(frag.selectionSet.selections, fragments, 
visited)
+        visited[name] = nil
+        return depth
+    end
+
+    if node.kind == "inlineFragment" then
+        if not node.selectionSet then
+            return 0
+        end
+        return node_depth(node.selectionSet.selections, fragments, visited)
+    end
+
+    local depth = 0
+    for k, v in pairs(node) do
+        local child
+        if k == "selections" then
+            child = 1 + node_depth(v, fragments, visited)
+        else
+            child = node_depth(v, fragments, visited)
+        end
+        depth = max(depth, child)
+    end
+
+    return depth
+end
+
+
+function _M.access(conf, ctx)
+    if limit_count_ver < '1.0.0' then
+        core.log.error("need to build APISIX-Base to support GraphQL limit 
count")
+        return 501
+    end
+
+    local method = core.request.get_method()
+    if method ~= "POST" then
+        return 405
+    end
+
+    local max_size = GRAPHQL_DEFAULT_MAX_SIZE
+    local local_conf = config_local.local_conf()
+    if local_conf then
+        local size = core.table.try_read_attr(local_conf, "graphql", 
"max_size")
+        if size then
+            local size_num = tonumber(size)
+            if size_num and size_num > 0 then
+                max_size = size_num
+            end
+        end
+    end
+
+    local body, err = fetch_graphql_body[method](ctx, max_size)
+    if not body then
+        core.log.error(err)
+        return 400, {message = "Invalid graphql request: can't get graphql 
request body"}
+    end
+
+    local is_graphql_req, query_or_err = check_graphql_request[method](ctx, 
body)
+    if not is_graphql_req then
+        core.log.error(query_or_err)
+        return 400, {message = query_or_err}
+    end
+
+    local ok, res = pcall(gq_parse, query_or_err)
+    if not ok then
+        core.log.error("failed to parse graphql: ", res)
+        return 400, {message = "Invalid graphql request: failed to parse 
graphql query"}
+    end
+
+    -- Split definitions into executable operations and named fragment 
definitions.
+    local fragments = {}
+    local operations = {}
+    for _, def in ipairs(res.definitions) do
+        if def.kind == "fragmentDefinition" then
+            fragments[def.name.value] = def
+        else
+            operations[#operations + 1] = def
+        end
+    end
+
+    if #operations == 0 then
+        core.log.error("failed to parse graphql: empty query")
+        return 400, {message = "Invalid graphql request: empty graphql query"}
+    end
+
+    local depth = 0
+    for _, op in ipairs(operations) do
+        local d = node_depth(op, fragments, {})
+        depth = max(depth, d)
+    end
+    depth = max(depth, 1)
+    core.log.info("graphql query depth: ", depth)
+
+    return limit_count.rate_limit(conf, ctx, plugin_name, depth)
+end
+
+
+return _M
diff --git a/conf/config.yaml.example b/conf/config.yaml.example
index 458bbf0a9..3bf6a73da 100644
--- a/conf/config.yaml.example
+++ b/conf/config.yaml.example
@@ -300,6 +300,8 @@ nginx_config:                     # Config for render the 
template to generate n
       plugin-limit-req-redis-cluster-slot-lock: 1m
       plugin-limit-count-redis-cluster-slot-lock: 1m
       plugin-limit-conn-redis-cluster-slot-lock: 1m
+      plugin-graphql-limit-count: 10m
+      plugin-graphql-limit-count-reset-header: 10m
       tracing_buffer: 10m
       plugin-api-breaker: 10m
       etcd-cluster-health-check: 10m
@@ -531,6 +533,7 @@ plugins:                           # plugin list (sorted by 
priority)
   - proxy-rewrite                  # priority: 1008
   - workflow                       # priority: 1006
   - api-breaker                    # priority: 1005
+  - graphql-limit-count            # priority: 1004
   - limit-conn                     # priority: 1003
   - limit-count                    # priority: 1002
   - limit-req                      # priority: 1001
diff --git a/docs/en/latest/config.json b/docs/en/latest/config.json
index 47f9b2326..ad930b96a 100644
--- a/docs/en/latest/config.json
+++ b/docs/en/latest/config.json
@@ -165,6 +165,7 @@
             "plugins/limit-req",
             "plugins/limit-conn",
             "plugins/limit-count",
+            "plugins/graphql-limit-count",
             "plugins/proxy-cache",
             "plugins/request-validation",
             "plugins/oas-validator",
diff --git a/docs/en/latest/plugins/graphql-limit-count.md 
b/docs/en/latest/plugins/graphql-limit-count.md
new file mode 100644
index 000000000..2633131ca
--- /dev/null
+++ b/docs/en/latest/plugins/graphql-limit-count.md
@@ -0,0 +1,179 @@
+---
+title: graphql-limit-count
+keywords:
+  - Apache APISIX
+  - API Gateway
+  - Plugin
+  - graphql-limit-count
+  - Rate Limiting
+  - GraphQL
+description: The graphql-limit-count Plugin limits the rate of GraphQL 
requests based on the query AST depth within a given time window, using the 
same counting mechanism as the limit-count Plugin.
+---
+
+<!--
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+-->
+
+<head>
+  <link rel="canonical" href="https://docs.api7.ai/hub/graphql-limit-count"; />
+</head>
+
+## Description
+
+The `graphql-limit-count` Plugin limits the rate of GraphQL requests using a 
fixed window algorithm. Unlike [limit-count](./limit-count.md), which counts 
each request as a cost of 1, this Plugin uses the **depth of the GraphQL query 
AST** as the cost. This allows you to enforce stricter limits on deeply nested 
queries that are more expensive to process.
+
+Only `POST` requests are supported. The Plugin accepts two content types:
+
+- `application/json`: request body must contain a `query` field with the 
GraphQL query string.
+- `application/graphql`: request body is the raw GraphQL query starting with 
`query`.
+
+You may see the following rate limiting headers in the response:
+
+- `X-RateLimit-Limit`: the total quota
+- `X-RateLimit-Remaining`: the remaining quota
+- `X-RateLimit-Reset`: number of seconds left for the counter to reset
+
+## Attributes
+
+This Plugin shares the same schema as the [limit-count](./limit-count.md) 
Plugin. Refer to that page for the full attribute reference. Key attributes are 
listed below.
+
+| Name | Type | Required | Default | Valid values | Description |
+|------|------|----------|---------|--------------|-------------|
+| count | integer or string | False | | > 0 | The maximum allowed accumulated 
query AST depth within the time window. Required if `rules` is not configured. |
+| time_window | integer or string | False | | > 0 | The time interval in 
seconds for the rate limiting window. Required if `rules` is not configured. |
+| key_type | string | False | var | ["var", "var_combination", "constant"] | 
The type of key. `var` treats `key` as an NGINX variable. `var_combination` 
combines multiple variables. `constant` uses `key` as a fixed value. |
+| key | string | False | remote_addr | | The key to count requests by. |
+| rejected_code | integer | False | 503 | [200,...,599] | HTTP status code 
returned when a request is rejected for exceeding the quota. |
+| rejected_msg | string | False | | non-empty | Response body returned when a 
request is rejected. |
+| policy | string | False | local | ["local", "redis", "redis-cluster"] | 
Counter storage policy. `local` stores the counter in memory on the current 
APISIX node. `redis` and `redis-cluster` share counters across instances. |
+| allow_degradation | boolean | False | false | | When true, APISIX continues 
handling requests if the Plugin or its dependencies become unavailable. |
+| show_limit_quota_header | boolean | False | true | | When true, include 
`X-RateLimit-Limit` and `X-RateLimit-Remaining` headers in the response. |
+| group | string | False | | non-empty | Group ID to share a single rate 
limiting counter across multiple routes. |
+| redis_host | string | False | | | Address of the Redis node. Required when 
`policy` is `redis`. |
+| redis_port | integer | False | 6379 | [1,...] | Port of the Redis node. Used 
when `policy` is `redis`. |
+| redis_username | string | False | | | Username for Redis ACL authentication. 
Used when `policy` is `redis`. |
+| redis_password | string | False | | | Password of the Redis node. Used when 
`policy` is `redis` or `redis-cluster`. |
+| redis_ssl | boolean | False | false | | When true, use SSL to connect to 
Redis. Used when `policy` is `redis`. |
+| redis_ssl_verify | boolean | False | false | | When true, verify the Redis 
server SSL certificate. Used when `policy` is `redis`. |
+| redis_database | integer | False | 0 | >= 0 | The Redis database number. 
Used when `policy` is `redis`. |
+| redis_timeout | integer | False | 1000 | [1,...] | Redis timeout in 
milliseconds. Used when `policy` is `redis` or `redis-cluster`. |
+| redis_cluster_nodes | array[string] | False | | | List of Redis cluster node 
addresses. Required when `policy` is `redis-cluster`. |
+| redis_cluster_name | string | False | | | Name of the Redis cluster. 
Required when `policy` is `redis-cluster`. |
+| redis_cluster_ssl | boolean | False | false | | When true, use SSL to 
connect to the Redis cluster. Used when `policy` is `redis-cluster`. |
+| redis_cluster_ssl_verify | boolean | False | false | | When true, verify the 
Redis cluster server SSL certificate. Used when `policy` is `redis-cluster`. |
+
+## Examples
+
+The examples below demonstrate how you can configure `graphql-limit-count` in 
different scenarios.
+
+:::note
+
+```bash
+admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed 
's/"//g')
+```
+
+:::
+
+### Limit Requests by Query Depth per Client
+
+The following example demonstrates how to rate limit GraphQL requests based on 
the accumulated query AST depth per client IP address. A shallow query like `{ 
foo { bar } }` (depth 2) consumes 2 out of the quota, while a deeply nested 
query like `{ foo { bar { baz { id } } } }` (depth 4) consumes 4.
+
+Create a Route with `graphql-limit-count` that allows a cumulative query depth 
of 10 per minute per client IP:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes"; -X PUT \
+  -H "X-API-KEY: ${admin_key}" \
+  -d '{
+    "id": "graphql-limit-count-route",
+    "uri": "/graphql",
+    "plugins": {
+      "graphql-limit-count": {
+        "count": 10,
+        "time_window": 60,
+        "rejected_code": 429,
+        "key_type": "var",
+        "key": "remote_addr",
+        "policy": "local"
+      }
+    },
+    "upstream": {
+      "type": "roundrobin",
+      "nodes": {
+        "127.0.0.1:1980": 1
+      }
+    }
+  }'
+```
+
+Send a depth-4 GraphQL query:
+
+```shell
+curl -i "http://127.0.0.1:9080/graphql"; \
+  -H "Content-Type: application/json" \
+  -d '{"query": "query { foo { bar { baz { id } } } }"}'
+```
+
+You should receive an `HTTP/1.1 200 OK` response with the following headers:
+
+```text
+X-RateLimit-Limit: 10
+X-RateLimit-Remaining: 6
+```
+
+The depth-4 query consumed 4 out of the 10 quota. After the quota is exhausted 
within the time window, you will receive `HTTP/1.1 429 Too Many Requests`.
+
+### Share Quota Among APISIX Nodes with a Redis Server
+
+The following example demonstrates how to use a Redis-backed counter so that 
the rate limiting quota is shared across multiple APISIX instances.
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes"; -X PUT \
+  -H "X-API-KEY: ${admin_key}" \
+  -d '{
+    "id": "graphql-limit-count-route",
+    "uri": "/graphql",
+    "plugins": {
+      "graphql-limit-count": {
+        "count": 100,
+        "time_window": 60,
+        "rejected_code": 429,
+        "key_type": "var",
+        "key": "remote_addr",
+        "policy": "redis",
+        "redis_host": "127.0.0.1",
+        "redis_port": 6379
+      }
+    },
+    "upstream": {
+      "type": "roundrobin",
+      "nodes": {
+        "127.0.0.1:1980": 1
+      }
+    }
+  }'
+```
+
+Send a request to verify:
+
+```shell
+curl -i "http://127.0.0.1:9080/graphql"; \
+  -H "Content-Type: application/json" \
+  -d '{"query": "query { foo { bar } }"}'
+```
+
+You should receive an `HTTP/1.1 200 OK` response. The counter is now shared 
across all APISIX nodes connected to the same Redis instance.
diff --git a/docs/zh/latest/config.json b/docs/zh/latest/config.json
index c0de6559a..f688efe97 100644
--- a/docs/zh/latest/config.json
+++ b/docs/zh/latest/config.json
@@ -153,6 +153,7 @@
             "plugins/limit-req",
             "plugins/limit-conn",
             "plugins/limit-count",
+            "plugins/graphql-limit-count",
             "plugins/proxy-cache",
             "plugins/request-validation",
             "plugins/oas-validator",
diff --git a/docs/zh/latest/plugins/graphql-limit-count.md 
b/docs/zh/latest/plugins/graphql-limit-count.md
new file mode 100644
index 000000000..96c2b8347
--- /dev/null
+++ b/docs/zh/latest/plugins/graphql-limit-count.md
@@ -0,0 +1,179 @@
+---
+title: graphql-limit-count
+keywords:
+  - Apache APISIX
+  - API 网关
+  - Plugin
+  - graphql-limit-count
+  - 限流
+  - GraphQL
+description: graphql-limit-count 插件使用固定窗口算法,基于 GraphQL 查询 AST 深度对请求速率进行限制,采用与 
limit-count 插件相同的计数机制。
+---
+
+<!--
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+-->
+
+<head>
+  <link rel="canonical" href="https://docs.api7.ai/hub/graphql-limit-count"; />
+</head>
+
+## 描述
+
+`graphql-limit-count` 插件使用固定窗口算法对 GraphQL 请求进行速率限制。与每个请求消耗固定计数 1 的 
[limit-count](./limit-count.md) 不同,本插件以 **GraphQL 查询 AST 
的深度**作为每次请求的消耗代价,对嵌套层级更深、处理代价更高的查询施加更严格的限制。
+
+仅支持 `POST` 方法。插件支持两种内容类型:
+
+- `application/json`:请求体必须包含 `query` 字段,值为 GraphQL 查询字符串。
+- `application/graphql`:请求体为以 `query` 开头的原始 GraphQL 查询。
+
+响应中可能包含以下限流相关的响应头:
+
+- `X-RateLimit-Limit`:总配额
+- `X-RateLimit-Remaining`:剩余配额
+- `X-RateLimit-Reset`:计数器重置的剩余秒数
+
+## 属性
+
+本插件与 [limit-count](./limit-count.md) 插件共享相同的 Schema,完整属性参考请见该页面。关键属性如下所示。
+
+| 名称 | 类型 | 必选项 | 默认值 | 有效值 | 描述 |
+|------|------|--------|--------|--------|------|
+| count | integer or string | 否 | | > 0 | 时间窗口内允许的最大累计查询 AST 深度。当未配置 `rules` 
时必填。 |
+| time_window | integer or string | 否 | | > 0 | 限流时间窗口(秒)。当未配置 `rules` 时必填。 |
+| key_type | string | 否 | var | ["var", "var_combination", "constant"] | key 
的类型。`var` 将 `key` 解释为 NGINX 变量;`var_combination` 将多个变量组合;`constant` 将 `key` 
作为固定值。 |
+| key | string | 否 | remote_addr | | 用于计数的 key。 |
+| rejected_code | integer | 否 | 503 | [200,...,599] | 请求超出配额时返回的 HTTP 状态码。 |
+| rejected_msg | string | 否 | | 非空 | 请求被拒绝时返回的响应体。 |
+| policy | string | 否 | local | ["local", "redis", "redis-cluster"] | 
限流计数器的存储策略。`local` 使用当前 APISIX 节点内存;`redis` 和 `redis-cluster` 在多个实例间共享计数器。 |
+| allow_degradation | boolean | 否 | false | | 为 true 时,插件或依赖不可用时 APISIX 
仍继续处理请求。 |
+| show_limit_quota_header | boolean | 否 | true | | 为 true 时,在响应中包含 
`X-RateLimit-Limit` 和 `X-RateLimit-Remaining` 响应头。 |
+| group | string | 否 | | 非空 | Group ID,用于在多个路由之间共享同一个限流计数器。 |
+| redis_host | string | 否 | | | Redis 节点地址。`policy` 为 `redis` 时必填。 |
+| redis_port | integer | 否 | 6379 | [1,...] | Redis 节点端口。`policy` 为 `redis` 
时使用。 |
+| redis_username | string | 否 | | | Redis ACL 认证用户名。`policy` 为 `redis` 时使用。 |
+| redis_password | string | 否 | | | Redis 节点密码。`policy` 为 `redis` 或 
`redis-cluster` 时使用。 |
+| redis_ssl | boolean | 否 | false | | 为 true 时使用 SSL 连接 Redis。`policy` 为 
`redis` 时使用。 |
+| redis_ssl_verify | boolean | 否 | false | | 为 true 时验证 Redis 服务端 SSL 
证书。`policy` 为 `redis` 时使用。 |
+| redis_database | integer | 否 | 0 | >= 0 | Redis 数据库编号。`policy` 为 `redis` 
时使用。 |
+| redis_timeout | integer | 否 | 1000 | [1,...] | Redis 超时时间(毫秒)。`policy` 为 
`redis` 或 `redis-cluster` 时使用。 |
+| redis_cluster_nodes | array[string] | 否 | | | Redis 集群节点地址列表。`policy` 为 
`redis-cluster` 时必填。 |
+| redis_cluster_name | string | 否 | | | Redis 集群名称。`policy` 为 `redis-cluster` 
时必填。 |
+| redis_cluster_ssl | boolean | 否 | false | | 为 true 时使用 SSL 连接 Redis 
集群。`policy` 为 `redis-cluster` 时使用。 |
+| redis_cluster_ssl_verify | boolean | 否 | false | | 为 true 时验证 Redis 集群服务端 
SSL 证书。`policy` 为 `redis-cluster` 时使用。 |
+
+## 示例
+
+以下示例演示了如何在不同场景中配置 `graphql-limit-count` 插件。
+
+:::note
+
+```bash
+admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed 
's/"//g')
+```
+
+:::
+
+### 按客户端 IP 对查询深度限流
+
+以下示例演示如何按客户端 IP 地址对 GraphQL 请求按累计查询 AST 深度进行限流。浅层查询(如 `{ foo { bar } }`,深度 
2)消耗 2 个配额,深层嵌套查询(如 `{ foo { bar { baz { id } } } }`,深度 4)消耗 4 个配额。
+
+创建一个路由,配置 `graphql-limit-count`,允许每个客户端 IP 每分钟累计查询深度为 10:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes"; -X PUT \
+  -H "X-API-KEY: ${admin_key}" \
+  -d '{
+    "id": "graphql-limit-count-route",
+    "uri": "/graphql",
+    "plugins": {
+      "graphql-limit-count": {
+        "count": 10,
+        "time_window": 60,
+        "rejected_code": 429,
+        "key_type": "var",
+        "key": "remote_addr",
+        "policy": "local"
+      }
+    },
+    "upstream": {
+      "type": "roundrobin",
+      "nodes": {
+        "127.0.0.1:1980": 1
+      }
+    }
+  }'
+```
+
+发送一个深度为 4 的 GraphQL 查询:
+
+```shell
+curl -i "http://127.0.0.1:9080/graphql"; \
+  -H "Content-Type: application/json" \
+  -d '{"query": "query { foo { bar { baz { id } } } }"}'
+```
+
+您将收到 `HTTP/1.1 200 OK` 响应,响应头如下:
+
+```text
+X-RateLimit-Limit: 10
+X-RateLimit-Remaining: 6
+```
+
+深度 4 的查询消耗了 10 个配额中的 4 个。时间窗口内配额耗尽后,将收到 `HTTP/1.1 429 Too Many Requests` 响应。
+
+### 使用 Redis 在多个 APISIX 节点间共享配额
+
+以下示例演示如何使用 Redis 后端计数器,在多个 APISIX 实例之间共享限流配额。
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes"; -X PUT \
+  -H "X-API-KEY: ${admin_key}" \
+  -d '{
+    "id": "graphql-limit-count-route",
+    "uri": "/graphql",
+    "plugins": {
+      "graphql-limit-count": {
+        "count": 100,
+        "time_window": 60,
+        "rejected_code": 429,
+        "key_type": "var",
+        "key": "remote_addr",
+        "policy": "redis",
+        "redis_host": "127.0.0.1",
+        "redis_port": 6379
+      }
+    },
+    "upstream": {
+      "type": "roundrobin",
+      "nodes": {
+        "127.0.0.1:1980": 1
+      }
+    }
+  }'
+```
+
+发送请求验证:
+
+```shell
+curl -i "http://127.0.0.1:9080/graphql"; \
+  -H "Content-Type: application/json" \
+  -d '{"query": "query { foo { bar } }"}'
+```
+
+您将收到 `HTTP/1.1 200 OK` 响应。所有连接到同一 Redis 实例的 APISIX 节点将共享同一个限流计数器。
diff --git a/t/APISIX.pm b/t/APISIX.pm
index b3cafb832..9f862cfec 100644
--- a/t/APISIX.pm
+++ b/t/APISIX.pm
@@ -595,6 +595,8 @@ _EOC_
     lua_shared_dict plugin-limit-conn 10m;
     lua_shared_dict plugin-ai-rate-limiting 10m;
     lua_shared_dict plugin-ai-rate-limiting-reset-header 10m;
+    lua_shared_dict plugin-graphql-limit-count 10m;
+    lua_shared_dict plugin-graphql-limit-count-reset-header 10m;
     lua_shared_dict internal-status 10m;
     lua_shared_dict worker-events 10m;
     lua_shared_dict lrucache-lock 10m;
diff --git a/t/admin/plugins.t b/t/admin/plugins.t
index 8f13a99f3..eecb0e897 100644
--- a/t/admin/plugins.t
+++ b/t/admin/plugins.t
@@ -111,6 +111,7 @@ proxy-mirror
 proxy-rewrite
 workflow
 api-breaker
+graphql-limit-count
 limit-conn
 limit-count
 limit-req
diff --git a/t/plugin/graphql-limit-count.t b/t/plugin/graphql-limit-count.t
new file mode 100644
index 000000000..f95ee3194
--- /dev/null
+++ b/t/plugin/graphql-limit-count.t
@@ -0,0 +1,635 @@
+#
+# 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_shuffle();
+no_root_location();
+
+add_block_preprocessor(sub {
+    my ($block) = @_;
+
+    my $extra_yaml_config = $block->extra_yaml_config // <<_EOC_;
+plugins:
+    - graphql-limit-count
+_EOC_
+
+    $block->set_value("extra_yaml_config", $extra_yaml_config);
+
+    my $extra_init_worker_by_lua = $block->extra_init_worker_by_lua // "";
+    $extra_init_worker_by_lua .= <<_EOC_;
+        require("lib.test_redis").flush_all()
+_EOC_
+
+    $block->set_value("extra_init_worker_by_lua", $extra_init_worker_by_lua);
+
+    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: local policy with count 4
+--- 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-limit-count": {
+                            "count": 4,
+                            "time_window": 60,
+                            "rejected_code": 503,
+                            "key": "remote_addr"
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/hello"
+                }]]
+                )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 2: query with depth equal to 4 exhausts quota and subsequent request 
is rejected
+--- pipelined_requests eval
+[
+    "POST /hello\n" . '{ "query": "query awesomeGraphqlQuery { foo { bar, baz 
{ boo, bee, baa { bar_id, lol } } } }" }',
+    "POST /hello\n" . '{ "query": "query awesomeGraphqlQuery { foo { bar, baz 
{ boo, bee, baa { bar_id, lol } } } }" }',
+]
+--- more_headers
+Content-Type: application/json
+--- error_code eval
+[200, 503]
+
+
+
+=== TEST 3: invalid graphql request: wrong method
+--- request
+HEAD /hello
+--- error_code: 405
+
+
+
+=== TEST 4: invalid graphql request: post method without body
+--- request
+POST /hello
+--- error_code: 400
+--- error_log
+failed to read graphql data, request body has zero size
+--- response_body eval
+qr/Invalid graphql request: can't get graphql request body/
+
+
+
+=== TEST 5: invalid graphql request: wrong content-type
+--- request
+POST /hello
+{
+    "query": "query{persons{id}}"
+}
+--- error_code: 400
+--- error_log
+invalid graphql request, error content-type
+--- response_body eval
+qr/invalid graphql request, error content-type/
+
+
+
+=== TEST 6: invalid graphql request: malformed json body
+--- request
+POST /hello
+{
+    "query": "query{persons{id}}",
+}
+--- more_headers
+Content-Type: application/json
+--- error_code: 400
+--- error_log
+invalid graphql request, Expected object key string but found T_OBJ_END at 
character 38
+--- response_body eval
+qr/invalid graphql request, Expected object key string/
+
+
+
+=== TEST 7: invalid graphql request: json body missing query field
+--- request
+POST /hello
+{
+    "test": "query{persons{id}}"
+}
+--- more_headers
+Content-Type: application/json
+--- error_code: 400
+--- error_log
+invalid graphql request, json body[query] is nil
+--- response_body eval
+qr/invalid graphql request, json body\[query\] is nil/
+
+
+
+=== TEST 8: invalid graphql request: application/graphql with unparsable body
+--- request
+POST /hello
+test {
+  persons(filter: { name: "Niek" }) {
+    name
+    blog
+    githubAccount
+  }
+}
+--- more_headers
+Content-Type: application/graphql
+--- error_code: 400
+--- error_log eval
+qr/failed to parse graphql/
+--- response_body eval
+qr/Invalid graphql request: failed to parse graphql query/
+
+
+
+=== TEST 9: valid application/graphql content-type with shorthand query
+--- 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-limit-count": {
+                            "count": 10,
+                            "time_window": 60,
+                            "rejected_code": 503,
+                            "key": "remote_addr"
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/hello"
+                }]]
+                )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 10: hit - application/graphql content-type accepted
+--- request
+POST /hello
+{ persons { id name } }
+--- more_headers
+Content-Type: application/graphql
+--- error_code: 200
+--- response_headers
+X-RateLimit-Remaining: 8
+
+
+
+=== TEST 11: application/json with charset parameter is accepted
+--- request
+POST /hello
+{
+    "query": "query{persons{id}}"
+}
+--- more_headers
+Content-Type: application/json; charset=utf-8
+--- error_code: 200
+
+
+
+=== TEST 12: invalid graphql request: failed to parse graphql
+--- request
+POST /hello
+{
+    "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 13: invalid graphql request: empty query
+--- request
+POST /hello
+{
+    "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 14: set route: redis policy
+--- 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-limit-count": {
+                            "allow_degradation": false,
+                            "rejected_code": 503,
+                            "redis_timeout": 1000,
+                            "key_type": "var",
+                            "time_window": 60,
+                            "show_limit_quota_header": true,
+                            "count": 5,
+                            "redis_host": "127.0.0.1",
+                            "redis_port": 6379,
+                            "redis_database": 0,
+                            "policy": "redis",
+                            "key": "remote_addr"
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/hello"
+                }]]
+                )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 15: hit redis policy - query with depth equal to 4
+--- request
+POST /hello
+{
+  "query": "query awesomeGraphqlQuery { foo { bar, baz { boo, bee, baa { 
bar_id, lol } } } }"
+}
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- response_headers
+X-RateLimit-Remaining: 1
+
+
+
+=== TEST 16: set route: redis-cluster policy
+--- 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-limit-count": {
+                            "redis_cluster_nodes": ["127.0.0.1:5000", 
"127.0.0.1:5001"],
+                            "redis_cluster_name": "redis-cluster-1",
+                            "redis_cluster_ssl": false,
+                            "redis_timeout": 1000,
+                            "key_type": "var",
+                            "time_window": 60,
+                            "show_limit_quota_header": true,
+                            "allow_degradation": false,
+                            "key": "remote_addr",
+                            "rejected_code": 503,
+                            "count": 5,
+                            "policy": "redis-cluster",
+                            "redis_cluster_ssl_verify": false
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/hello"
+                }]]
+                )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 17: hit redis-cluster policy - query with depth equal to 4
+--- request
+POST /hello
+{
+  "query": "query awesomeGraphqlQuery { foo { bar, baz { boo, bee, baa { 
bar_id, lol } } } }"
+}
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- response_headers
+X-RateLimit-Remaining: 1
+
+
+
+=== TEST 18: set route: fragment depth tests
+--- 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-limit-count": {
+                            "count": 20,
+                            "time_window": 60,
+                            "rejected_code": 503,
+                            "key": "remote_addr",
+                            "show_limit_quota_header": true
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/hello"
+                }]]
+                )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 19: fragment spread depth equals direct nesting depth
+--- request
+POST /hello
+{
+  "query": "query { foo { ...FooParts } } fragment FooParts on Foo { bar, baz 
{ boo, bee, baa { bar_id, lol } } }"
+}
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- response_headers
+X-RateLimit-Remaining: 16
+
+
+
+=== TEST 20: set route: inline fragment depth test
+--- 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-limit-count": {
+                            "count": 20,
+                            "time_window": 60,
+                            "rejected_code": 503,
+                            "key": "remote_addr",
+                            "show_limit_quota_header": true
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/hello"
+                }]]
+                )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 21: inline fragment depth equals fragment spread depth
+--- request
+POST /hello
+{
+  "query": "query { foo { ... on Foo { bar, baz { boo, bee, baa { bar_id, lol 
} } } } }"
+}
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- response_headers
+X-RateLimit-Remaining: 16
+
+
+
+=== TEST 22: fragment cycle does not cause infinite recursion
+--- request
+POST /hello
+{
+  "query": "query { user { ...CycleA } } fragment CycleA on User { id { 
...CycleA } }"
+}
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- response_headers_like
+X-RateLimit-Remaining: \d+
+
+
+
+=== TEST 23: set route: max_size test
+--- extra_yaml_config
+plugins:
+    - graphql-limit-count
+graphql:
+    max_size: 100
+--- 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-limit-count": {
+                            "count": 10,
+                            "time_window": 60,
+                            "rejected_code": 503,
+                            "key": "remote_addr"
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/hello"
+                }]]
+                )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 24: request body exceeding graphql.max_size is rejected
+--- extra_yaml_config
+plugins:
+    - graphql-limit-count
+graphql:
+    max_size: 100
+--- request
+POST /hello
+{"query":"query awesomeGraphqlQuery { foo { bar, baz { boo, bee, baa { bar_id, 
lol, extra_field_1, extra_field_2, extra_field_3 } } } }"}
+--- more_headers
+Content-Type: application/json
+--- error_code: 400
+--- response_body_like eval
+qr/can't get graphql request body/
+--- error_log
+is greater than the maximum size 100 allowed
+
+
+
+=== TEST 25: set route: chained fragment depth test
+--- 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-limit-count": {
+                            "count": 20,
+                            "time_window": 60,
+                            "rejected_code": 503,
+                            "key": "remote_addr",
+                            "show_limit_quota_header": true
+                        }
+                    },
+                    "upstream": {
+                        "nodes": {
+                            "127.0.0.1:1980": 1
+                        },
+                        "type": "roundrobin"
+                    },
+                    "uri": "/hello"
+                }]]
+                )
+            if code >= 300 then
+                ngx.status = code
+            end
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 26: chained fragment spreads expand correctly across two levels
+--- request
+POST /hello
+{
+  "query": "query { user { ...A } } fragment A on User { posts { ...B } } 
fragment B on Post { comments { author { id } } }"
+}
+--- more_headers
+Content-Type: application/json
+--- error_code: 200
+--- response_headers
+X-RateLimit-Remaining: 15


Reply via email to