Skip to content

Commit d755355

Browse files
authored
feat(graphql-limit-count): rate limit by GraphQL query cost (#13840)
1 parent 6e51dcf commit d755355

16 files changed

Lines changed: 5329 additions & 27 deletions

File tree

Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,9 @@ install: runtime
309309
$(ENV_INSTALL) -d $(ENV_INST_LUADIR)/apisix/plugins/ext-plugin
310310
$(ENV_INSTALL) apisix/plugins/ext-plugin/*.lua $(ENV_INST_LUADIR)/apisix/plugins/ext-plugin/
311311

312+
$(ENV_INSTALL) -d $(ENV_INST_LUADIR)/apisix/plugins/graphql-limit-count
313+
$(ENV_INSTALL) apisix/plugins/graphql-limit-count/*.lua $(ENV_INST_LUADIR)/apisix/plugins/graphql-limit-count/
314+
312315
$(ENV_INSTALL) -d $(ENV_INST_LUADIR)/apisix/plugins/grpc-transcode
313316
$(ENV_INSTALL) apisix/plugins/grpc-transcode/*.lua $(ENV_INST_LUADIR)/apisix/plugins/grpc-transcode/
314317

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
--
2+
-- Licensed to the Apache Software Foundation (ASF) under one or more
3+
-- contributor license agreements. See the NOTICE file distributed with
4+
-- this work for additional information regarding copyright ownership.
5+
-- The ASF licenses this file to You under the Apache License, Version 2.0
6+
-- (the "License"); you may not use this file except in compliance with
7+
-- the License. You may obtain a copy of the License at
8+
--
9+
-- http://www.apache.org/licenses/LICENSE-2.0
10+
--
11+
-- Unless required by applicable law or agreed to in writing, software
12+
-- distributed under the License is distributed on an "AS IS" BASIS,
13+
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
-- See the License for the specific language governing permissions and
15+
-- limitations under the License.
16+
--
17+
--
18+
-- Admin API for the GraphQL cost decorations consumed by graphql-limit-count.
19+
--
20+
-- A decoration is owned by a service and is only reachable through the service
21+
-- scoped path, the same shape consumer credentials use:
22+
--
23+
-- /apisix/admin/services/{service_id}/graphql_cost_decorations
24+
-- /apisix/admin/services/{service_id}/graphql_cost_decorations/{id}
25+
--
26+
-- Stored under the service it belongs to, `/services/{service_id}/graphql_cost_decorations/{id}`,
27+
-- so it rides the services config watcher the way a credential rides the consumers
28+
-- one. `apisix/http/service.lua` tells the two kinds of item apart by key. The
29+
-- `service_id` field is written from the path as well, so the runtime can group by
30+
-- service without re-parsing keys.
31+
--
32+
local core = require("apisix.core")
33+
local resource = require("apisix.admin.resource")
34+
35+
local ipairs = ipairs
36+
local type = type
37+
local tostring = tostring
38+
39+
local RESOURCE_NAME = "graphql_cost_decorations"
40+
41+
local base_get = resource.get
42+
local base_post = resource.post
43+
local base_put = resource.put
44+
local base_patch = resource.patch
45+
local base_delete = resource.delete
46+
47+
48+
-- sub_path is "{service_id}/graphql_cost_decorations" or
49+
-- "{service_id}/graphql_cost_decorations/{decoration_id}"
50+
local function service_id_of(sub_path)
51+
local uri_segs = core.utils.split_uri(sub_path or "")
52+
return uri_segs[1]
53+
end
54+
55+
56+
local function check_conf(_id, conf, _need_id, schema)
57+
local ok, err = core.schema.check(schema, conf)
58+
if not ok then
59+
return nil, {error_msg = "invalid configuration: " .. err}
60+
end
61+
62+
return true, nil
63+
end
64+
65+
66+
local function get_etcd_key(id, _conf, sub_path)
67+
local service_id = service_id_of(sub_path)
68+
if not service_id or service_id == "" then
69+
-- only reachable through the flat /apisix/admin/graphql_cost_decorations
70+
-- path, which this resource does not serve
71+
return nil
72+
end
73+
74+
-- a sub resource of the service, the same layout consumer credentials use
75+
local key = "/services/" .. service_id .. "/" .. RESOURCE_NAME
76+
if id then
77+
key = key .. "/" .. id
78+
end
79+
80+
return key
81+
end
82+
83+
84+
-- Rejects a field_path already decorated on the same service. Best effort: the
85+
-- read and the following write are not atomic, so two concurrent writers can
86+
-- still land a duplicate. The runtime resolves that deterministically (etcd key
87+
-- order), it is only the configuration that becomes ambiguous.
88+
local function check_duplicate_field_path(service_id, id, field_path)
89+
local res, err = core.etcd.get("/services/" .. service_id .. "/" .. RESOURCE_NAME,
90+
true)
91+
if not res then
92+
return 503, {error_msg = err}
93+
end
94+
95+
if res.status ~= 200 or not res.body.list then
96+
return nil
97+
end
98+
99+
for _, item in ipairs(res.body.list) do
100+
local value = item.value
101+
if type(value) == "table" and value.field_path == field_path
102+
and value.service_id == service_id
103+
and tostring(value.id) ~= tostring(id) then
104+
return 400, {error_msg = "field_path " .. field_path ..
105+
" is already decorated on this service by [" ..
106+
tostring(value.id) .. "]"}
107+
end
108+
end
109+
110+
return nil
111+
end
112+
113+
114+
-- Verifies the service exists and stamps the ownership onto the stored value.
115+
local function bind_service(conf, sub_path, id)
116+
local service_id = service_id_of(sub_path)
117+
if not service_id or service_id == "" then
118+
return 400, {error_msg = "missing service id"}
119+
end
120+
121+
if type(conf) ~= "table" then
122+
return 400, {error_msg = "invalid configuration"}
123+
end
124+
125+
if conf.service_id and tostring(conf.service_id) ~= tostring(service_id) then
126+
return 400, {error_msg = "wrong service_id, it is taken from the path"}
127+
end
128+
129+
local res, err = core.etcd.get("/services/" .. service_id, false)
130+
if not res then
131+
return 503, {error_msg = err}
132+
end
133+
134+
if res.status == 404 then
135+
return 404, {error_msg = "service not found"}
136+
end
137+
138+
if res.status ~= 200 then
139+
return res.status, {error_msg = "failed to get the service"}
140+
end
141+
142+
if conf.field_path then
143+
local code, dup_err = check_duplicate_field_path(service_id, id, conf.field_path)
144+
if code then
145+
return code, dup_err
146+
end
147+
end
148+
149+
conf.service_id = service_id
150+
return nil
151+
end
152+
153+
154+
local _M = resource.new({
155+
name = RESOURCE_NAME,
156+
kind = "graphql cost decoration",
157+
schema = core.schema.graphql_cost_decoration,
158+
checker = check_conf,
159+
get_resource_etcd_key = get_etcd_key,
160+
})
161+
162+
163+
function _M:get(id, conf, sub_path)
164+
local service_id = service_id_of(sub_path)
165+
if not service_id or service_id == "" then
166+
return 400, {error_msg = "missing service id"}
167+
end
168+
169+
local code, body = base_get(self, id, conf, sub_path)
170+
171+
-- the range is now scoped by the service segment, so a sibling service cannot
172+
-- bleed in; the filter stays as the guard for a hand-written etcd key
173+
if code == 200 and not id and type(body) == "table" and body.list then
174+
local list = {}
175+
for _, item in ipairs(body.list) do
176+
if type(item.value) == "table"
177+
and tostring(item.value.service_id) == tostring(service_id) then
178+
core.table.insert(list, item)
179+
end
180+
end
181+
-- an empty dir already 404s in etcd; keep the same answer when the
182+
-- prefix range only turned up decorations of a different service
183+
if #list == 0 then
184+
return 404, {error_msg = "Key not found"}
185+
end
186+
187+
body.list = list
188+
body.total = #list
189+
end
190+
191+
return code, body
192+
end
193+
194+
195+
function _M:post(id, conf, sub_path, args)
196+
local code, err = bind_service(conf, sub_path, id)
197+
if code then
198+
return code, err
199+
end
200+
201+
return base_post(self, id, conf, sub_path, args)
202+
end
203+
204+
205+
function _M:put(id, conf, sub_path, args)
206+
local code, err = bind_service(conf, sub_path, id)
207+
if code then
208+
return code, err
209+
end
210+
211+
return base_put(self, id, conf, sub_path, args)
212+
end
213+
214+
215+
function _M:patch(id, conf, sub_path, args)
216+
if type(conf) ~= "table" then
217+
return 400, {error_msg = "invalid configuration"}
218+
end
219+
220+
local service_id = service_id_of(sub_path)
221+
if not service_id or service_id == "" then
222+
return 400, {error_msg = "missing service id"}
223+
end
224+
225+
if conf.service_id then
226+
return 400, {error_msg = "service_id can not be patched"}
227+
end
228+
229+
-- a patch may move the decoration onto a field_path another one already owns
230+
if conf.field_path then
231+
local code, err = check_duplicate_field_path(service_id, id, conf.field_path)
232+
if code then
233+
return code, err
234+
end
235+
end
236+
237+
return base_patch(self, id, conf, sub_path, args)
238+
end
239+
240+
241+
function _M:delete(id, conf, sub_path, uri_args)
242+
local service_id = service_id_of(sub_path)
243+
if not service_id or service_id == "" then
244+
return 400, {error_msg = "missing service id"}
245+
end
246+
247+
return base_delete(self, id, conf, sub_path, uri_args)
248+
end
249+
250+
251+
return _M

apisix/admin/init.lua

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ local resources = {
7171
plugin_configs = require("apisix.admin.plugin_config"),
7272
consumer_groups = require("apisix.admin.consumer_group"),
7373
secrets = require("apisix.admin.secrets"),
74+
graphql_cost_decorations = require("apisix.admin.graphql_cost_decorations"),
7475
}
7576

7677

@@ -202,6 +203,13 @@ local function run()
202203
seg_id = uri_segs[7]
203204
end
204205

206+
if seg_res == "services" and #uri_segs >= 6
207+
and uri_segs[6] == "graphql_cost_decorations" then
208+
seg_sub_path = seg_id .. "/" .. seg_sub_path
209+
seg_res = uri_segs[6]
210+
seg_id = uri_segs[7]
211+
end
212+
205213
local resource = resources[seg_res]
206214
if not resource then
207215
core.response.exit(404, {error_msg = "Unsupported resource type: ".. seg_res})

apisix/admin/resource.lua

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ local utils = require("apisix.admin.utils")
1919
local apisix_ssl = require("apisix.ssl")
2020
local apisix_plugin = require("apisix.plugin")
2121
local apisix_consumer = require("apisix.consumer")
22+
local apisix_service = require("apisix.http.service")
2223
local tbl_deepcopy = require("apisix.core.table").deepcopy
2324
local setmetatable = setmetatable
2425
local tostring = tostring
@@ -200,6 +201,12 @@ function _M:get(id, conf, sub_path)
200201
res.body.total = #res.body.list
201202
end
202203

204+
-- same for services and their graphql cost decorations
205+
if self.name == "services" and res.body.list then
206+
res.body.list = apisix_service.filter_services_list(res.body.list)
207+
res.body.total = #res.body.list
208+
end
209+
203210
utils.fix_count(res.body, id)
204211
return res.status, res.body
205212
end
@@ -229,6 +236,12 @@ function _M:post(id, conf, sub_path, args)
229236
local key = "/" .. self.name
230237
utils.inject_timestamp(conf)
231238

239+
-- a sub resource is pushed under its parent's dir, not under /{name};
240+
-- the id is generated by etcd.push, so ask for the collection key
241+
if self.get_resource_etcd_key then
242+
key = self.get_resource_etcd_key(nil, conf, sub_path, args)
243+
end
244+
232245
local ttl = nil
233246
if args then
234247
ttl = args.ttl
@@ -401,6 +414,13 @@ function _M:patch(id, conf, sub_path, args)
401414

402415
key = key .. "/" .. id
403416

417+
-- a sub resource carries its routing information in sub_path, so there is
418+
-- no JSON sub path left to patch
419+
if self.get_resource_etcd_key then
420+
key = self.get_resource_etcd_key(id, conf, sub_path, args)
421+
sub_path = nil
422+
end
423+
404424
if conf == nil then
405425
return 400, {error_msg = "missing new configuration"}
406426
end

0 commit comments

Comments
 (0)