Skip to content

feat(plugin): add graphql-proxy-cache plugin#13435

Open
AlinsRan wants to merge 11 commits into
apache:masterfrom
AlinsRan:feat/graphql-proxy-cache
Open

feat(plugin): add graphql-proxy-cache plugin#13435
AlinsRan wants to merge 11 commits into
apache:masterfrom
AlinsRan:feat/graphql-proxy-cache

Conversation

@AlinsRan
Copy link
Copy Markdown
Contributor

@AlinsRan AlinsRan commented May 25, 2026

What does this PR do?

This PR adds a new graphql-proxy-cache plugin that caches GraphQL query responses on disk or in memory, reusing the existing proxy-cache infrastructure.

Changes

  • apisix/plugins/graphql-proxy-cache.lua — plugin implementation
  • t/plugin/graphql-proxy-cache/graphql.t — schema and request validation tests (19 cases)
  • t/plugin/graphql-proxy-cache/disk.t — disk cache hit/miss tests (11 cases)
  • t/plugin/graphql-proxy-cache/memory.t — memory cache hit/miss and consumer isolation tests (15 cases)
  • docs/en/latest/plugins/graphql-proxy-cache.md — English documentation
  • docs/zh/latest/plugins/graphql-proxy-cache.md — Chinese documentation
  • Plugin registered in the default plugin list at priority 1009

Motivation

GraphQL APIs have different caching semantics from plain HTTP APIs:

  • The request body (query + variables) determines the response, not just the URL
  • mutation operations have side effects and must never be cached
  • Consumers authenticating to the same route should by default have isolated cache namespaces to prevent data leakage

The existing proxy-cache plugin works at the HTTP level and cannot distinguish query operations from mutations, nor does it handle GraphQL-over-GET (where the query is in the query string). This plugin fills that gap.

How it works

Cache key

md5(conf_version + "\1" + host + "\1" + route_id + "\1" + service_id + "\1" + identity + "\1" + body)
  • conf_version — invalidates the cache automatically when the plugin configuration changes
  • host + route_id + service_id — ensures two routes with the same query body never share cache entries
  • identity — when consumer_isolation is enabled (default), this is the consumer name or remote user; otherwise it is an empty string
  • body — the normalized GraphQL query body

Mutation bypass

After parsing the GraphQL AST, if any top-level operation is a mutation, the plugin sets Apisix-Cache-Status: BYPASS and skips the cache entirely.

Caching infrastructure

The plugin delegates to the same proxy-cache disk and memory handlers (apisix.plugins.proxy-cache.disk_handler, apisix.plugins.proxy-cache.memory_handler), so all existing cache zone configuration in config.yaml is reused without duplication.

Configuration

Name Type Required Default Valid values Description
cache_strategy string No disk ["disk", "memory"] Use disk for NGINX proxy_cache, or memory for a shared dict.
cache_zone string No disk_cache_one Must match a zone name defined in config.yaml.
cache_ttl integer No 300 >= 1 TTL in seconds for the memory strategy. Disk TTL follows upstream Cache-Control/Expires.
consumer_isolation boolean No true Partition the cache by authenticated consumer identity.
cache_set_cookie boolean No false Cache responses that include a Set-Cookie header (memory strategy only).

Static configuration

Configure at least one cache zone in config.yaml before enabling this plugin:

apisix:
  proxy_cache:
    cache_ttl: 10s
    zones:
      - name: disk_cache_one
        memory_size: 50m
        disk_size: 1G
        disk_path: /tmp/disk_cache_one
        cache_levels: 1:2
      - name: memory_cache
        memory_size: 50m

Usage examples

Cache GraphQL queries on disk (default)

# Create a route with graphql-proxy-cache
curl http://127.0.0.1:9180/apisix/admin/routes/1 \
  -H "X-API-KEY: $admin_key" -X PUT -d '
{
  "uri": "/graphql",
  "plugins": {
    "graphql-proxy-cache": {}
  },
  "upstream": {
    "type": "roundrobin",
    "nodes": { "127.0.0.1:8080": 1 }
  }
}'

# First request — cache miss
curl -s -D - http://127.0.0.1:9080/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "query { persons { name } }"}'
# Apisix-Cache-Status: MISS
# APISIX-Cache-Key: <cache-key>

# Second identical request — cache hit
curl -s -D - http://127.0.0.1:9080/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "query { persons { name } }"}'
# Apisix-Cache-Status: HIT

Mutation operations always bypass the cache

curl -s -D - http://127.0.0.1:9080/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "mutation { addPerson(name: \"Alice\") { id } }"}'
# Apisix-Cache-Status: BYPASS

