HTTP QUERY Method

A new HTTP verb for safe, idempotent, cacheable queries with a request body. Closes the gap between GET and POST for complex search APIs.

✓ Safe ✓ Idempotent ✓ Cacheable ✓ Body allowed ⚠ CORS preflight required
RFC: RFC 10008 — The HTTP QUERY Method  ·  Published: October 2024  ·  Authors: J. Reschke, A. Malhotra, J. Snell

The problem: GET is safe and cacheable but can't carry a body. POST can carry a body but is not safe (caches treat it as state-mutating). For complex search APIs, this forces developers to either stuff queries into URIs (brittle, length-limited, logged in cleartext) or use POST with search semantics (un-cacheable, semantically wrong).

Safe
Does not change server state. Caches, proxies, and retry logic treat it like GET — safe to replay automatically.
Body carries the query
Any format: application/json, application/sql, application/graphql, application/x-www-form-urlencoded, custom DSLs.
Cacheable
RFC 10008 §2.2: responses are cacheable. Cache key = URI + Content-Type + SHA-256(body). Shared proxies can cache search results.
Content-Location
Server can return a Content-Location URI so clients can later GET the same results without resending the body.
Accept-Query
New header (§3): server advertises which query formats it accepts. Returned on 415 responses so clients can adapt.
Idempotent
Identical QUERY requests MUST produce identical results. Retry on timeout is safe — no risk of duplicate side effects.
This demo runs a real Go implementation compiled to WebAssembly. The pkg/query package implements RFC 10008 QUERY handling including Content-Type validation, 415 + Accept-Query negotiation, and 422 for malformed query bodies. The cache demo uses pkg/cache with a SHA-256 body-keyed store.
Quick comparison
Property GET QUERY POST (search)
Safe (no state change) Yes Yes No
Idempotent Yes Yes No
Cacheable by default Yes Yes No
Carries a request body Undefined Yes Yes
Query in URI Required Optional No
Accept-Query negotiation No Yes No

Protocol Flow

A QUERY request flows through client → (optional proxy cache) → origin server. The animation shows all three paths: cache miss, cache hit, and the Accept-Query negotiation on unsupported formats.

Click a button below to animate the flow Client application Proxy Cache SHA-256 keyed Origin Server QUERY handler QUERY + body → → forwarded ← response
1
Client sends QUERY /users HTTP/1.1 with Content-Type: application/json and a query body.
2
Proxy cache computes cache key = SHA-256(URI + Content-Type + body). On MISS, forwards to origin with the full body intact.
3
Origin validates Content-Type is present and supported. Runs the query. Returns 200 OK with Content-Location URI for the result set.
4
Proxy caches the response under the computed key. Client and proxy both receive X-Cache: MISS. Next identical QUERY → X-Cache: HIT, no backend call.

QUERY vs GET vs POST

See exactly how the same complex query looks in each HTTP method — and why QUERY is the right choice for search APIs.

Live Comparison
Click "Compare methods" to run the WASM comparison...
Code examples — same query, three methods
# ─── GET (query in URI — breaks at ~2000 chars) ───────────────────
curl -G "https://api.example.com/users" \
  --data-urlencode 'filter={"status":"active","dept":["Engineering"]}' \
  --data-urlencode 'sort=name:asc' \
  --data-urlencode 'limit=50'

# ─── POST (not safe, not cacheable) ───────────────────────────────
curl -X POST "https://api.example.com/users/search" \
  -H "Content-Type: application/json" \
  -d '{"filter":{"status":"active","dept":["Engineering"]},"sort":[{"field":"name","dir":"asc"}],"limit":50}'

# ─── QUERY (safe + cacheable + body) ──────────────────────────────
curl -X QUERY "https://api.example.com/users" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"filter":{"status":"active","dept":["Engineering"]},"sort":[{"field":"name","dir":"asc"}],"limit":50}'
package main

import (
  "bytes"
  "encoding/json"
  "net/http"
)

func queryUsers(client *http.Client, baseURL string) (*http.Response, error) {
  body, _ := json.Marshal(map[string]interface{}{
    "filter": map[string]interface{}{
      "status": "active",
      "dept":   []string{"Engineering"},
    },
    "sort":  []map[string]string{{"field": "name", "dir": "asc"}},
    "limit": 50,
  })

  req, _ := http.NewRequest("QUERY", baseURL+"/users", bytes.NewReader(body))
  req.Header.Set("Content-Type", "application/json")
  req.Header.Set("Accept", "application/json")

  // QUERY is safe + idempotent — the client can retry on timeout
  return client.Do(req)
}

// Server handler (uses pkg/query from this project)
import "github.com/jralmaraz/http-query-method/pkg/query"

