HTTP QUERY 方法,以及到底谁支持它

分享

6 月,IETF 发布了 RFC 10008,定义了一个名为 QUERY 的新 HTTP 方法。上一个被标准化的方法是 2010 年的 PATCH。QUERY 的工作方式就像一个带请求体的 GET:它是安全的、幂等的、可缓存的,查询内容放在请求体里,而不是 URL 里。

这听上去应该早就被所有人支持了,所以我们测了一下。我们向五个公共测试 API(包括我们自己的)发送了 QUERY 请求,得到了五种不同的响应,只有一个真正执行了查询。我们还试了 Node、ASP.NET Core 和 Python,三者都处理得毫无问题。在应用代码里,这个方法已经可以用了。 沿途的代理和服务器则是另一回事。

QUERY 解决的问题

假设你有一个商品搜索,带十几个筛选条件、一个嵌套的价格区间和一个排序方式。现在你有两种选择,各有缺点。

  • 把一切都放进查询字符串的 GET。你得想办法把嵌套的筛选条件编码进 URL。长 URL 会碰到服务器和代理的长度限制,整个查询还会出现在访问日志、浏览器历史和 Referer 请求头里。给 GET 加请求体也没用:RFC 9110 规定 GET 请求中的内容没有定义好的含义,很多代理和客户端会直接丢掉它。
  • POST /search。大多数 API 最后都这么做。它能用,但 POST 告诉每个客户端和代理,这个请求可能会改变某些东西。 所以连接断开后没有任何东西会自动重试它,也没有缓存会保存结果。

QUERY 给了你一个请求体,同时客户端、代理和缓存仍能像对待 GET 一样对待它:

GETPOSTQUERY
请求体有定义好的含义否是是
安全(不改变任何东西)是否是
幂等(可以放心重试)是否是
响应可缓存是很少是,以请求体为缓存键
查询会出现在 URL 和日志中是否否

QUERY 请求长什么样

它看起来像 POST,只是方法名告诉沿途的所有人:它是只读的。

请求
QUERY /products/search HTTP/1.1
Host: shop.example
Content-Type: application/json
Accept: application/json

{"category":"electronics","maxPrice":500,"inStock":true,"sort":"price"}
响应
HTTP/1.1 200 OK
Content-Type: application/json
Location: /products/search/q/7f3a9c
Cache-Control: max-age=60

[{"id":4,"name":"USB-C hub","price":39},{"id":11,"name":"Mechanical keyboard","price":89}]

Location 响应头是可选的,也是 RFC 里比较有用的部分之一。它给出一个用普通 GET 就能执行同一查询的 URL,所以客户端只需发送一次请求体,之后就能用普通链接。Content-Location 的意思略有不同:它是返回这一个具体结果的 URL。服务器也可以不带任何结果,直接回复 303 See Other,让客户端去 Location 里的 URL 用 GET 获取结果。

RFC 对服务器的要求

大部分要求都是对请求体要严格,因为请求体就是查询本身:

  • Content-Type 是必需的。如果缺少,服务器用 4xx(通常是 400)拒绝请求。服务器不能根据请求体去猜类型。
  • 资源不接受的查询格式返回 415。与声明类型不符的请求体返回 400,格式正确但服务器无法执行的查询返回 422。
  • 新的 Accept-Query 响应头列出资源接受的查询格式。它也告诉客户端这里支持 QUERY:
响应
HTTP/1.1 415 Unsupported Media Type
Accept-Query: application/json, "application/jsonpath"
  • 缓存可以保存 QUERY 的响应,但缓存键必须包含请求体。对同一 URL、筛选条件不同的两次查询,是两条不同的缓存记录。
  • 遇到 301 或 302 重定向后,客户端会把 QUERY 重新发到新的 URL。客户端在这类重定向后常常把 POST 改成 GET,RFC 对 QUERY 明确禁止这样做。

我们向五个公共测试 API 发送了 QUERY

测试 API 本来就是为了原样返回你发的东西而建的,所以你会以为它们是最宽容的服务器。脚本如下:

probe-query.mjs
// Send one QUERY request to each URL and report what came back.
const targets = [
  "https://httpbin.org/anything",
  "https://httpbingo.org/anything",
  "https://postman-echo.com/query",
  "https://jsonplaceholder.typicode.com/posts",
  "https://api.testingapis.com/api/methods/any",
];

