Copilot commented on code in PR #13840:
URL: https://github.com/apache/apisix/pull/13840#discussion_r3819003903


##########
apisix/plugins/graphql-limit-count.lua:
##########
@@ -203,23 +360,66 @@ function _M.access(conf, ctx)
         return 400, {message = "Invalid graphql request: empty graphql query"}
     end
 
-    local depth = 0
-    local memo = {}
-    local cycle = {found = false}
-    for _, op in ipairs(operations) do
-        local d = node_depth(op, fragments, {}, memo, cycle)
-        depth = max(depth, d)
+    -- A document with several operations only executes the one `operationName`
+    -- selects, so that is the one to charge for. Without it the request is 
not a
+    -- valid multi operation request at all; the whole document is then costed 
and
+    -- the most expensive operation charged, which cannot under charge 
whichever
+    -- one the upstream ends up running.
+    if operation_name and #operations > 1 then
+        for _, op in ipairs(operations) do
+            if op.name and op.name.value == operation_name then
+                operations = {op}
+                break
+            end
+        end
     end
 
-    if cycle.found then
-        core.log.error("invalid graphql request: fragment spreads form a 
cycle")
-        return 400, {message = "Invalid graphql request: fragment spreads must 
not form cycles"}
+    local raw_cost, client_msg
+    raw_cost, err, client_msg = raw_query_cost(conf, ctx, operations, 
fragments, variables)
+    if not raw_cost then
+        -- a malformed document reports its own message; anything else is the
+        -- introspection failing
+        core.log.error(client_msg and err
+                       or "failed to compute the graphql query cost: " .. err)
+        return 400, {message = client_msg
+                               or "Invalid graphql request: failed to 
introspect the "
+                                  .. "upstream graphql schema"}
     end
 
-    depth = max(depth, 1)
-    core.log.info("graphql query depth: ", depth)
+    -- The +0.01 floor makes a query whose nodes are all undecorated still 
cost 1.
+    -- "depth" is never 0 and has always charged exactly the depth, so it is 
left
+    -- alone: with the default score_factor of 1 the cost is unchanged.
+    if conf.cost_strategy ~= "depth" then
+        raw_cost = raw_cost + 0.01
+    end
+
+    -- ceil keeps the value an integer, which the Redis backend requires anyway
+    local cost = max(ceil(raw_cost * (conf.score_factor or 1)), 1)

Review Comment:
   The `+ 0.01` does not merely floor zero: because the result is immediately 
passed to `ceil`, every integral raw cost is incremented (for example, 51 
becomes 52). The existing `max(..., 1)` already provides the minimum charge, so 
this currently distorts `score_factor`, quota usage, headers, and `max_cost` 
thresholds.



##########
apisix/plugins/graphql-limit-count.lua:
##########
@@ -146,6 +245,63 @@ local function node_depth(node, fragments, visited, memo, 
cycle)
 end
 
 
+-- Returns the depth, or nil plus the log line and the client message when the
+-- document is not valid GraphQL to begin with.
+local function query_depth(operations, fragments)
+    local depth = 0
+    local memo = {}
+    local cycle = {found = false}
+    for _, op in ipairs(operations) do
+        depth = max(depth, node_depth(op, fragments, {}, memo, cycle))
+    end
+
+    if cycle.found then
+        return nil, "invalid graphql request: fragment spreads form a cycle",
+               "Invalid graphql request: fragment spreads must not form cycles"
+    end
+
+    depth = max(depth, 1)
+    core.log.info("graphql node depth: ", depth)
+    return depth
+end
+
+
+-- Returns the raw cost of the query, or nil plus an error message.
+local function raw_query_cost(conf, ctx, operations, fragments, variables)
+    if conf.cost_strategy == "depth" then
+        return query_depth(operations, fragments)
+    end
+
+    -- Decorations are owned by the service; a route that is not bound to one 
has
+    -- no place to hang them, so the cost model simply does not apply there.
+    local service_decorations
+    if ctx.service_id then
+        service_decorations = decorations.get(ctx.service_id)
+    else
+        -- info, not warn: this is on the request path, and the effect is 
already
+        -- visible on every response through X-Graphql-Query-Cost
+        core.log.info("the route is not bound to a service, so it has no 
graphql ",
+                      "cost decorations; the query cost degenerates to the 
node count")
+    end
+
+    local schema_index
+    if service_decorations then
+        local err
+        schema_index, err = introspection.get(conf, ctx)
+        if not schema_index then
+            return nil, err
+        end
+    end
+
+    return gql_cost.query_cost(conf.cost_strategy, operations, fragments, {
+        decorations  = service_decorations,
+        schema       = schema_index,
+        variables    = conf.resolve_variables and variables or nil,
+        use_defaults = conf.resolve_variables,
+    })

Review Comment:
   This contradicts the stated fallback that a Service without decorations, or 
a Route without a Service, is charged its node count. With `node_quantifier`, a 
missing decoration makes every node produce zero and the final floor charges 
the whole document as 1 (as TEST 11 currently demonstrates). Fall back to the 
unweighted complexity walk when no decoration index exists.



##########
apisix/control/v1.lua:
##########
@@ -19,7 +19,8 @@ local core = require("apisix.core")
 local plugin = require("apisix.plugin")
 local get_routes = require("apisix.router").http_routes
 local get_stream_routes = require("apisix.router").stream_routes
-local get_services = require("apisix.http.service").services
+local get_service_mod = require("apisix.http.service")
+local get_services = get_service_mod.services

Review Comment:
   The services watcher now includes decoration values, but not every 
control-API reader filters them. `iter_and_find_healthcheck_info` still 
compares each raw `value.value.id` and returns immediately; a decoration whose 
id equals the requested Service id can therefore produce a false “no checker” 
before the actual Service is reached. Filter decoration keys in that lookup as 
well.



##########
apisix/plugins/graphql-limit-count/introspection.lua:
##########
@@ -0,0 +1,409 @@
+--
+-- 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.
+--
+--
+-- Upstream GraphQL schema introspection.
+--
+-- Cost decorations are addressed by GraphQL type name, while a query AST only
+-- carries field names, so the schema is required to tell `Person.name` from
+-- `Vehicle.name`. The schema is fetched lazily on the first request that 
needs it
+-- and then kept for the lifetime of the worker; there is no knob for the TTL, 
so a
+-- schema change on a live upstream needs a reload.
+--
+local core        = require("apisix.core")
+local http        = require("resty.http")
+local resty_lock  = require("resty.lock")
+local upstream    = require("apisix.upstream")
+local service_fetch = require("apisix.http.service").get
+
+local ipairs   = ipairs
+local pairs    = pairs
+local type     = type
+local str_find = string.find
+local tab_sort = table.sort
+local tostring = tostring
+local ngx_now  = ngx.now
+
+local LOCK_SHDICT_NAME = "lrucache-lock"
+
+-- milliseconds; see the comment in fetch_schema
+local INTROSPECTION_CONNECT_TIMEOUT = 2000
+local INTROSPECTION_SEND_TIMEOUT    = 2000
+local INTROSPECTION_READ_TIMEOUT    = 5000
+-- seconds a failed introspection is remembered, so an upstream that answers
+-- nothing does not get one request per client request
+local INTROSPECTION_FAILURE_TTL     = 10
+
+-- Trimmed to what the cost engine consumes: the root type names 
(`field_path`'s
+-- first segment is matched against them), each type's fields and their result
+-- types, and the argument default values used by `resolve_variables`.
+local INTROSPECTION_QUERY = [[
+fragment TypeAttr on __Type {
+    kind
+    name
+}
+
+fragment WrappedTypeRef on __Type {
+    ...TypeAttr
+    ofType { ...TypeAttr
+      ofType { ...TypeAttr
+        ofType { ...TypeAttr
+          ofType { ...TypeAttr } } } }
+}
+
+query {
+    __schema {
+        queryType { name }
+        mutationType { name }
+        types {
+            ...TypeAttr
+            fields {
+                name
+                args { name defaultValue type { ...WrappedTypeRef } }
+                type { ...WrappedTypeRef }
+            }
+        }
+    }
+}
+]]
+
+-- Per-worker, keyed by the owning service (or by the explicit endpoint when 
one
+-- is configured). Never invalidated: an upstream schema change only takes 
effect
+-- after a reload. Keying on the service rather than on the derived endpoint is
+-- what bounds the table: the endpoint embeds the
+-- request path, so a route matching many paths would otherwise add a permanent
+-- entry -- and trigger an upstream introspection -- for every distinct path.
+local schema_cache = {}
+-- cache_key -> {err = <string>, expire_at = <number>}
+local failure_cache = {}
+
+local _M = {}
+
+
+local function unwrap_type_name(type_ref)
+    -- NON_NULL / LIST wrappers have no name of their own; descend to the 
named type.
+    while type_ref do
+        if type_ref.name then
+            return type_ref.name
+        end
+        type_ref = type_ref.ofType
+    end
+
+    return nil
+end
+
+
+local function build_index(schema_data)
+    local introspected = schema_data and schema_data.__schema
+    if not introspected or type(introspected.types) ~= "table" then
+        return nil, "introspection response has no __schema.types"
+    end
+
+    local types = {}
+    for _, type_def in ipairs(introspected.types) do
+        if type_def.name and type_def.fields then
+            local fields = {}
+            for _, field in ipairs(type_def.fields) do
+                if field.name then
+                    local args
+                    if field.args then
+                        for _, arg in ipairs(field.args) do
+                            if arg.name and arg.defaultValue ~= nil then
+                                args = args or {}
+                                args[arg.name] = {default_value = 
arg.defaultValue}
+                            end
+                        end
+                    end
+
+                    fields[field.name] = {
+                        type = unwrap_type_name(field.type),
+                        args = args,
+                    }
+                end
+            end
+            types[type_def.name] = {fields = fields}
+        end
+    end
+
+    return {
+        query_type    = introspected.queryType and introspected.queryType.name 
or "Query",
+        mutation_type = introspected.mutationType and 
introspected.mutationType.name
+                        or "Mutation",
+        types         = types,
+    }
+end
+
+
+-- Picks the node the introspection request is sent to. APISIX runs the access
+-- phase before the balancer, so there is no resolved peer to reuse:
+-- the nodes are sorted and the first one is taken so repeated introspections 
of
+-- the same upstream are stable. Deployments where that is not good enough 
(service
+-- discovery, a separate introspection path) set `introspection_endpoint`.
+local function first_node(nodes, scheme)
+    local candidates = {}
+    local default_port = (scheme == "https" or scheme == "grpcs") and 443 or 80
+
+    if core.table.isarray(nodes) then
+        for _, node in ipairs(nodes) do
+            if node.host then
+                core.table.insert(candidates, node.host .. ":" .. (node.port 
or default_port))
+            end
+        end
+    else
+        for addr in pairs(nodes) do
+            core.table.insert(candidates, addr)
+        end
+    end
+
+    if #candidates == 0 then
+        return nil
+    end
+
+    tab_sort(candidates)
+    return candidates[1]
+end
+
+
+-- Returns the endpoint to introspect and the Host header to send with it, or
+-- nil plus an error message.
+local function resolve_endpoint(conf, ctx)
+    if conf.introspection_endpoint then
+        return core.utils.escape_uri_control_chars(conf.introspection_endpoint)
+    end
+
+    local route = ctx.matched_route and ctx.matched_route.value
+    if not route then
+        return nil, "no matched route to derive the introspection endpoint 
from"
+    end
+
+    -- The schema is cached per service, so prefer the service's own upstream:
+    -- merge_service_route lets a route override it, and two routes on one 
service
+    -- with different overrides would otherwise share whichever schema was 
fetched
+    -- first. Falls back to the route for a route-only upstream.
+    local up_conf
+    local service = ctx.service_id and service_fetch(ctx.service_id)
+    if service and service.value then
+        up_conf = service.value.upstream
+        if not up_conf and service.value.upstream_id then
+            up_conf = upstream.get_by_id(service.value.upstream_id)
+        end
+    end
+
+    if not up_conf then
+        up_conf = route.upstream
+        if not up_conf and route.upstream_id then
+            up_conf = upstream.get_by_id(route.upstream_id)
+        end
+    end
+
+    if not up_conf or not up_conf.nodes then
+        return nil, "the route has no upstream nodes, set 
introspection_endpoint"
+    end
+
+    local scheme = up_conf.scheme or "http"
+    local addr = first_node(up_conf.nodes, scheme)
+    if not addr then
+        return nil, "the route has no upstream nodes, set 
introspection_endpoint"
+    end
+
+    local path = ctx.var.upstream_uri
+    if not path or path == "" then
+        path = ctx.var.uri
+    end
+    local query_pos = str_find(path, "?", 1, true)
+    if query_pos then
+        path = path:sub(1, query_pos - 1)
+    end
+    -- $uri is already percent-decoded, so a request path carrying %0d%0a would
+    -- otherwise reach the request line of the introspection call verbatim. 
Same
+    -- treatment $upstream_uri gets in init.lua.
+    path = core.utils.escape_uri_control_chars(path)
+
+    -- Mirror what proxied traffic sends, so a virtual-hosted upstream answers 
the
+    -- introspection the same way it answers the query. `node` means "use the 
node
+    -- address", which is what request_uri does on its own. For `pass` the 
template
+    -- leaves $upstream_host at $http_host (cli/ngx_tpl.lua:810) -- the 
verbatim
+    -- header including any port -- so $host, which is lowercased and 
port-stripped,
+    -- would reach a different vhost than the proxied request does.
+    local host
+    if up_conf.pass_host == "rewrite" then
+        host = up_conf.upstream_host
+    elseif up_conf.pass_host ~= "node" then
+        host = ctx.var.http_host or ctx.var.host
+    end
+
+    if host and not core.utils.validate_header_value(host) then
+        return nil, "the upstream host is not a valid header value"
+    end
+
+    return scheme .. "://" .. addr .. path, nil, host
+end
+
+
+-- Nothing here comes from the request. The schema is cached per service, so an
+-- introspection that varied with the caller would mean the request which 
happens
+-- to warm a worker picks the schema every later request on it is costed 
against,
+-- and one caller's rejected credentials would cache a failure that answers 
400 to
+-- everyone else for as long as it lives. Introspection is the operator's call 
to
+-- the upstream, made with the operator's credentials.
+local function build_headers(conf, host)
+    local headers = {["Content-Type"] = "application/json"}
+
+    -- request_uri would otherwise send the node address as the Host, which 
breaks
+    -- a virtual-hosted GraphQL upstream that proxied traffic reaches fine
+    if host then
+        headers["Host"] = host
+    end
+
+    -- applied last, so an operator who needs a different Host or Content-Type 
on
+    -- the introspection request can say so
+    for name, value in pairs(conf.introspection_headers or {}) do
+        headers[name] = value
+    end
+
+    return headers
+end
+
+
+local function fetch_schema(conf, endpoint, host)
+    local httpc, err = http.new()
+    if not httpc then
+        return nil, "failed to create http client: " .. err
+    end
+
+    -- The single-flight lock is held for the whole fetch, so without an 
explicit
+    -- timeout an unresponsive endpoint would park every other request on the 
lock
+    -- until the OpenResty default (60s) expired. Bounded, not configurable.
+    httpc:set_timeouts(INTROSPECTION_CONNECT_TIMEOUT, 
INTROSPECTION_SEND_TIMEOUT,
+                       INTROSPECTION_READ_TIMEOUT)
+
+    local res
+    res, err = httpc:request_uri(endpoint, {
+        method  = "POST",
+        body    = core.json.encode({query = INTROSPECTION_QUERY}),
+        headers = build_headers(conf, host),
+        -- The introspection target is the upstream this route already proxies 
to,
+        -- whose certificate the proxy path does not verify either.
+        ssl_verify = false,
+        keepalive  = false,
+    })
+
+    if not res then
+        return nil, "failed to request " .. endpoint .. ": " .. err
+    end
+
+    if res.status ~= 200 then
+        return nil, "unexpected status " .. res.status .. " from " .. endpoint
+    end
+
+    local body
+    body, err = core.json.decode(res.body, {null_as_nil = true})
+    if not body then
+        return nil, "failed to decode the introspection response: " .. (err or 
"not an object")
+    end
+
+    if not body.data then
+        return nil, "introspection response has no data field"
+    end
+
+    return build_index(body.data)
+end
+
+
+local function fetch_and_cache(conf, cache_key, endpoint, host)
+    local lock, err = resty_lock:new(LOCK_SHDICT_NAME)
+    if not lock then
+        core.log.warn("failed to create the introspection lock: ", err,
+                      ", fetching the schema without single-flight protection")
+        return fetch_schema(conf, endpoint, host)
+    end
+
+    local elapsed
+    elapsed, err = lock:lock("graphql-schema#" .. cache_key)
+    if not elapsed then
+        core.log.warn("failed to acquire the introspection lock: ", err,
+                      ", fetching the schema without single-flight protection")
+        return fetch_schema(conf, endpoint, host)
+    end
+
+    -- another request may have populated the cache while this one waited
+    local cached = schema_cache[cache_key]
+    if cached then
+        lock:unlock()
+        return cached
+    end
+
+    local schema, ferr = fetch_schema(conf, endpoint, host)
+    if schema then
+        schema_cache[cache_key] = schema
+        failure_cache[cache_key] = nil
+    else
+        failure_cache[cache_key] = {
+            err = ferr,
+            expire_at = ngx_now() + INTROSPECTION_FAILURE_TTL,
+        }
+    end
+    lock:unlock()
+
+    return schema, ferr
+end
+
+
+---
+-- Returns the schema index for the upstream this request is routed to.
+function _M.get(conf, ctx)
+    -- The derived endpoint embeds the request path, so it is not a safe cache 
key
+    -- (a route matching many paths would grow the table without bound). The 
schema
+    -- belongs to the upstream, and decorations only exist per service, so the
+    -- service is both the correct and the bounded key. An explicit endpoint 
is its
+    -- own key: it does not vary per request.
+    local cache_key = conf.introspection_endpoint
+                      or (ctx.service_id and tostring(ctx.service_id))

Review Comment:
   Using the explicit endpoint itself as the cache key allows two Services that 
share a URL to share a schema or failure entry, even when they configure 
different introspection credentials and receive different schemas. This 
violates the per-Service cache isolation described by the feature and lets 
whichever Service warms the worker determine the other's cost model.



##########
apisix/plugins/graphql-limit-count/cost.lua:
##########
@@ -0,0 +1,527 @@
+--
+-- 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.
+--
+--
+-- GraphQL query cost engine.
+--
+-- Implements the `complexity` and `node_quantifier` cost strategies on the AST
+-- produced by the `graphql` rock.
+--
+-- Cost decorations are keyed by a position in the schema graph -- a `<GraphQL
+-- type>`, or a `<GraphQL type>` followed by the chain of fields that reaches 
it
+-- -- so matching a query field against a decoration requires the upstream 
schema:
+-- the walker carries a type cursor that descends alongside the selection tree.
+-- Without a schema no decoration can match and every node falls back to its
+-- default weights.
+--
+local core   = require("apisix.core")
+
+local ipairs     = ipairs
+local type       = type
+local tonumber   = tonumber
+local math_max   = math.max
+local str_gmatch = string.gmatch
+local tab_sort   = table.sort
+
+local _M = {}
+
+-- the fields a decoration contributes; everything else on the value is 
metadata
+local WEIGHT_KEYS = {"add_value", "mul_value", "add_arguments", 
"mul_arguments"}
+
+local EMPTY_INDEX = {flat = {}, root = {children = {}}, deep = false}
+
+-- How many selections the walk may expand. A real query is in the hundreds; a
+-- document that spreads fragments into an exponential DAG reaches this in
+-- milliseconds and is rejected instead of costed.
+local EXPANSION_BUDGET = 100000
+
+
+local function field_name(node)
+    return node.name and node.name.value
+end
+
+
+-- A field_path is a chain of GraphQL name tokens naming a position in the 
schema
+-- graph. `Person` weights every field that returns a `Person`; `Person.name`
+-- weights the field `name` wherever it is selected on a `Person`;
+-- `Query.products.nodes.reviews` pins one specific chain of fields. All three 
live
+-- in the same trie, one node per token, so matching is a walk rather than a 
string
+-- compare.
+local function insert_path(root, deco)
+    local node = root
+    local depth = 0
+
+    for token in str_gmatch(deco.field_path, "[^.]+") do
+        depth = depth + 1
+        local children = node.children
+        if not children then
+            children = {}
+            node.children = children
+        end
+
+        local child = children[token]
+        if not child then
+            child = {}
+            children[token] = child
+        end
+        node = child
+    end
+
+    -- an empty path names nothing; decorating the trie root would weight every
+    -- field in the document
+    if depth == 0 then
+        return 0
+    end
+
+    node.decoration = deco
+    node.depth = depth
+    return depth
+end
+
+
+---
+-- Compiles a service's decorations into the index `query_cost` consumes. Built
+-- once per configuration version, not per request.
+-- @tparam table list decoration values, each carrying a `field_path`
+function _M.build_index(list)
+    local index = {flat = {}, root = {children = {}}, deep = false}
+
+    for _, deco in ipairs(list or {}) do
+        if type(deco.field_path) == "string" then
+            local depth = insert_path(index.root, deco)
+            if depth == 2 then
+                index.flat[deco.field_path] = deco
+            elseif depth ~= 0 then
+                -- a single token is a type level rule and a longer path is 
pinned
+                -- to a chain; neither can be answered by the `<type>.<field>`
+                -- lookup, so the whole index falls back to the trie walk
+                index.deep = true
+            end
+        end
+    end
+
+    return index
+end
+
+
+-- Advances the candidate paths for one field: the ones inherited from the 
parent
+-- move on by this field's name, and a fresh candidate is seeded from the root 
by
+-- this field's own type. Seeding by type is what lets a two segment path 
match at
+-- any depth, while carrying the parent's candidates forward keeps a longer 
path
+-- pinned to its chain.
+local function advance(root, queue, type_name, name)
+    local next_queue
+
+    if queue then
+        for _, candidate in ipairs(queue) do
+            local children = candidate.children
+            local child = children and children[name]
+            if child then
+                next_queue = next_queue or {}
+                next_queue[#next_queue + 1] = child
+            end
+        end
+    end
+
+    local seed = type_name and root.children[type_name]
+    if seed then
+        next_queue = next_queue or {}
+        next_queue[#next_queue + 1] = seed
+    end
+
+    return next_queue
+end
+
+
+-- Two paths can name the same field: `Product.reviews` and
+-- `Query.products.nodes.reviews` both match one node. They are merged key by 
key,
+-- least specific first, so the longer path wins wherever they disagree. 
Resolving
+-- by path length rather than by storage order keeps the cost a function of the
+-- configuration alone.
+local function matched_decoration(queue)
+    if not queue then
+        return nil
+    end
+
+    local first, overlapping
+    for _, candidate in ipairs(queue) do
+        if candidate.decoration then
+            if not first then
+                first = candidate
+            else
+                overlapping = overlapping or {first}
+                overlapping[#overlapping + 1] = candidate
+            end
+        end
+    end
+
+    if not first then
+        return nil
+    end
+
+    if not overlapping then
+        return first.decoration
+    end
+
+    tab_sort(overlapping, function (a, b)
+        return a.depth < b.depth
+    end)
+
+    local merged = {}
+    for _, candidate in ipairs(overlapping) do
+        for _, key in ipairs(WEIGHT_KEYS) do
+            local value = candidate.decoration[key]
+            if value ~= nil then
+                merged[key] = value
+            end
+        end
+    end
+
+    return merged
+end
+
+
+-- Resolves a GraphQL argument literal to a Lua value.
+-- Only scalar literals carry a `.value`; list / inputObject nodes do not and 
are
+-- therefore reported as absent. A variable resolves only under
+-- `resolve_variables`, from the request's `variables` map first and then from 
the
+-- default the operation declares for it.
+local function literal_value(state, value_node)
+    if type(value_node) ~= "table" then
+        return nil
+    end
+
+    if value_node.kind == "variable" then
+        local name = value_node.name and value_node.name.value
+        if not name then
+            return nil
+        end
+
+        if state.variables then
+            local value = state.variables[name]
+            if value ~= nil then
+                return value
+            end
+        end
+
+        -- The client did not supply the variable, so the upstream executes 
with
+        -- the default the operation itself declares. Reading only the supplied
+        -- map would let `query Q($n: Int = 10000)` sent with no variables at 
all
+        -- cost the same as an absent quantifier -- the bypass 
`resolve_variables`
+        -- exists to close, reached through the document instead of the map.
+        if state.use_defaults then
+            local var_default = state.var_defaults and state.var_defaults[name]
+            return var_default and var_default.value
+        end
+
+        return nil
+    end
+
+    return value_node.value
+end
+
+
+local function argument_literal(state, node, name)
+    local args = node.arguments
+    if not args then
+        return nil
+    end
+
+    for _, arg in ipairs(args) do
+        if arg.name and arg.name.value == name then
+            return literal_value(state, arg.value)
+        end
+    end
+
+    return nil
+end
+
+
+-- Returns the numeric value of `name` for this node, or nil when the argument 
is
+-- absent or not usable in arithmetic. `first: "ten"` is client-controlled, so 
it
+-- must not reach the arithmetic: treating it as absent keeps the request on 
the
+-- fast path instead of turning a client-controlled value into a 500.
+local function resolve_argument(state, node, name, field_def)
+    local value = argument_literal(state, node, name)
+
+    -- By default a query that omits a paginating argument is free, because the
+    -- schema default is not consulted; `resolve_variables` opts into reading 
it.
+    if value == nil and state.use_defaults and field_def and field_def.args 
then
+        local arg_def = field_def.args[name]
+        value = arg_def and arg_def.default_value
+    end
+
+    return tonumber(value)

Review Comment:
   Client-supplied quantifiers are allowed to be negative. A negative outer 
multiplier makes descendant costs negative, after which the final floor reduces 
an arbitrarily expensive nested query to cost 1. Quantifiers represent counts, 
so non-negative finite values must be enforced before arithmetic.



##########
apisix/plugins/graphql-limit-count/cost.lua:
##########
@@ -0,0 +1,527 @@
+--
+-- 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.
+--
+--
+-- GraphQL query cost engine.
+--
+-- Implements the `complexity` and `node_quantifier` cost strategies on the AST
+-- produced by the `graphql` rock.
+--
+-- Cost decorations are keyed by a position in the schema graph -- a `<GraphQL
+-- type>`, or a `<GraphQL type>` followed by the chain of fields that reaches 
it
+-- -- so matching a query field against a decoration requires the upstream 
schema:
+-- the walker carries a type cursor that descends alongside the selection tree.
+-- Without a schema no decoration can match and every node falls back to its
+-- default weights.
+--
+local core   = require("apisix.core")
+
+local ipairs     = ipairs
+local type       = type
+local tonumber   = tonumber
+local math_max   = math.max
+local str_gmatch = string.gmatch
+local tab_sort   = table.sort
+
+local _M = {}
+
+-- the fields a decoration contributes; everything else on the value is 
metadata
+local WEIGHT_KEYS = {"add_value", "mul_value", "add_arguments", 
"mul_arguments"}
+
+local EMPTY_INDEX = {flat = {}, root = {children = {}}, deep = false}
+
+-- How many selections the walk may expand. A real query is in the hundreds; a
+-- document that spreads fragments into an exponential DAG reaches this in
+-- milliseconds and is rejected instead of costed.
+local EXPANSION_BUDGET = 100000
+
+
+local function field_name(node)
+    return node.name and node.name.value
+end
+
+
+-- A field_path is a chain of GraphQL name tokens naming a position in the 
schema
+-- graph. `Person` weights every field that returns a `Person`; `Person.name`
+-- weights the field `name` wherever it is selected on a `Person`;
+-- `Query.products.nodes.reviews` pins one specific chain of fields. All three 
live
+-- in the same trie, one node per token, so matching is a walk rather than a 
string
+-- compare.
+local function insert_path(root, deco)
+    local node = root
+    local depth = 0
+
+    for token in str_gmatch(deco.field_path, "[^.]+") do
+        depth = depth + 1
+        local children = node.children
+        if not children then
+            children = {}
+            node.children = children
+        end
+
+        local child = children[token]
+        if not child then
+            child = {}
+            children[token] = child
+        end
+        node = child
+    end
+
+    -- an empty path names nothing; decorating the trie root would weight every
+    -- field in the document
+    if depth == 0 then
+        return 0
+    end
+
+    node.decoration = deco
+    node.depth = depth
+    return depth
+end
+
+
+---
+-- Compiles a service's decorations into the index `query_cost` consumes. Built
+-- once per configuration version, not per request.
+-- @tparam table list decoration values, each carrying a `field_path`
+function _M.build_index(list)
+    local index = {flat = {}, root = {children = {}}, deep = false}
+
+    for _, deco in ipairs(list or {}) do
+        if type(deco.field_path) == "string" then
+            local depth = insert_path(index.root, deco)
+            if depth == 2 then
+                index.flat[deco.field_path] = deco
+            elseif depth ~= 0 then
+                -- a single token is a type level rule and a longer path is 
pinned
+                -- to a chain; neither can be answered by the `<type>.<field>`
+                -- lookup, so the whole index falls back to the trie walk
+                index.deep = true
+            end
+        end
+    end
+
+    return index
+end
+
+
+-- Advances the candidate paths for one field: the ones inherited from the 
parent
+-- move on by this field's name, and a fresh candidate is seeded from the root 
by
+-- this field's own type. Seeding by type is what lets a two segment path 
match at
+-- any depth, while carrying the parent's candidates forward keeps a longer 
path
+-- pinned to its chain.
+local function advance(root, queue, type_name, name)
+    local next_queue
+
+    if queue then
+        for _, candidate in ipairs(queue) do
+            local children = candidate.children
+            local child = children and children[name]
+            if child then
+                next_queue = next_queue or {}
+                next_queue[#next_queue + 1] = child
+            end
+        end
+    end
+
+    local seed = type_name and root.children[type_name]
+    if seed then
+        next_queue = next_queue or {}
+        next_queue[#next_queue + 1] = seed
+    end
+
+    return next_queue
+end
+
+
+-- Two paths can name the same field: `Product.reviews` and
+-- `Query.products.nodes.reviews` both match one node. They are merged key by 
key,
+-- least specific first, so the longer path wins wherever they disagree. 
Resolving
+-- by path length rather than by storage order keeps the cost a function of the
+-- configuration alone.
+local function matched_decoration(queue)
+    if not queue then
+        return nil
+    end
+
+    local first, overlapping
+    for _, candidate in ipairs(queue) do
+        if candidate.decoration then
+            if not first then
+                first = candidate
+            else
+                overlapping = overlapping or {first}
+                overlapping[#overlapping + 1] = candidate
+            end
+        end
+    end
+
+    if not first then
+        return nil
+    end
+
+    if not overlapping then
+        return first.decoration
+    end
+
+    tab_sort(overlapping, function (a, b)
+        return a.depth < b.depth
+    end)
+
+    local merged = {}
+    for _, candidate in ipairs(overlapping) do
+        for _, key in ipairs(WEIGHT_KEYS) do
+            local value = candidate.decoration[key]
+            if value ~= nil then
+                merged[key] = value
+            end
+        end
+    end
+
+    return merged
+end
+
+
+-- Resolves a GraphQL argument literal to a Lua value.
+-- Only scalar literals carry a `.value`; list / inputObject nodes do not and 
are
+-- therefore reported as absent. A variable resolves only under
+-- `resolve_variables`, from the request's `variables` map first and then from 
the
+-- default the operation declares for it.
+local function literal_value(state, value_node)
+    if type(value_node) ~= "table" then
+        return nil
+    end
+
+    if value_node.kind == "variable" then
+        local name = value_node.name and value_node.name.value
+        if not name then
+            return nil
+        end
+
+        if state.variables then
+            local value = state.variables[name]
+            if value ~= nil then
+                return value
+            end
+        end
+
+        -- The client did not supply the variable, so the upstream executes 
with
+        -- the default the operation itself declares. Reading only the supplied
+        -- map would let `query Q($n: Int = 10000)` sent with no variables at 
all
+        -- cost the same as an absent quantifier -- the bypass 
`resolve_variables`
+        -- exists to close, reached through the document instead of the map.
+        if state.use_defaults then
+            local var_default = state.var_defaults and state.var_defaults[name]
+            return var_default and var_default.value
+        end
+
+        return nil
+    end
+
+    return value_node.value
+end
+
+
+local function argument_literal(state, node, name)
+    local args = node.arguments
+    if not args then
+        return nil
+    end
+
+    for _, arg in ipairs(args) do
+        if arg.name and arg.name.value == name then
+            return literal_value(state, arg.value)
+        end
+    end
+
+    return nil
+end
+
+
+-- Returns the numeric value of `name` for this node, or nil when the argument 
is
+-- absent or not usable in arithmetic. `first: "ten"` is client-controlled, so 
it
+-- must not reach the arithmetic: treating it as absent keeps the request on 
the
+-- fast path instead of turning a client-controlled value into a 500.
+local function resolve_argument(state, node, name, field_def)
+    local value = argument_literal(state, node, name)
+
+    -- By default a query that omits a paginating argument is free, because the
+    -- schema default is not consulted; `resolve_variables` opts into reading 
it.
+    if value == nil and state.use_defaults and field_def and field_def.args 
then
+        local arg_def = field_def.args[name]
+        value = arg_def and arg_def.default_value
+    end
+
+    return tonumber(value)
+end
+
+
+local function node_weights(state, node, deco, field_def)
+    if not deco then
+        return 1, 1, false
+    end
+
+    local node_add = deco.add_value or 1
+    local node_mul = deco.mul_value or 1
+    local has_quantifier = false
+
+    if deco.add_arguments then
+        for _, name in ipairs(deco.add_arguments) do
+            local value = resolve_argument(state, node, name, field_def)
+            if value then
+                node_add = node_add + value
+            end
+        end
+    end
+
+    if deco.mul_arguments then
+        for _, name in ipairs(deco.mul_arguments) do
+            local value = resolve_argument(state, node, name, field_def)
+            if value then
+                node_mul = node_mul * value
+                has_quantifier = true
+            end
+        end
+    end
+
+    return node_add, node_mul, has_quantifier
+end
+
+
+local each_field
+-- Calls fn(child, decoration, field_def, child_type, child_queue) for every 
field
+-- selected by `node`, where `sel_type` is the GraphQL type the selection set
+-- belongs to and `queue` the decoration paths still live at this point.
+--
+-- Fragments are transparent: they carry no cost of their own and they do not 
move
+-- the type cursor or the decoration paths. `... on Droid { name }` therefore
+-- matches `<parent type>.name` rather than `Droid.name`: the walk never sees 
the
+-- fragment node, so its `typeCondition` does not take effect.
+-- A fragment's `typeCondition` narrows the selection to a concrete type, so 
the
+-- fields inside it belong to that type and not to the abstract one the parent
+-- selected. Without moving the cursor, `... on Product { expensive }` under an
+-- interface looks `expensive` up on the interface, misses a 
`Product.expensive`
+-- weight and undercharges the query. Only move it when the schema actually 
knows
+-- the type, so an unknown condition degrades instead of losing the cursor.
+local function fragment_type(state, node, sel_type)
+    local condition = node.typeCondition
+    local name = condition and condition.name and condition.name.value
+    if name and state.types and state.types[name] then
+        return name
+    end
+
+    return sel_type
+end
+
+
+function each_field(state, node, sel_type, queue, fn)
+    local selection_set = node.selectionSet
+    if not selection_set or not selection_set.selections then
+        return
+    end
+
+    for _, sel in ipairs(selection_set.selections) do
+        local kind = sel.kind
+
+        -- Spreading a fragment twice legitimately costs twice, so an acyclic 
chain
+        -- of fragments that each spread the previous one twice expands
+        -- exponentially: a document under a kilobyte can otherwise burn 
seconds of
+        -- CPU in the access phase, before any limit has been applied. Bound 
the
+        -- expansion instead, and let the caller reject the request.
+        state.budget = state.budget - 1
+        if state.budget < 0 then
+            state.exhausted = true
+            return
+        end
+
+        if kind == "field" then
+            local name = field_name(sel)
+            local deco, field_def, child_type, child_queue
+            if name and sel_type then
+                local type_def = state.types and state.types[sel_type]
+                field_def = type_def and type_def.fields[name]
+                child_type = field_def and field_def.type
+
+                if state.deep then
+                    child_queue = advance(state.root, queue, child_type, name)
+                    deco = matched_decoration(child_queue)
+                else
+                    -- every path is `<type>.<field>`, so the walk has at most 
one
+                    -- live candidate and collapses to a single lookup
+                    deco = state.flat[sel_type .. "." .. name]
+                end
+            end
+            fn(sel, deco, field_def, child_type, child_queue)
+
+        elseif kind == "inlineFragment" then
+            each_field(state, sel, fragment_type(state, sel, sel_type), queue, 
fn)

Review Comment:
   When the index contains any type-level or deep rule, `state.deep` is enabled 
and shallow rules are matched through `queue`. Entering a concrete fragment 
changes `sel_type` but leaves `queue` seeded from the parent's abstract return 
type, so a rule such as `Product.expensive` stops matching if an unrelated deep 
rule also exists. Re-seed the queue from the known fragment condition while 
preserving the inherited deep-path candidates; the named-fragment branch has 
the same issue.



##########
apisix/plugins/graphql-limit-count/introspection.lua:
##########
@@ -0,0 +1,409 @@
+--
+-- 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.
+--
+--
+-- Upstream GraphQL schema introspection.
+--
+-- Cost decorations are addressed by GraphQL type name, while a query AST only
+-- carries field names, so the schema is required to tell `Person.name` from
+-- `Vehicle.name`. The schema is fetched lazily on the first request that 
needs it
+-- and then kept for the lifetime of the worker; there is no knob for the TTL, 
so a
+-- schema change on a live upstream needs a reload.
+--
+local core        = require("apisix.core")
+local http        = require("resty.http")
+local resty_lock  = require("resty.lock")
+local upstream    = require("apisix.upstream")
+local service_fetch = require("apisix.http.service").get
+
+local ipairs   = ipairs
+local pairs    = pairs
+local type     = type
+local str_find = string.find
+local tab_sort = table.sort
+local tostring = tostring
+local ngx_now  = ngx.now
+
+local LOCK_SHDICT_NAME = "lrucache-lock"
+
+-- milliseconds; see the comment in fetch_schema
+local INTROSPECTION_CONNECT_TIMEOUT = 2000
+local INTROSPECTION_SEND_TIMEOUT    = 2000
+local INTROSPECTION_READ_TIMEOUT    = 5000
+-- seconds a failed introspection is remembered, so an upstream that answers
+-- nothing does not get one request per client request
+local INTROSPECTION_FAILURE_TTL     = 10
+
+-- Trimmed to what the cost engine consumes: the root type names 
(`field_path`'s
+-- first segment is matched against them), each type's fields and their result
+-- types, and the argument default values used by `resolve_variables`.
+local INTROSPECTION_QUERY = [[
+fragment TypeAttr on __Type {
+    kind
+    name
+}
+
+fragment WrappedTypeRef on __Type {
+    ...TypeAttr
+    ofType { ...TypeAttr
+      ofType { ...TypeAttr
+        ofType { ...TypeAttr
+          ofType { ...TypeAttr } } } }
+}
+
+query {
+    __schema {
+        queryType { name }
+        mutationType { name }

Review Comment:
   The schema index omits `subscriptionType`, and `root_type` consequently 
treats every non-mutation operation as a query. A valid subscription is 
therefore walked under the Query type, so Subscription decorations never match 
and its cost is undercounted. Include the subscription root in introspection 
and select it for subscription operations.



##########
apisix/plugins/graphql-limit-count.lua:
##########
@@ -25,30 +28,115 @@ local pairs   = pairs
 local ipairs  = ipairs
 local pcall   = pcall
 local max     = math.max
+local ceil    = math.ceil
 local tonumber = tonumber
 
 local GRAPHQL_DEFAULT_MAX_SIZE = 1048576
+local QUERY_COST_HEADER = "X-Graphql-Query-Cost"
 
 local plugin_name = "graphql-limit-count"
+
+-- The plugin reuses the whole limit-count configuration surface and adds the 
cost
+-- model on top of it. limit_count.schema is shared with limit-count,
+-- limit-count-advanced and ai-rate-limiting, so it must not be mutated in 
place.
+local schema = core.table.deepcopy(limit_count.schema)
+
+schema.properties.cost_strategy = {
+    type = "string",
+    enum = {"depth", "complexity", "node_quantifier"},
+    -- "depth" is what this plugin has always done; keeping it as the default 
means
+    -- an existing configuration keeps its current cost after the upgrade.
+    default = "depth",
+}
+schema.properties.max_cost = {
+    type = "number",
+    minimum = 0,
+    default = 0,
+    description = "reject with 403 above this cost, 0 disables the check",
+}
+schema.properties.score_factor = {
+    type = "number",
+    exclusiveMinimum = 0,
+    default = 1,
+    description = "scaling applied to the raw cost before the quota is 
charged",
+}
+schema.properties.resolve_variables = {
+    type = "boolean",
+    -- On by default: with it off, moving `first: 10000` to `first: $n` makes 
the
+    -- same request cost a fraction of what the literal costs, which is a 
bypass
+    -- of max_cost and of the quota. Turn it off only to reproduce an engine 
that
+    -- ignores variables.
+    default = true,
+    description = "resolve GraphQL variables and schema argument defaults when 
" ..
+                  "computing the cost, instead of treating them as absent",
+}
+schema.properties.introspection_endpoint = {
+    type = "string",
+    pattern = "^https?://",
+    description = "explicit schema introspection endpoint, derived from the " 
..
+                  "upstream when unset",
+}
+schema.properties.introspection_headers = {
+    type = "object",
+    patternProperties = {["^[^:]+$"] = {type = "string"}},
+    -- Deliberately not taken from the request. The schema is cached per 
service,
+    -- so an introspection whose result depends on the caller would let 
whichever
+    -- request warms a worker pick the schema every later request is costed
+    -- against, and would let one caller's bad credentials cache a failure that
+    -- rejects everyone else. Credentials for it belong to the operator.
+    description = "headers sent on the schema introspection request, for an " 
..
+                  "upstream whose introspection needs credentials",
+}

Review Comment:
   `introspection_headers` is explicitly intended to hold upstream credentials, 
but it is not added to the inherited `encrypt_fields`, so tokens remain 
plaintext in etcd even when APISIX data encryption is enabled. Comparable 
credential-bearing header maps are encrypted in 
`apisix/plugins/loki-logger.lua:113` and 
`apisix/plugins/elasticsearch-logger.lua:113`; append this field to the 
schema's encryption list.



##########
apisix/plugins/graphql-limit-count/introspection.lua:
##########
@@ -0,0 +1,409 @@
+--
+-- 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.
+--
+--
+-- Upstream GraphQL schema introspection.
+--
+-- Cost decorations are addressed by GraphQL type name, while a query AST only
+-- carries field names, so the schema is required to tell `Person.name` from
+-- `Vehicle.name`. The schema is fetched lazily on the first request that 
needs it
+-- and then kept for the lifetime of the worker; there is no knob for the TTL, 
so a
+-- schema change on a live upstream needs a reload.
+--
+local core        = require("apisix.core")
+local http        = require("resty.http")
+local resty_lock  = require("resty.lock")
+local upstream    = require("apisix.upstream")
+local service_fetch = require("apisix.http.service").get
+
+local ipairs   = ipairs
+local pairs    = pairs
+local type     = type
+local str_find = string.find
+local tab_sort = table.sort
+local tostring = tostring
+local ngx_now  = ngx.now
+
+local LOCK_SHDICT_NAME = "lrucache-lock"
+
+-- milliseconds; see the comment in fetch_schema
+local INTROSPECTION_CONNECT_TIMEOUT = 2000
+local INTROSPECTION_SEND_TIMEOUT    = 2000
+local INTROSPECTION_READ_TIMEOUT    = 5000
+-- seconds a failed introspection is remembered, so an upstream that answers
+-- nothing does not get one request per client request
+local INTROSPECTION_FAILURE_TTL     = 10
+
+-- Trimmed to what the cost engine consumes: the root type names 
(`field_path`'s
+-- first segment is matched against them), each type's fields and their result
+-- types, and the argument default values used by `resolve_variables`.
+local INTROSPECTION_QUERY = [[
+fragment TypeAttr on __Type {
+    kind
+    name
+}
+
+fragment WrappedTypeRef on __Type {
+    ...TypeAttr
+    ofType { ...TypeAttr
+      ofType { ...TypeAttr
+        ofType { ...TypeAttr
+          ofType { ...TypeAttr } } } }

Review Comment:
   The introspection query follows only four `ofType` links. GraphQL permits 
deeper nested list/non-null wrappers, so a valid field such as a sufficiently 
nested list of `Product` is returned without the innermost type name; 
`unwrap_type_name` then yields nil and all decorations beneath that field are 
silently missed. Request enough wrapper depth (as standard introspection 
queries do) or otherwise reject an unresolved type instead of undercharging it.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to