handler := query.JSONQueryHandler(func(q map[string]interface{}) (interface{}, error) {
  results := db.Query(q["filter"], q["sort"], q["limit"])
  return map[string]interface{}{"results": results}, nil
})
// ─── Browser / Node.js (fetch API) ───────────────────────────────
const response = await fetch('https://api.example.com/users', {
  method: 'QUERY',         // RFC 10008 method
  headers: {
    'Content-Type': 'application/json',
    'Accept':       'application/json',
  },
  body: JSON.stringify({
    filter: { status: 'active', dept: ['Engineering'] },
    sort:   [{ field: 'name', dir: 'asc' }],
    limit:  50,
  }),
  // Note: QUERY is not a CORS-safelisted method — preflight will fire
});

const data = await response.json();
console.log(`Cache: ${response.headers.get('X-Cache')}`);      // MISS or HIT
console.log(`Results URI: ${response.headers.get('Content-Location')}`);

// ─── Node.js with undici (explicit QUERY support) ─────────────────
import { fetch } from 'undici';   // undici allows custom methods
const res = await fetch(url, { method: 'QUERY', body, headers });
import httpx  # httpx supports custom methods natively

query_body = {
    "filter": {"status": "active", "dept": ["Engineering"]},
    "sort":   [{"field": "name", "dir": "asc"}],
    "limit": 50,
}

# httpx allows any HTTP method string
response = httpx.request(
    method="QUERY",
    url="https://api.example.com/users",
    json=query_body,
    headers={"Accept": "application/json"},
)

data = response.json()
print(f"Cache: {response.headers.get('X-Cache')}")          # MISS or HIT
print(f"Results: {data['results']}")

# Flask server handler
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route('/users', methods=['QUERY'])
def query_users():
    query = request.get_json()
    if not query:
        return jsonify(error="Content-Type required"), 400
    results = db.search(query["filter"], query.get("limit", 20))
    return jsonify(results=results)
import java.net.URI;
import java.net.http.*;
import java.net.URLEncoder;

// ─── GET (query in URI — breaks at ~2000 chars) ───────────────────
var client = HttpClient.newHttpClient();
var params = URLEncoder.encode("""
    {"status":"active","dept":["Engineering"]}""", StandardCharsets.UTF_8);
var get = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/users?filter=" + params + "&limit=50"))
    .GET()
    .header("Accept", "application/json")
    .build();

// ─── POST (not safe, not cacheable) ───────────────────────────────
var body = """
    {"filter":{"status":"active","dept":["Engineering"]},
     "sort":[{"field":"name","dir":"asc"}],"limit":50}""";
var post = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/users/search"))
    .POST(HttpRequest.BodyPublishers.ofString(body))
    .header("Content-Type", "application/json")
    .build();

// ─── QUERY (safe + cacheable + body) — Java 11+ HttpClient ────────
var query = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/users"))
    .method("QUERY", HttpRequest.BodyPublishers.ofString(body))
    .headers("Content-Type", "application/json",
             "Accept",       "application/json")
    .build();
var response = client.send(query, HttpResponse.BodyHandlers.ofString());
// QUERY is safe + idempotent → safe to retry on timeout or 5xx

// ─── Spring Boot 6 client (RestClient with custom method) ─────────
@Service
public class UserQueryService {
    private final RestClient rest = RestClient.create();

    public List<User> findUsers(UserFilter filter) {
        return rest
            .method(HttpMethod.valueOf("QUERY"))
            .uri("https://api.example.com/users")
            .contentType(MediaType.APPLICATION_JSON)
            .body(filter)
            .retrieve()
            .body(new ParameterizedTypeReference<List<User>>() {});
    }
}

// ─── Spring Boot 6 handler (server side) ──────────────────────────
@RestController
@RequestMapping("/users")
public class UserController {

