O método HTTP QUERY, e quem de fato o suporta
Em junho a IETF publicou a RFC 10008, que define um novo método HTTP chamado QUERY. O último método padronizado tinha sido o PATCH, em 2010. O QUERY funciona como um GET com corpo: é seguro, idempotente e cacheável, e a consulta vai no corpo da requisição em vez de na URL.
Parece algo que todo mundo já deveria suportar, então testamos. Mandamos uma requisição QUERY para cinco APIs de teste públicas, incluindo a nossa, e recebemos cinco respostas diferentes. Só uma executou a consulta. Também testamos Node, ASP.NET Core e Python, e os três lidaram com ele sem problema. O método está pronto no código de aplicação. Proxies e servidores no caminho são outra história.
O problema que o QUERY resolve
Imagine uma busca de produtos com uma dúzia de filtros, uma faixa de preço aninhada e uma ordenação. Hoje você tem duas opções, e as duas têm desvantagens.
- GET com tudo na query string. Você precisa codificar os filtros aninhados na URL de algum jeito. URLs longas esbarram em limites de tamanho em servidores e proxies, e a consulta inteira vai parar nos logs de acesso, no histórico do navegador e nos cabeçalhos
Referer. Colocar corpo num GET não ajuda: a RFC 9110 diz que conteúdo numa requisição GET não tem significado definido, e muitos proxies e clientes o descartam. - POST /search. É o que a maioria das APIs acaba fazendo. Funciona, mas o POST diz a todo cliente e proxy que a requisição pode mudar alguma coisa. Então nada a repete automaticamente depois de uma conexão caída, e nenhum cache guarda o resultado.
O QUERY dá um corpo de requisição que clientes, proxies e caches ainda podem tratar como um GET:
| GET | POST | QUERY | |
|---|---|---|---|
| O corpo da requisição tem significado definido | Não | Sim | Sim |
| Seguro (não muda nada) | Sim | Não | Sim |
| Idempotente (pode repetir) | Sim | Não | Sim |
| Respostas cacheáveis | Sim | Raramente | Sim, com o corpo na chave |
| A consulta vai parar em URLs e logs | Sim | Não | Não |
Como é uma requisição QUERY
Parece um POST, só que o nome do método avisa a todos no caminho que ela é somente leitura:
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}]O cabeçalho Location é opcional, e é uma das partes mais úteis da RFC. Ele dá uma URL que executa a mesma consulta com um GET comum, então o cliente envia o corpo uma vez e depois usa um link normal. Content-Location significa algo um pouco diferente: uma URL que devolve este resultado específico. O servidor também pode responder 303 See Other sem resultado nenhum, o que manda o cliente buscá-los com GET na URL do Location.
O que a RFC pede dos servidores
A maior parte é sobre ser rigoroso com o corpo, porque o corpo é a consulta:
Content-Typeé obrigatório. Se faltar, o servidor rejeita a requisição com um 4xx, normalmente 400. O servidor não deve adivinhar o tipo pelo corpo.- Um formato que o recurso não aceita para consultas recebe
415. Um corpo que não bate com o tipo declarado recebe400, e uma consulta bem formada que o servidor não consegue executar recebe422. - Um novo cabeçalho de resposta,
Accept-Query, lista os formatos de consulta que um recurso aceita. Ele também avisa aos clientes que o QUERY é suportado:
HTTP/1.1 415 Unsupported Media Type
Accept-Query: application/json, "application/jsonpath"- Caches podem guardar respostas de QUERY, mas a chave do cache precisa incluir o corpo da requisição. Duas consultas à mesma URL com filtros diferentes são duas entradas diferentes no cache.
- Depois de um redirecionamento 301 ou 302, o cliente envia o QUERY de novo para a nova URL. Clientes costumam transformar um POST em GET depois desses redirecionamentos, e a RFC proíbe isso para o QUERY.
Mandamos QUERY para cinco APIs de teste públicas
APIs de teste existem para devolver o que você manda, então era de se esperar que fossem os servidores mais tolerantes que existem. Este é o script:
// 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: -São cinco servidores e cinco respostas diferentes:
| API | Resposta | O que isso diz a um cliente |
|---|---|---|
| httpbingo.org | 200 com o método e o corpo devolvidos | Funciona. O httpbingo é uma reescrita do httpbin em Go, e o net/http do Go deixa passar qualquer método. |
| httpbin.org | 405 com um cabeçalho Allow | O jeito certo de dizer não: o método não é suportado, e aqui está a lista dos que são. |
| jsonplaceholder.typicode.com | 404 | Enganoso. A URL existe, mas o roteador não achou rota, então um cliente acharia que errou o caminho. |
| postman-echo.com | 400, 11 bytes de text/plain | Diz que a requisição estava malformada, e não estava. |
| api.testingapis.com | 400, 11 bytes de text/plain | A mesma coisa, e esta é a nossa. |
Assim a nossa própria API falha:
$ 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 RequestEssa resposta nunca chegou à nossa aplicação. Toda resposta que o nosso app ASP.NET Core envia traz os cabeçalhos server: Kestrel e x-powered-by: TestingAPIs, e esta não tem nenhum dos dois. A API roda no Azure Container Apps, e o proxy de entrada na frente dela rejeitou o método antes de repassá-lo. O Postman Echo devolve o mesmo corpo de 11 bytes e um cabeçalho x-envoy-upstream-service-time, então parece que o mesmo acontece lá, atrás do Envoy. Recebemos o mesmo 400 com um método inventado como FOO, então o proxy trata o QUERY como método desconhecido, não como requisição ruim.
A lição é sobre onde está o problema. Seu framework pode lidar bem com o QUERY enquanto um balanceador de carga, ingress ou WAF na frente dele recusa a requisição, e o erro que volta não dá nenhuma pista disso. Então teste o QUERY passando por cada salto que um cliente real atravessa, não só contra o localhost.
Os frameworks já suportam
O código de aplicação está bem melhor. Testamos três servidores na máquina em que estamos escrevendo isto, e cada um lidou com o QUERY sem configuração extra.
Node.js 24. O parser HTTP embutido conhece o método, e o fetch o envia:
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. O .NET 10 traz HttpMethods.Query no lado do servidor e HttpMethod.Query para o HttpClient:
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: QUERYRepare na segunda linha. Um GET numa rota que só aceita QUERY recebe um 405 com Allow: QUERY sem você escrever nada para isso. É a mesma recusa educada que o httpbin.org nos deu.
Python. O http.server passa cada requisição para um handler do_<METHOD>, então adicionar QUERY custa um método. No lado do cliente, urllib.request.Request aceita 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()Escreva em maiúsculas
Nomes de métodos HTTP diferenciam maiúsculas de minúsculas. O padrão fetch põe seis deles em maiúsculas para você: DELETE, GET, HEAD, OPTIONS, POST e PUT. Todo o resto sai exatamente como você digitou. Isto é o que um servidor Node devolveu para três grafias:
"query" -> 400
"QUERY" -> 200 QUERY
"post" -> 200 POSTmethod: "query" sai como query em minúsculas, e o servidor responde 400. O PATCH tem a mesma armadilha há anos, e o Node mostra um aviso quando você escreve "patch". Para "query" ele não avisa. Se uma requisição QUERY falhar e você não entender por quê, confira as maiúsculas.
Navegadores mandam um preflight antes
O QUERY não está na lista curta de métodos que os navegadores enviam entre origens sem perguntar antes. Então, antes de um QUERY entre origens, o navegador manda um preflight OPTIONS, e o servidor precisa citar QUERY em Access-Control-Allow-Methods. O httpbingo foi a única API que executou nossa consulta, e é isto que ele diz a um navegador:
$ 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-typeO QUERY não está nessa lista, então a mesma requisição que funcionou pelo Node falharia no navegador com um erro de CORS. Se suas configurações de CORS listam os métodos permitidos por nome, adicione QUERY também.
O OpenAPI 3.2 consegue descrever
O OpenAPI 3.2, lançado em setembro de 2025, acrescentou uma operação query aos path items, ao lado de get e post:
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 productsSe o seu gerador de documentação, gerador de clientes ou gateway de API já lê esse campo é outra questão. Confira antes de depender disso, igual com a rede.
Já dá para usar o QUERY?
Para tráfego entre os seus próprios serviços, onde você controla cada salto, sim. Teste o caminho completo uma vez e você ganha retries e cache que o POST /search não oferece.
Para uma API pública, nós adicionaríamos sem tirar nada:
- Mantenha o
POST /searchfuncionando e adicione QUERY no mesmo recurso. Clientes e redes que não conseguem mandar QUERY continuam tendo uma entrada. - Envie
Accept-Querypara que os clientes saibam que o QUERY é suportado antes de tentar. - Devolva um cabeçalho
Location. Depois que um cliente executa uma consulta, ele pode repeti-la com um GET, que passa por qualquer proxy que existe. - Nas rotas que não suportam QUERY, responda
405com um cabeçalhoAllow. Não responda 400 nem 404, que sugerem um problema na requisição do cliente.
Se você está verificando como o seu cliente lida com um servidor que recusa um método, nossa lição de métodos HTTP cobre 405 e Allow, e o guia de respostas de erro mostra como obter respostas 4xx e 5xx da nossa API quando quiser.
O PATCH levou anos para funcionar de forma confiável entre clientes, proxies e frameworks, e o QUERY provavelmente também vai levar um tempo. A diferença desta vez é que o código de aplicação está pronto desde o primeiro dia. É nos proxies que ele ainda falha.