Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions apisix/plugins/graphql-limit-count.lua
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,10 @@ local check_graphql_request = {
-- 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)
-- visited guards against fragment definition cycles; memo caches each
-- fragment's resolved depth so a fragment is measured once regardless of how
-- many times it is spread, keeping the traversal linear in the document size.
local function node_depth(node, fragments, visited, memo)
if type(node) ~= "table" then
return 0
end
Expand All @@ -101,30 +103,35 @@ local function node_depth(node, fragments, visited)
if not name or visited[name] then
return 0
end
local cached = memo[name]
if cached then
return cached
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)
local depth = node_depth(frag.selectionSet.selections, fragments, visited, memo)
visited[name] = nil
memo[name] = depth
return depth
end

if node.kind == "inlineFragment" then
if not node.selectionSet then
return 0
end
return node_depth(node.selectionSet.selections, fragments, visited)
return node_depth(node.selectionSet.selections, fragments, visited, memo)
end

local depth = 0
for k, v in pairs(node) do
local child
if k == "selections" then
child = 1 + node_depth(v, fragments, visited)
child = 1 + node_depth(v, fragments, visited, memo)
else
child = node_depth(v, fragments, visited)
child = node_depth(v, fragments, visited, memo)
end
depth = max(depth, child)
end
Expand Down Expand Up @@ -191,8 +198,9 @@ function _M.access(conf, ctx)
end

local depth = 0
local memo = {}
for _, op in ipairs(operations) do
local d = node_depth(op, fragments, {})
local d = node_depth(op, fragments, {}, memo)
depth = max(depth, d)
end
depth = max(depth, 1)
Expand Down
80 changes: 80 additions & 0 deletions t/plugin/graphql-limit-count.t
Original file line number Diff line number Diff line change
Expand Up @@ -633,3 +633,83 @@ Content-Type: application/json
--- error_code: 200
--- response_headers
X-RateLimit-Remaining: 15



=== TEST 27: set route: nested fragment expansion 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": 100,
"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 28: deeply reused fragments are measured once and keep their depth
--- config
location /t {
content_by_lua_block {
-- f0 nests three field levels (depth 3); every fN spreads the
-- previous fragment twice, so a naive expansion is O(2^N) while
-- the result stays depth 3. A completed request with the exact
-- quota proves the traversal is both linear and depth-correct.
local http = require "resty.http"
local n = 34
local parts = {"fragment f0 on Query { a { b { c } } }"}
for i = 1, n do
parts[#parts + 1] = string.format(
"fragment f%d on Query { ...f%d ...f%d }", i, i - 1, i - 1)
end
parts[#parts + 1] = string.format("query { ...f%d }", n)
local body = table.concat(parts, " ")
local httpc = http.new()
local res, err = httpc:request_uri(
"http://127.0.0.1:" .. ngx.var.server_port .. "/hello", {
method = "POST",
body = body,
headers = { ["Content-Type"] = "application/graphql" },
})
if not res then
ngx.say(err)
return
end
ngx.say(res.status)
ngx.say(res.headers["X-RateLimit-Remaining"])
}
}
--- request
GET /t
--- timeout: 10
--- response_body
200
97
Loading