    @RequestMapping(method = RequestMethod.valueOf("QUERY"),
                    consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<List<User>> queryUsers(@RequestBody UserFilter filter) {
        var results = userService.search(filter);
        return ResponseEntity.ok()
            .header("Content-Location", "/users/results/" + filter.cacheKey())
            .body(results);
    }
}
use reqwest::Method;

// reqwest supports custom methods via Method::from_bytes
let query_method = Method::from_bytes(b"QUERY").unwrap();

let body = serde_json::json!({
    "filter": {"status": "active", "dept": ["Engineering"]},
    "sort":   [{"field": "name", "dir": "asc"}],
    "limit": 50,
});

let response = client
    .request(query_method, "https://api.example.com/users")
    .header("Content-Type", "application/json")
    .header("Accept",       "application/json")
    .json(&body)
    .send()
    .await?;

// Axum server handler
use axum::{routing::method_routing, Router, Json, http::StatusCode};

async fn query_users(Json(q): Json<serde_json::Value>) -> Json<serde_json::Value> {
    let results = db.search(&q).await;
    Json(serde_json::json!({"results": results}))
}

let app = Router::new()
    .route("/users", method_routing::on(MethodFilter::try_from("QUERY").unwrap(), query_users));

Caching Semantics

RFC 10008 §2.2 defines how QUERY responses are cached. The cache key incorporates the full request body, making QUERY unique among cacheable methods.

Key insight: Unlike GET (where the URI is the cache key), QUERY's cache key is SHA-256(method + URI + Content-Type + body). A proxy must buffer and hash the request body before looking up the cache — this is inherently more expensive than GET cache lookup.
Client sends QUERY Proxy Cache computes key: sha256(URI+CT+body) checking... Origin runs query QUERY + body forward 200 OK (X-Cache: MISS) Click a button to animate
1
Client sends QUERY /users with body. Proxy reads the full body to compute the cache key: SHA-256(URI + Content-Type + body).
2
Cache lookup. On MISS the request is forwarded to the origin server. On HIT the cached response is returned immediately — no backend call.
3
Origin processes the query and returns 200 OK with Cache-Control: max-age=300 and optionally a Content-Location URI for the result set.
4
Proxy stores the response under the computed key. Second request with same body → X-Cache: HIT, 1ms latency, no origin call. Different body → new MISS.
WASM Cache Demo

Enter a query body. Send it twice to see MISS → HIT behaviour.

Run the demo to see caching in action...
Cache implementation — code examples
// pkg/cache — in-memory QUERY response cache (from this project)
store := cache.NewStore(5 * time.Minute)
handler := cache.NewCachingHandler(queryHandler, store)
// X-Cache: MISS on first request, X-Cache: HIT on repeat

// Cache key derivation (RFC 10008 §2.2)
func Key(uri, contentType string, body []byte) string {
  h := sha256.New()
  h.Write([]byte(uri))
  h.Write([]byte("\x00"))
  h.Write([]byte(contentType))
  h.Write([]byte("\x00"))
  h.Write(body)
  return hex.EncodeToString(h.Sum(nil))
}

// RFC 10008 §2.2: "no-transform" prevents cache normalisation
// Cache-Control: no-transform → use raw body as key, skip whitespace normalisation
# nginx — cache QUERY responses using body hash as key
http {
  proxy_cache_path /var/cache/nginx/query levels=1:2
                   keys_zone=query_cache:10m max_size=1g
                   inactive=5m use_temp_path=off;

  server {
    location /api/ {
      # Allow QUERY to be cached (nginx treats unknown methods as uncacheable by default)
      proxy_cache_methods GET HEAD QUERY;
      proxy_cache query_cache;

      # Cache key: URI + body digest (requires request body digest module)
      proxy_cache_key "$scheme$request_method$host$request_uri$http_content_type$request_body_hash";

      proxy_cache_valid 200 5m;
      add_header X-Cache $upstream_cache_status;

      proxy_pass http://backend;
    }
  }
}
// Apache Tomcat 12 + Servlet 6.1 — native QUERY method support
// apache/tomcat#1026 merged July 1 2026; requires Tomcat 12.0.x + Servlet 6.1 API
// Tomcat 12 is the FIRST Java servlet container to ship native doQuery() dispatch.

import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.*;
import java.io.IOException;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.concurrent.ConcurrentHashMap;

@WebServlet("/api/users")
public class UserQueryServlet extends HttpServlet {

    // Simple in-process cache — replace with Ehcache / Caffeine in production
    private final ConcurrentHashMap<String, byte[]> cache = new ConcurrentHashMap<>();

    // Tomcat 12 dispatches QUERY to doQuery() — override like doGet() / doPost()
    @Override
    protected void doQuery(HttpServletRequest req, HttpServletResponse resp)
            throws IOException {

        String ct = req.getContentType();
        if (ct == null || !ct.contains("application/json")) {
            resp.setStatus(415);
            resp.setHeader("Accept-Query", "application/json"); // RFC 10008 §3
            return;
        }

        byte[] body = req.getInputStream().readAllBytes();

        // RFC 10008 §2.2 — body-keyed cache key (SHA-256 of URI + CT + body)
        String cacheKey = sha256Hex(
            (req.getRequestURI() + "\0" + ct + "\0").getBytes() ,body);

        byte[] cached = cache.get(cacheKey);
        if (cached != null) {
            resp.setContentType("application/json");
            resp.setHeader("X-Cache", "HIT");
            resp.getOutputStream().write(cached);
            return;
        }

        // QUERY is safe + idempotent → no side effects; safe to retry
        byte[] result = userRepository.search(body);
        cache.put(cacheKey, result);

        resp.setStatus(200);
        resp.setContentType("application/json");
        resp.setHeader("Cache-Control", "max-age=300");
        resp.setHeader("Content-Location", "/api/users/results/" + cacheKey); // RFC 10008 §4
        resp.setHeader("X-Cache", "MISS");
        resp.getOutputStream().write(result);
    }