for (const url of targets) {
  const res = await fetch(url, {
    method: "QUERY", // capitals: fetch only uppercases the six standard methods
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ category: "electronics", maxPrice: 500 }),
  });
  const type = (res.headers.get("content-type") ?? "-").split(";")[0];
  const allow = res.headers.get("allow") ?? "-";
  console.log(new URL(url).host.padEnd(29), res.status, type.padEnd(17), "allow:", allow);
}
输出(Node 24,2026 年 10 月 10 日)
httpbin.org                   405 text/html         allow: HEAD, POST, TRACE, PUT, GET, OPTIONS, DELETE, PATCH
httpbingo.org                 200 application/json  allow: -
postman-echo.com              400 text/plain        allow: -
jsonplaceholder.typicode.com  404 application/json  allow: -
api.testingapis.com           400 text/plain        allow: -

五个服务器,五种不同的回答:

API响应它告诉客户端什么
httpbingo.org200,并原样返回方法和请求体能用。httpbingo 是 httpbin 的 Go 重写版,Go 的 net/http 会放行任何方法。
httpbin.org带 Allow 响应头的 405拒绝的正确方式:不支持这个方法,这里是支持的方法列表。
jsonplaceholder.typicode.com404有误导性。URL 是存在的,只是路由没匹配上,客户端会以为自己路径写错了。
postman-echo.com400,11 字节的 text/plain说请求格式有误,但其实没有。
api.testingapis.com400,11 字节的 text/plain同上,而这个是我们自己的。

这是我们自己的 API 失败的样子:

Terminal
$ curl -i -X QUERY https://api.testingapis.com/api/methods/any \
    -H "content-type: application/json" -d '{"q":"x"}'
HTTP/1.1 400 Bad Request
content-length: 11
content-type: text/plain
connection: close

Bad Request

这个响应根本没到达我们的应用。我们的 ASP.NET Core 应用发出的每个响应都带有 server: Kestrel 和 x-powered-by: TestingAPIs 响应头,而这个两个都没有。API 运行在 Azure Container Apps 上,前面的入口代理在转发之前就拒绝了这个方法。Postman Echo 返回同样的 11 字节响应体和一个 x-envoy-upstream-service-time 响应头,看来那边在 Envoy 后面也发生了同样的事。对 FOO 这样随便编的方法,我们也得到同样的 400,所以代理把 QUERY 当成了未知方法,而不是错误的请求。

教训在于问题出在哪里。你的框架可能把 QUERY 处理得很好,而它前面的负载均衡器、入口或 WAF 却拒绝了请求,返回的错误也看不出任何端倪。所以测试 QUERY 时,要经过真实客户端会经过的每一跳,而不只是对着 localhost。

框架已经支持了

应用代码的情况要好得多。我们在写这篇文章的机器上试了三种服务器,每一种都不需要额外配置就能处理 QUERY。

Node.js 24。内置的 HTTP 解析器认识这个方法,fetch 也能发送它:

server.mjs
import http from "node:http";

console.log(http.METHODS.includes("QUERY")); // true on Node 24

http
  .createServer((req, res) => {
    if (req.method !== "QUERY") {
      res.writeHead(405, { allow: "QUERY" }).end();
      return;
    }
    let body = "";
    req.on("data", (chunk) => (body += chunk));
    req.on("end", () => {
      const filters = JSON.parse(body);
      res.writeHead(200, { "content-type": "application/json" });
      res.end(JSON.stringify({ method: req.method, filters }));
    });
  })
  .listen(3000);

ASP.NET Core 10。.NET 10 在服务端提供了 HttpMethods.Query,在 HttpClient 端提供了 HttpMethod.Query:

Program.cs
using System.Net.Http.Json;

var app = WebApplication.Create(args);

app.MapMethods("/products/search", [HttpMethods.Query], async (HttpRequest req) =>
{
    var filters = await req.ReadFromJsonAsync<ProductFilter>();
    return Results.Ok(new { method = req.Method, filters });
});

app.Urls.Add("http://localhost:5000");
await app.StartAsync();

// Call it with HttpClient, then try a GET on the same route.
using var http = new HttpClient();
var msg = new HttpRequestMessage(HttpMethod.Query, "http://localhost:5000/products/search")
{
    Content = JsonContent.Create(new { category = "electronics", maxPrice = 500 })
};
var res = await http.SendAsync(msg);
Console.WriteLine($"QUERY: {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");

var get = await http.GetAsync("http://localhost:5000/products/search");
Console.WriteLine($"GET:   {(int)get.StatusCode} Allow: {string.Join(", ", get.Content.Headers.Allow)}");

await app.StopAsync();

record ProductFilter(string Category, decimal MaxPrice);
输出(.NET 10)
QUERY: 200 {"method":"QUERY","filters":{"category":"electronics","maxPrice":500}}
GET:   405 Allow: QUERY

注意第二行。对一个只处理 QUERY 的路由发 GET,不用你写任何代码就会得到带 Allow: QUERY 的 405。这和 httpbin.org 给我们的礼貌拒绝一模一样。

Python。http.server 把每个请求交给对应的 do_<METHOD> 处理函数,所以加上 QUERY 只需要一个方法。在客户端,urllib.request.Request 接受 method="QUERY"。

server.py
from http.server import BaseHTTPRequestHandler, HTTPServer
import json

class Handler(BaseHTTPRequestHandler):
    def do_QUERY(self):
        length = int(self.headers.get("content-length", 0))
        filters = json.loads(self.rfile.read(length))
        body = json.dumps({"method": self.command, "filters": filters}).encode()
        self.send_response(200)
        self.send_header("content-type", "application/json")
        self.end_headers()
        self.wfile.write(body)

HTTPServer(("127.0.0.1", 8000), Handler).serve_forever()

要用大写

HTTP 方法名区分大小写。fetch 标准只会帮你把六个方法转成大写:DELETE、GET、HEAD、OPTIONS、POST 和 PUT。其他方法都原样发出。下面是一个 Node 服务器对三种写法的返回:

输出(Node 24)
"query" -> 400
"QUERY" -> 200 QUERY
"post" -> 200 POST

method: "query" 会以小写的 query 发出,服务器返回 400。PATCH 多年来也有同样的坑,你写 "patch" 时 Node 会打印警告,但对 "query" 不会。如果 QUERY 请求失败而你看不出原因,检查一下大小写。

浏览器会先发预检请求

QUERY 不在浏览器无需先询问就能跨域发送的那份短名单里。所以在跨域 QUERY 之前,浏览器会先发一个 OPTIONS 预检请求,服务器必须在 Access-Control-Allow-Methods 里列出 QUERY。httpbingo 是唯一执行了我们查询的 API,下面是它对浏览器的回答:

Terminal
$ curl -i -X OPTIONS https://httpbingo.org/anything \
    -H "Origin: https://example.com" \
    -H "Access-Control-Request-Method: QUERY" \
    -H "Access-Control-Request-Headers: content-type"
HTTP/1.1 200 OK
access-control-allow-origin: https://example.com
access-control-allow-methods: GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS
access-control-allow-headers: content-type

这个列表里没有 QUERY,所以同一个在 Node 中能成功的请求,在浏览器里会因 CORS 错误而失败。如果你的 CORS 配置按名称列出允许的方法,也把 QUERY 加进去。

OpenAPI 3.2 可以描述它

2025 年 9 月发布的 OpenAPI 3.2 在 path item 中,与 get 和 post 并列,新增了 query 操作:

openapi.yaml
openapi: 3.2.0
info:
  title: Shop
  version: 1.0.0
paths:
  /products/search:
    query:
      summary: Search products with filters in the body
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ProductFilter"
      responses:
        "200":
          description: Matching products

至于你的文档生成器、客户端生成器或 API 网关是否已经读取这个字段,那是另一个问题。和网络一样,依赖它之前先检查一下。

现在应该用 QUERY 吗?

对于你自己服务之间的流量,你能控制每一跳,那就用。把完整路径测一遍,你就能得到 POST /search 给不了的重试和缓存。

对于公开 API,我们会在不拿掉任何东西的前提下加上它:

  • 保持 POST /search 可用,在同一个资源上加上 QUERY。发不了 QUERY 的客户端和网络依然有路可走。
  • 发送 Accept-Query,让客户端在尝试之前就知道支持 QUERY。
  • 返回 Location 响应头。客户端执行过一次查询后,就能用 GET 重复它,而 GET 可以穿过所有代理。
  • 在不支持 QUERY 的路由上,返回带 Allow 响应头的 405。不要返回 400 或 404,它们暗示的是客户端请求有问题。

如果你在检查客户端如何应对拒绝某个方法的服务器,我们的 HTTP 方法课程讲了 405 和 Allow,错误响应指南介绍了如何按需从我们的 API 获取 4xx 和 5xx 响应。

PATCH 花了好几年才在客户端、代理和框架之间可靠地工作,QUERY 大概也需要一段时间。这次的不同在于,应用代码从第一天起就准备好了。还在失败的是代理。