-
Notifications
You must be signed in to change notification settings - Fork 2.9k
feat(plugin): add graphql-limit-count plugin #13372
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 11 commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
3611ddb
feat(plugin): add graphql-limit-count plugin
AlinsRan 2618714
fix: add license header, update config.yaml.example and docs format
AlinsRan aa1a1d0
docs: rewrite graphql-limit-count docs to follow open-source format
AlinsRan c49acd3
fix: address review comments for graphql-limit-count plugin
AlinsRan 3019135
fix: fix typo unparseable -> unparsable in test description
AlinsRan ef7e21d
fix: fix luacheck warning - avoid overwriting err variable
AlinsRan ce2f301
refactor: rename max_query_depth to node_depth
AlinsRan a400d7c
fix: remove manual http_config shared dict declarations from test
AlinsRan d8ef31e
fix: add plugin-graphql-limit-count shared dicts to test framework
AlinsRan a2506b4
fix(tests): use pipelined_requests for rate limit quota exhaustion test
AlinsRan 34fafe7
fix(tests): reindex test cases after merging TEST 2+3
AlinsRan 637fe1c
fix(graphql-limit-count): expand fragment spreads and inline fragment…
AlinsRan 9cfb56b
fix(graphql-limit-count): localize ipairs to satisfy lj-releng
AlinsRan 69ed2d1
fix: use name.value for fragment AST name nodes; add charset and cycl…
AlinsRan bdf7eeb
test(graphql-limit-count): make inline fragment depth test independent
AlinsRan 9730ccf
fix(graphql-limit-count): coerce graphql.max_size to number with tonu…
AlinsRan 039f47b
test(graphql-limit-count): add E2E test for graphql.max_size body siz…
AlinsRan 0421cf9
fix(graphql-limit-count): validate max_size > 0 and clamp depth to mi…
AlinsRan 464c0c7
fix(graphql-limit-count): localize tonumber to satisfy lj-releng lint
AlinsRan 1d443b0
test(graphql-limit-count): assert response body in max_size rejection…
AlinsRan 68067a4
fix(graphql-limit-count): avoid logging full request body in error paths
AlinsRan f7f3f38
fix(graphql-limit-count): remove request body from error log messages
AlinsRan 2baff86
test(graphql-limit-count): add error_log assertion to TEST 24 for max…
AlinsRan ec942c1
test(graphql-limit-count): add chained fragment spread depth test
AlinsRan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
AlinsRan marked this conversation as resolved.
|
||
| end | ||
|
|
||
|
|
||
| return _M | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
| # | ||
| --> | ||
|
|
||
|
AlinsRan marked this conversation as resolved.
|
||
| <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. | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.