    private String sha256Hex(byte[]... parts) throws IOException {
        try {
            var md = MessageDigest.getInstance("SHA-256");
            for (var p : parts) md.update(p);
            return HexFormat.of().formatHex(md.digest());
        } catch (Exception e) { throw new IOException(e); }
    }
}
// Spring Boot 6 — QUERY caching handler
// Spring Framework issue #36988 closed as "not planned" — @QueryMapping annotation
// cannot be added because @QueryMapping is already claimed by Spring GraphQL.
// Community-endorsed workaround (confirmed by Spring team): RequestMethod.valueOf("QUERY")
// This matches how @RequestMapping resolves any custom HTTP method name.

@RestController
@RequestMapping("/api/users")
public class UserQueryController {

    private final UserService userService;
    private final Cache<String, List<User>> queryCache;  // Caffeine / Ehcache

    // No @QueryMapping — use RequestMethod.valueOf("QUERY") workaround
    @RequestMapping(method = RequestMethod.valueOf("QUERY"),
                    consumes = MediaType.APPLICATION_JSON_VALUE,
                    produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<List<User>> queryUsers(@RequestBody UserFilter filter) {

        // RFC 10008 §2.2 — body-keyed cache: use deterministic filter key
        String cacheKey = filter.cacheKey();  // SHA-256(canonical JSON) of filter

        List<User> cached = queryCache.getIfPresent(cacheKey);
        if (cached != null) {
            return ResponseEntity.ok()
                .header("X-Cache", "HIT")
                .header("Content-Location", "/api/users/results/" + cacheKey)
                .body(cached);
        }

        // QUERY is idempotent — safe to run under Spring's @Cacheable as well
        List<User> results = userService.search(filter);
        queryCache.put(cacheKey, results);

        return ResponseEntity.ok()
            .header("Cache-Control", "max-age=300")
            .header("Content-Location", "/api/users/results/" + cacheKey) // RFC 10008 §4
            .header("X-Cache", "MISS")
            .body(results);
    }
}

// RestClient (Spring 6.1+) on the calling side — no special setup needed
var results = RestClient.create()
    .method(HttpMethod.valueOf("QUERY"))
    .uri("https://api.example.com/api/users")
    .contentType(MediaType.APPLICATION_JSON)
    .body(filter)
    .retrieve()
    .body(new ParameterizedTypeReference<List<User>>() {});
// Varnish VCL — full QUERY caching with body-keyed lookup
import digest;

sub vcl_recv {
  // Allow QUERY to pass through cache lookup (not just GET/HEAD)
  if (req.method == "QUERY") {
    // Buffer the body (requires bereq.body access in VCL 7+)
    set req.http.X-Query-Body-Hash =
      digest.hash_sha256(req.body);

    // Synthesise a cache-lookup key combining URI + body hash
    set req.http.X-Cache-Key =
      req.url + "|" + req.http.Content-Type + "|" + req.http.X-Query-Body-Hash;

    return(hash);   // proceed to cache lookup
  }
}

sub vcl_hash {
  if (req.method == "QUERY") {
    hash_data(req.http.X-Cache-Key);
    return(lookup);
  }
}

sub vcl_deliver {
  set resp.http.X-Cache = if(obj.hits > 0, "HIT", "MISS");
}
No-transform directive
Client sends Cache-Control: no-transform to prevent the cache from normalising the body before computing the key. The raw body is used as-is.
Body buffering cost
A caching proxy must buffer the full request body to compute the key — more expensive than GET. For large bodies (>1MB), streaming proxies may bypass the cache.
Content-Location
Server returns Content-Location: /users/queries/q-abc123. Client can GET that URI later without resending the body — no cache-key computation needed.

Error Response Matrix

RFC 10008 §2 defines specific HTTP status codes for each class of QUERY failure. Correct error responses are essential for client adaptation and debuggability.

WASM Error Simulator
Click a scenario button above...
Complete error reference
StatusTriggerRFC refSpecial header
400 Bad RequestMissing Content-Type header on QUERY request§2—
400 Bad RequestContent-Type claims JSON but body is XML (inconsistency)§2—
405 Method Not AllowedQUERY sent to a resource that does not support itRFC 9110Allow: GET, HEAD
406 Not AcceptableAccept header lists format server can't produce§2—
415 Unsupported Media TypeQuery body format not understood by server§2 + §3Accept-Query: application/json
422 Unprocessable ContentSyntactically valid query but semantically invalid§2—
200 OKQuery processed successfully§2Content-Location (optional)
Accept-Query header (RFC 10008 §3): When returning 415, the server MUST include an Accept-Query header listing the media types it does accept. This allows clients to adapt automatically. Example: Accept-Query: application/json, application/x-www-form-urlencoded
Server error handling — code examples
// pkg/query.Handler enforces RFC 10008 error semantics automatically
import "github.com/jralmaraz/http-query-method/pkg/query"

handler := query.NewHandler(
  func(req *query.Request) ([]byte, string, error) {
    // Validate query semantics
    var q SearchQuery
    if err := json.Unmarshal(req.Body, &q); err != nil {
      return nil, "", &query.QueryError{
        Status:  http.StatusUnprocessableEntity,   // 422
        Message: "invalid query: " + err.Error(),
      }
    }
    results := db.Search(q)
    out, _ := json.Marshal(results)
    return out, "application/json", nil
  },
  "application/json",   // supported types → Accept-Query on 415
)
// Missing Content-Type → 400 (automatic)
// Wrong Content-Type  → 415 + Accept-Query header (automatic)
const express = require('express');
const app = express();

// Express doesn't know QUERY natively — register it explicitly
app.all('/users', (req, res) => {
  if (req.method !== 'QUERY') {
    return res.set('Allow', 'QUERY').status(405).json({ error: 'Method Not Allowed' });
  }

  // RFC 10008 §2: validate Content-Type
  const ct = req.headers['content-type'] || '';
  if (!ct) {
    return res.status(400).json({ error: 'Content-Type is required for QUERY' });
  }
  if (!ct.includes('application/json')) {
    return res
      .set('Accept-Query', 'application/json')    // RFC 10008 §3
      .status(415).json({ error: 'Unsupported format' });
  }

  const results = db.search(req.body);
  res.json({ results });
});
from fastapi import FastAPI, Request, Response
from fastapi.routing import APIRoute

app = FastAPI()

# FastAPI allows custom HTTP methods via include_router
@app.api_route("/users", methods=["QUERY"])
async def query_users(request: Request):
    ct = request.headers.get("content-type", "")
    if not ct:
        return Response(
            content='{"error":"Content-Type required"}',
            status_code=400, media_type="application/json"
        )
    if "application/json" not in ct:
        return Response(
            content='{"error":"Unsupported format"}',
            status_code=415, media_type="application/json",
            headers={"Accept-Query": "application/json"}   # RFC 10008 §3
        )
    body = await request.json()
    results = db.search(body)
    return {"results": results}
import org.springframework.web.bind.annotation.*;
import org.springframework.http.*;

@RestController
@RequestMapping("/users")
public class UserController {

