diff --git a/apisix/cli/config.lua b/apisix/cli/config.lua index f6daf6e9dfe1..3fb982b9a62a 100644 --- a/apisix/cli/config.lua +++ b/apisix/cli/config.lua @@ -170,6 +170,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", @@ -241,6 +243,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 6bd33368b9d9..09fae203156d 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 000000000000..95a64b33eadf --- /dev/null +++ b/apisix/plugins/graphql-limit-count.lua @@ -0,0 +1,160 @@ +-- +-- 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 pcall = pcall +local max = math.max + +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. +local function node_depth(t) + if type(t) ~= "table" then + return 0 + end + + local depth = 0 + for k, v in pairs(t) do + local child + if k == "selections" then + child = 1 + node_depth(v) + else + child = node_depth(v) + 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 + max_size = size + 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, ", 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 + + local depth = node_depth(res) + 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 9922d3c8116d..69ac37c38472 100644 --- a/conf/config.yaml.example +++ b/conf/config.yaml.example @@ -296,6 +296,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 @@ -524,6 +526,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 6de6e1d92073..118969e91264 100644 --- a/docs/en/latest/config.json +++ b/docs/en/latest/config.json @@ -161,6 +161,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 000000000000..2633131ca6a5 --- /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. +--- + + + +
+ + + +## 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 074c571ebd2c..d28611fb8cb2 100644 --- a/docs/zh/latest/config.json +++ b/docs/zh/latest/config.json @@ -150,6 +150,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 000000000000..96c2b8347c86 --- /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 插件相同的计数机制。 +--- + + + + + + + +## 描述 + +`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 facf13e4d04a..1a6c5dd7f1f6 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 b9f3846ca78d..84674717bda4 100644 --- a/t/admin/plugins.t +++ b/t/admin/plugins.t @@ -108,6 +108,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 000000000000..39e787627a5c --- /dev/null +++ b/t/plugin/graphql-limit-count.t @@ -0,0 +1,388 @@ +# +# 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: 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 12: 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 13: 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 14: 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 15: 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 16: 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