Cache in memory with a short TTL

curl http://127.0.0.1:9180/apisix/admin/routes/1 \
  -H "X-API-KEY: $admin_key" -X PUT -d '
{
  "uri": "/graphql",
  "plugins": {
    "graphql-proxy-cache": {
      "cache_strategy": "memory",
      "cache_zone": "memory_cache",
      "cache_ttl": 60
    }
  },
  "upstream": {
    "type": "roundrobin",
    "nodes": { "127.0.0.1:8080": 1 }
  }
}'

Purge a cached response

Expose the purge endpoint via the public-api plugin:

curl http://127.0.0.1:9180/apisix/admin/routes/graphql-purge \
  -H "X-API-KEY: $admin_key" -X PUT -d '
{
  "uri": "/apisix/plugin/graphql-proxy-cache/*",
  "plugins": { "public-api": {} }
}'

Then purge by strategy, route ID, and cache key:

# Purge a disk-cached entry for route 1
curl -X PURGE http://127.0.0.1:9080/apisix/plugin/graphql-proxy-cache/disk/1/<cache-key>
# HTTP 200 on success, HTTP 404 if the entry does not exist

Consumer isolation

With consumer_isolation: true (default), each consumer gets an isolated cache namespace.
Two consumers sending the same query will each get their own MISS on the first request
and their own HIT on subsequent requests — they never share each other's cached responses:

# alice: first request → MISS, second → HIT
curl -H "apikey: alice-key" -H "Content-Type: application/json" \
  -d '{"query":"query{persons{id}}"}' http://127.0.0.1:9080/graphql
# Apisix-Cache-Status: MISS

curl -H "apikey: alice-key" -H "Content-Type: application/json" \
  -d '{"query":"query{persons{id}}"}' http://127.0.0.1:9080/graphql
# Apisix-Cache-Status: HIT

# bob: first request → MISS (isolated from alice)
curl -H "apikey: bob-key" -H "Content-Type: application/json" \
  -d '{"query":"query{persons{id}}"}' http://127.0.0.1:9080/graphql
# Apisix-Cache-Status: MISS

Set consumer_isolation: false to let all consumers share the same cache, useful when the upstream response is not user-specific.

Checklist

  • Code changes follow the existing code style
  • Tests added for new functionality
  • Documentation added (English + Chinese)
  • Lint passes
  • All CI checks pass

@dosubot dosubot Bot added size:XXL This PR changes 1000+ lines, ignoring generated files. doc Documentation things enhancement New feature or request labels May 25, 2026
Copilot and others added 3 commits May 26, 2026 15:25
Add a new graphql-proxy-cache plugin that caches GraphQL query responses
on disk or in memory, reusing the proxy-cache infrastructure.

Key features:
- Supports both disk and memory caching strategies
- Cache key is bound to conf version, host, route/service ID, and query body
- Consumer isolation: each authenticated consumer gets its own cache namespace
- Mutation operations bypass the cache automatically
- PURGE API to invalidate specific cache entries
- Supports GET and POST GraphQL requests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new graphql-proxy-cache plugin to cache GraphQL query responses using APISIX’s existing proxy-cache infrastructure, supporting both disk (NGINX proxy_cache) and in-memory (shared dict) strategies, plus a PURGE endpoint exposed via public-api.

Changes:

  • Added apisix/plugins/graphql-proxy-cache.lua implementing GraphQL request parsing, cache key derivation, mutation bypass, and PURGE API.
  • Added integration tests in t/plugin/graphql-proxy-cache/graphql.t and registered the plugin in admin/plugin lists and default plugin configs.
  • Added English documentation and registered it in the docs sidebar config.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 18 comments.

