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 一样对待它:
| GET | POST | QUERY | |
|---|---|---|---|
| 请求体有定义好的含义 | 否 | 是 | 是 |
| 安全(不改变任何东西) | 是 | 否 | 是 |
| 幂等(可以放心重试) | 是 | 否 | 是 |
| 响应可缓存 | 是 | 很少 | 是,以请求体为缓存键 |
| 查询会出现在 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 本来就是为了原样返回你发的东西而建的,所以你会以为它们是最宽容的服务器。脚本如下:
// 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);
}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.org | 200,并原样返回方法和请求体 | 能用。httpbingo 是 httpbin 的 Go 重写版,Go 的 net/http 会放行任何方法。 |
| httpbin.org | 带 Allow 响应头的 405 | 拒绝的正确方式:不支持这个方法,这里是支持的方法列表。 |
| jsonplaceholder.typicode.com | 404 | 有误导性。URL 是存在的,只是路由没匹配上,客户端会以为自己路径写错了。 |
| postman-echo.com | 400,11 字节的 text/plain | 说请求格式有误,但其实没有。 |
| api.testingapis.com | 400,11 字节的 text/plain | 同上,而这个是我们自己的。 |
这是我们自己的 API 失败的样子:
$ 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 也能发送它:
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:
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);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"。
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 服务器对三种写法的返回:
"query" -> 400
"QUERY" -> 200 QUERY
"post" -> 200 POSTmethod: "query" 会以小写的 query 发出,服务器返回 400。PATCH 多年来也有同样的坑,你写 "patch" 时 Node 会打印警告,但对 "query" 不会。如果 QUERY 请求失败而你看不出原因,检查一下大小写。
浏览器会先发预检请求
QUERY 不在浏览器无需先询问就能跨域发送的那份短名单里。所以在跨域 QUERY 之前,浏览器会先发一个 OPTIONS 预检请求,服务器必须在 Access-Control-Allow-Methods 里列出 QUERY。httpbingo 是唯一执行了我们查询的 API,下面是它对浏览器的回答:
$ 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: 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 大概也需要一段时间。这次的不同在于,应用代码从第一天起就准备好了。还在失败的是代理。