    // Register QUERY method — Spring MVC accepts arbitrary method names
    @RequestMapping(method = RequestMethod.valueOf("QUERY"),
                    consumes = MediaType.APPLICATION_JSON_VALUE,
                    produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<?> queryUsers(
            HttpServletRequest req,
            @RequestBody(required = false) UserFilter filter) {

        // 400 — missing Content-Type or body (Spring throws HttpMediaTypeNotSupportedException)
        if (filter == null) {
            return ResponseEntity.badRequest()
                .body(Map.of("error", "Content-Type and body required"));
        }

        // Business logic — safe to retry (QUERY is idempotent)
        var results = userService.search(filter);

        // 200 — include Content-Location for the result URI (RFC 10008 §4)
        return ResponseEntity.ok()
            .header("Content-Location", "/users/results/" + filter.cacheKey())
            .body(results);
    }

    // 415 — wrong Content-Type; Spring auto-sends this but you can customise:
    @ExceptionHandler(HttpMediaTypeNotSupportedException.class)
    public ResponseEntity<?> handleUnsupportedMedia() {
        return ResponseEntity.status(415)
            .header("Accept-Query", "application/json")  // RFC 10008 §3
            .body(Map.of("error", "Unsupported format; use application/json"));
    }

    // 405 — if someone sends GET or POST to this endpoint
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    public ResponseEntity<?> handleWrongMethod() {
        return ResponseEntity.status(405)
            .header("Allow", "QUERY, OPTIONS")
            .body(Map.of("error", "Use QUERY method for search operations"));
    }
}

Database & Backend Integration

The QUERY method is particularly powerful for database-backed APIs — any query language can be the body: SQL, GraphQL, JSONPath, or a custom DSL. See how different backends expose the QUERY method.

Client any language HTTP Server pkg/query.Handler validates Content-Type routes by media type Accept-Query: … PostgreSQL application/sql SELECT * FROM users QUERY + body read-only query 200 OK + JSON results Click a query type to animate
WASM DB Integration Demo
Click a query type above to run the demo...
Backend integration patterns
// PostgreSQL: accept application/sql body, execute as read-only query
handler := query.NewHandler(func(req *query.Request) ([]byte, string, error) {
  sql := string(req.Body)

  // Safety: only SELECT statements allowed (parse + verify before exec)
  if !isSafeSelect(sql) {
    return nil, "", &query.QueryError{Status: 422, Message: "only SELECT queries allowed"}
  }

  // Execute in a read-only transaction to enforce safety semantics
  rows, err := db.QueryContext(ctx, "BEGIN READ ONLY; "+sql+"; COMMIT")
  if err != nil {
    return nil, "", &query.QueryError{Status: 422, Message: err.Error()}
  }
  defer rows.Close()

  result := scanRows(rows)   // → []map[string]interface{}
  out, _ := json.Marshal(result)
  return out, "application/json", nil
}, "application/sql")

// Content-Location lets client bookmark results:
// Content-Location: /queries/results/sha256-abcdef
// GraphQL: accept application/graphql body
import "github.com/graphql-go/graphql"

handler := query.NewHandler(func(req *query.Request) ([]byte, string, error) {
  // Accept both pure GraphQL body and JSON-wrapped {query, variables}
  var gqlQuery string
  var variables map[string]interface{}

  if strings.HasPrefix(req.ContentType, "application/graphql") {
    gqlQuery = string(req.Body)
  } else {
    var envelope struct { Query string; Variables map[string]interface{} }
    json.Unmarshal(req.Body, &envelope)
    gqlQuery = envelope.Query
    variables = envelope.Variables
  }

  result := graphql.Do(graphql.Params{Schema: schema, RequestString: gqlQuery, VariableValues: variables})
  out, _ := json.Marshal(result)
  return out, "application/json", nil
}, "application/graphql", "application/json")
from fastapi import FastAPI, Request, Response
from sqlalchemy import text
import json

app = FastAPI()

@app.api_route("/data", methods=["QUERY"])
async def sql_query(request: Request):
    ct = request.headers.get("content-type", "")

    if "application/sql" not in ct:
        return Response(status_code=415,
                        headers={"Accept-Query": "application/sql, application/json"})

    sql = (await request.body()).decode()

    # Enforce read-only: parse AST, reject non-SELECT
    if not sql.strip().upper().startswith("SELECT"):
        return Response(status_code=422,
                        content='{"error":"Only SELECT allowed"}')

    with db.connect() as conn:
        result = conn.execute(text(sql))
        rows = [dict(r) for r in result]

    return {"results": rows, "total": len(rows)}
const express = require('express');
const { MongoClient } = require('mongodb');

app.all('/documents', async (req, res) => {
  if (req.method !== 'QUERY') return res.status(405).send();

  const filter  = req.body?.filter  || {};
  const project = req.body?.project || {};
  const sort    = req.body?.sort    || {};
  const limit   = Math.min(req.body?.limit || 100, 1000);

  // MongoDB filter expression is the QUERY body — no separate query param
  const results = await collection
    .find(filter, { projection: project })
    .sort(sort)
    .limit(limit)
    .toArray();

  // Return Content-Location for client-side bookmarking
  const queryHash = sha256(JSON.stringify(req.body));
  res
    .set('Content-Location', `/queries/${queryHash}`)
    .set('Cache-Control', 'max-age=300')
    .json({ results, total: results.length });
});

Live Playground

Send QUERY requests to the in-browser Go server (compiled to WASM). All processing happens locally — no network requests.

QUERY Request Builder
Enter a query body and click "Send QUERY"...

Standards Tracker

This project tracks RFC 10008 and related HTTP standards. A GitHub Actions workflow checks the IETF Datatracker daily and opens a GitHub issue on any update.

IETF Standard Lifecycle
Individual Draft
WG Draft
Last Call
IESG Review
Published RFC ← RFC 10008
PoC status legend
Implemented — has animated demo + Go code
Monitoring — tracked; not yet implemented
Excluded — out of scope; decision documented
Production readiness
Only Published RFCs should be used in production. WG Drafts and individual drafts are unstable — breaking changes are expected.
WG Draft caution
This PoC implements and monitors WG drafts for early adoption exploration. implemented_rev in the baseline locks the specific revision implemented.
Tracked standards

Last checked: 2026-08-02  ·  standards-baseline.json  ·  standards-tracker.yml

Standard WG / Publisher Lifecycle status PoC status Rev Affected files Impl. commit
RFC 10008 — HTTP QUERY Method IETF HTTP WG ● Published RFC Implemented RFC pkg/query, cmd/demo-wasm, demo/index.html initial
RFC 9110 — HTTP Semantics IETF HTTP WG ● Published RFC Implemented RFC pkg/query (method semantics, status codes, Allow header) initial
RFC 7234 — HTTP/1.1 Caching IETF HTTP WG ● Published RFC Implemented RFC pkg/cache (TTL, X-Cache, no-transform, body-keyed key) initial
RFC 8288 — Web Linking IETF HTTP WG ● Published RFC Monitoring RFC Content-Location / Location result URI semantics monitoring
RFC 7807 — Problem Details IETF HTTP WG ● Published RFC Monitoring RFC pkg/query error responses (could adopt application/problem+json) monitoring
Ecosystem Adoption

Researched 2026-08-29 via jeswr/http-query-adoption, desiderantes' tracker, and primary issue trackers. Full data in standards-baseline.json → ecosystem_adoption.

Servers & Runtimes

ImplementationStatusDetails
Apache Tomcat 12 ✅ Shipped apache/tomcat#1026 merged July 1, 2026. Native doQuery() in HttpServlet (Servlet 6.1). First Java container with native support. Requires Tomcat 12.0.x minimum.
Node.js (http) ✅ Works Node.js http module has parsed QUERY natively since early 2024 — the http parser imposes no method allowlist. No special config. undici explicit support: nodejs/undici#5454 (open, body-aware cache key).
Eclipse Jetty 13 🔄 PR open jetty.project#15316 — implements safe+idempotent registration, Accept-Query header, redirect semantics, compression handler integration. Not yet merged.
nginx 🔄 PR open nginx/nginx#1488 — basic QUERY method recognition. Note: proxy_cache_methods GET HEAD QUERY already works as a Varnish/cache config workaround today.

Frameworks

FrameworkStatusDetails
ASP.NET Core / .NET 10 ✅ Shipped Ships HttpMethods.Query constant, MapMethods(), and [AcceptVerbs] support. First-class — no workarounds.
Quarkus / Vert.x ✅ Works Netty-based routing imposes no method restriction. QUERY reaches route handlers without special handling. Route via .method(HttpMethod.valueOf("QUERY")).
Express.js ✅ Works app.all('/path', handler) + if (req.method !== 'QUERY') return res.status(405). No dedicated app.query() — works today without changes.
Spring Framework MVC ❌ Not planned spring-framework#36988 closed not-planned. @QueryMapping conflicts with Spring GraphQL. Workaround (Spring-confirmed): @RequestMapping(method = RequestMethod.valueOf("QUERY")) — what this demo uses. Stable, correct.
Fastify 🔄 Issue open fastify#6807 — proposes fastify.query() shorthand, schema validation, lifecycle hooks. Works today via fastify.route({method:'QUERY', ...}).

Proxies, CDNs & API Tooling

ImplementationStatusDetails
Varnish / HAProxy ✅ Works Both forward arbitrary methods. Varnish VCL body-keyed caching works today (see Caching tab). HAProxy passes QUERY transparently with no config changes.
Envoy Proxy 🔄 PRs open PRs open for HTTP/1, HTTP/2, and HTTP/3 QUERY support. Envoy's codec currently normalises unknown methods in ways that may drop the body or misclassify safety.
Cloudflare Workers 🔄 Issue open Issue open for body-keyed QUERY caching in workerd. RFC 10008 was co-authored by Cloudflare engineers — production edge support expected but no ship date.
OpenAPI 3.2 ✅ Shipped OpenAPI 3.2 path items formally support QUERY as a method. API specs can now describe QUERY endpoints with full schema, request body, and response definitions.
OpenSearch / Elasticsearch 🔄 RFC open OpenSearch#22409 — RFC to replace POST /_search with QUERY /_search, making all search safe+cacheable. Synchronized rollout with Elasticsearch under discussion.

Browser & Standards Body Signals

Body / VendorStatusDetails
WHATWG Fetch 🔄 Issue open whatwg/fetch#1938 (June 30, 2026) — three open integration questions: (1) method normalization, (2) CORS preflight by design, (3) body-keyed cache model. See "Path to Browser Support" below.
Mozilla Firefox 💬 Position pending mozilla/standards-positions#1430 — position requested July 2026. No position published. Not requesting immediate ship — guiding WHATWG Fetch/HTML work.
WebKit / Safari 💬 Position pending WebKit/standards-positions#692 — companion to Mozilla #1430 and Fetch #1938. No position published.
Chrome / Blink — No signal No Chrome Platform Status entry or Intent to Prototype as of 2026-08-29. Chrome does not cache repeated identical QUERY requests — body-keyed caching not implemented. Waiting on WHATWG Fetch #1938.
Spring Framework: Issue #36988 is closed, not open — the RequestMethod.valueOf("QUERY") pattern is the correct, stable, Spring-team-endorsed approach. No better option is coming.
Path to Browser Support

QUERY is safe, idempotent, and cacheable — the perfect semantics for a browser. Yet browser support involves several independent problems that must each be solved in the right order. Here is what needs to happen, and why it is non-trivial despite QUERY being, on the surface, a simple new HTTP method.

1
WHATWG Fetch Standard update ▸ In progress (whatwg/fetch#1938)

Before any browser can implement QUERY, the WHATWG Fetch Standard must be updated. Three questions must be resolved:

① Method normalization footgun
The Fetch spec currently uppercases only DELETE, GET, HEAD, OPTIONS, POST, PUT. So fetch(url, {method: 'query'}) sends lowercase query — not QUERY. Servers that do case-sensitive method matching will reject it silently. QUERY must be added to the "normalize a method" algorithm to prevent this footgun.

② CORS — by design, no change needed
QUERY is not CORS-safelisted, so every cross-origin QUERY triggers a preflight (OPTIONS). RFC 10008 §4 explicitly expects this. Servers must include Access-Control-Allow-Methods: QUERY in their preflight response. This is already the correct behaviour — no Fetch spec change required for CORS.

③ Body-keyed cache — the hard one
RFC 10008 §2.7 makes QUERY responses cacheable with a key that incorporates the request body. The Fetch spec's HTTP-cache model is URL-keyed — no existing mechanism for body-keyed entries. Must decide: does Fetch reflect body-keyed caching, or leave it entirely to the HTTP layer? Currently, Chrome and Firefox both cache GET but neither caches a repeated identical QUERY.
2
Vendor standards positions ▸ In progress (Mozilla #1430, WebKit #692)

Mozilla, WebKit, and Chrome must each publish a formal position before their engineers begin implementation. These positions signal: "we think this is a good idea and we intend to implement it." Without all three, the feature stalls because the browser interop requirement (identical behaviour across engines) cannot be guaranteed. The positions were requested in late June/July 2026 — expect 2–6 months for each engine's review process.

3
HTTP stack implementation ▸ Not started

Four distinct browser subsystems each require changes:

HTTP method dispatch
Register QUERY as safe + idempotent in the browser's HTTP engine. This unlocks safe-retry on timeout/5xx and correct interaction with preloading.
Body-keyed HTTP cache
The most architecturally novel piece: browsers must extend the HTTP cache to key QUERY entries on sha256(URI + Content-Type + body). No existing method does this. Requires memory and disk cache changes in each engine.
Service Worker Cache API
cache.put(queryRequest, response) must store and retrieve by body. Current Cache API is URL-keyed per the Fetch spec. A body-keyed variant is a new API surface — needs specification and implementation.
HTML form support
<form method="QUERY"> does not work — WHATWG HTML lists only GET and POST as valid form methods. Adding QUERY requires both the HTML spec and browser HTML parser to be updated.
4
Web Platform Tests + DevTools ▸ Not started

Cross-browser interoperability is enforced through the WPT (Web Platform Tests) suite. QUERY tests must be written and pass in all three engines before the feature can be considered "interoperable." DevTools updates are also required: the Network panel currently treats body-carrying requests as POST-like (non-cacheable). QUERY needs its own badge showing safe+idempotent+cacheable with the body-keyed cache key visible in the headers inspector.

5
Infrastructure catch-up ▸ Parallel to Phases 3–4

Browser adoption will instantly expose gaps in the surrounding infrastructure: WAFs that block unknown methods (many enterprise WAFs currently return 405 for anything not in GET/POST/PUT/DELETE/PATCH/HEAD/OPTIONS); CSP doesn't restrict methods but tooling may flag QUERY in security scans; API gateways like Kong and AWS API Gateway need QUERY added to their allowed-methods lists; CDN edge caches (Cloudflare, Fastly, Akamai) need body-keyed cache entries. RFC 10008 was co-authored by Cloudflare and Akamai engineers precisely to drive this alignment from the top, but enterprise WAF rules take years to propagate.

The WAF problem is real today: even without browser support, developers deploying QUERY in production report WAFs and API gateways returning 405. Test your stack end-to-end before relying on QUERY in production — see the Hive Security research on the attack surface still catching up.
Realistic timeline estimate
2026 H2
Vendor positions published. Fetch #1938 consensus.
2027 H1
First browser lands method dispatch + CORS. WPT tests written.
2027 H2
Body-keyed cache shipped in 1–2 browsers. Interop flag.
2028+
Baseline interop. WAF / CDN catch-up. <form method="QUERY">.

Estimates based on comparable method/cache changes (e.g. PATCH — RFC 5789, 2010 → broadly supported by 2012; HTTP/2 push → deprecated 2022 despite complexity). Body-keyed cache is the wildcard — it has no precedent in browser engines.

Automated tracking
Daily cron
GitHub Actions runs scripts/check_standards.py at 09:00 UTC. Queries the IETF Datatracker API for each tracked standard and compares to standards-baseline.json.
RFC finality check
For Published RFCs, the tracker checks for errata or obsoleted-by relationships. An RFC being obsoleted is treated as a high-priority update.
Issue opened on change
A standards-update labelled issue lists the change, the PoC impact, affected files, and the due-diligence checklist from docs/standards-tracking.md.
Loading WASM...