Show a summary per file
File Description
apisix/plugins/graphql-proxy-cache.lua New plugin implementation (GraphQL parsing, cache keying, mutation bypass, PURGE endpoint).
t/plugin/graphql-proxy-cache/graphql.t New test suite covering schema validation, request validation, hit/miss, bypass, and purge.
t/admin/plugins.t Adds the plugin to the expected Admin API plugins list.
apisix/cli/config.lua Registers the plugin in the default plugin set.
conf/config.yaml.example Adds the plugin to the example default plugin list with priority 1009.
docs/en/latest/plugins/graphql-proxy-cache.md New plugin documentation page.
docs/en/latest/config.json Adds the doc page to the docs navigation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread apisix/plugins/graphql-proxy-cache.lua Outdated
Comment thread apisix/plugins/graphql-proxy-cache.lua Outdated
Comment thread t/plugin/graphql-proxy-cache/graphql.t Outdated
Comment thread t/plugin/graphql-proxy-cache/graphql.t Outdated
Comment thread t/plugin/graphql-proxy-cache/graphql.t
Comment thread apisix/plugins/graphql-proxy-cache.lua
Comment thread apisix/plugins/graphql-proxy-cache.lua Outdated
Comment thread apisix/plugins/graphql-proxy-cache.lua Outdated
Comment thread apisix/plugins/graphql-proxy-cache.lua Outdated
Comment thread t/plugin/graphql-proxy-cache/graphql.t
Copilot and others added 5 commits May 27, 2026 04:34
- fix typo: cant't -> can't in error message
- return specific validation error instead of generic 'no query'
- normalize args.query to string when GET params are duplicated
- guard header_filter/body_filter against nil upstream_cache_key
- move body/cache-key logs from info to debug level
- validate strategy param in purge handler
- fix test assertions: assert equality not presence for MISS status
- fix spelling: zone not exits -> zone not exists in test title
- graph-proxy-cache -> graphql-proxy-cache in error log
- file not exits -> file not exists in error log
- memory_hanler -> memory_handler variable name
- decode JSON with null_as_nil to handle {"query": null} correctly
- log body_size instead of raw body to avoid PII leakage
- reject empty string route_id/cache_key in purge handler
…nd add tests

- Return 400 when URL strategy doesn't match route's configured cache_strategy
- Add TEST 20: invalid strategy returns 400
- Add TEST 21: strategy mismatch returns 400

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
nic-6443
nic-6443 previously approved these changes May 28, 2026
@membphis
Copy link
Copy Markdown
Member

Review notes from merge-risk check:

[P1] graphql-proxy-cache can be enabled without the required NGINX proxy-cache variables/directives

  • Problem: the new plugin writes/uses upstream_cache_key, upstream_cache_zone, upstream_cache_bypass, upstream_no_cache, and disk purge relies on upstream_cache_zone_info, but the OSS NGINX template only defines these variables and proxy_cache* directives when enabled_plugins["proxy-cache"] is true.
  • Why this blocks merge: graphql-proxy-cache is registered as an independent plugin and the tests/docs show it being enabled directly. In custom deployments that include graphql-proxy-cache but not proxy-cache, the plugin can hit undefined NGINX variables or run without the required disk cache configuration.
  • Impact: runtime failures or non-functional disk cache/purge for users with a minimal custom plugin list.
  • Trigger: configure plugins: with graphql-proxy-cache but without proxy-cache, then enable the plugin on a route.
  • Evidence: apisix/plugins/graphql-proxy-cache.lua sets ctx.var.upstream_cache_key and purge sets ngx_var.upstream_cache_key; apisix/cli/ngx_tpl.lua wraps cache variable/directive generation in enabled_plugins["proxy-cache"]; t/plugin/graphql-proxy-cache/graphql.t enables only graphql-proxy-cache and public-api in its minimal plugin list.
  • Suggested fix: treat graphql-proxy-cache as a proxy-cache user in the template and startup validation, e.g. generate the cache variables/directives and require apisix.proxy_cache when either proxy-cache or graphql-proxy-cache is enabled. Add a startup/runtime test that enables only graphql-proxy-cache.

[P2] Parse-error logging still writes the full GraphQL request body to error log

  • Problem: malformed GraphQL requests and empty query paths still log the raw body value.
  • Why this blocks merge: GraphQL bodies frequently contain variables or business data. A bad query should not cause request payloads to be written to error.log.
  • Impact: sensitive request data can leak into logs for any route using this plugin.
  • Trigger: send an invalid GraphQL query or an empty query body.
  • Evidence: apisix/plugins/graphql-proxy-cache.lua logs failed to parse graphql: ..., body: ... and failed to parse graphql: empty query, body: ....
  • Suggested fix: log parser error plus body_size/request id only, not the raw body; add a regression test that malformed input is rejected without body content appearing in logs.

… is enabled and stop logging raw body

- Extend ngx_tpl.lua guards so that the upstream_cache_* NGINX variables
  and proxy_cache* directives are generated when either proxy-cache or
  graphql-proxy-cache is enabled, preventing undefined-variable failures
  in deployments that use graphql-proxy-cache without proxy-cache
- Replace raw body with body_size in parse-error log lines to avoid
  leaking request payloads into error.log

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@AlinsRan AlinsRan dismissed stale reviews from shreemaan-abhishek and nic-6443 via 4f26849 May 28, 2026 06:22
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

doc Documentation things enhancement New feature or request size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants