El método HTTP QUERY, y quién lo soporta de verdad
En junio la IETF publicó la RFC 10008, que define un nuevo método HTTP llamado QUERY. El último método que se estandarizó fue PATCH, en 2010. QUERY funciona como un GET con cuerpo: es seguro, idempotente y cacheable, y la consulta va en el cuerpo de la petición en lugar de en la URL.
Suena a algo que todo el mundo debería soportar ya, así que lo probamos. Enviamos una petición QUERY a cinco APIs de prueba públicas, la nuestra incluida, y recibimos cinco respuestas distintas. Solo una ejecutó la consulta. También probamos Node, ASP.NET Core y Python, y los tres lo manejaron sin problemas. El método está listo en el código de aplicación. Los proxies y servidores intermedios son otra historia.
El problema que resuelve QUERY
Imagina una búsqueda de productos con una docena de filtros, un rango de precios anidado y un orden. Hoy tienes dos opciones, y las dos tienen pegas.
- GET con todo en la query string. Tienes que codificar los filtros anidados en la URL de alguna forma. Las URLs largas chocan con los límites de longitud de servidores y proxies, y la consulta entera acaba en los logs de acceso, el historial del navegador y las cabeceras
Referer. Poner un cuerpo en un GET no ayuda: la RFC 9110 dice que el contenido de una petición GET no tiene un significado definido, y muchos proxies y clientes lo descartan. - POST /search. Es lo que acaban haciendo casi todas las APIs. Funciona, pero POST les dice a todos los clientes y proxies que la petición podría cambiar algo. Así que nada la reintenta automáticamente tras una conexión caída, y ninguna caché guarda el resultado.
QUERY te da un cuerpo de petición que clientes, proxies y cachés pueden seguir tratando como un GET:
| GET | POST | QUERY | |
|---|---|---|---|
| El cuerpo de la petición tiene un significado definido | No | Sí | Sí |
| Seguro (no cambia nada) | Sí | No | Sí |
| Idempotente (se puede reintentar) | Sí | No | Sí |
| Respuestas cacheables | Sí | Rara vez | Sí, con el cuerpo como clave |
| La consulta acaba en URLs y logs | Sí | No | No |
Cómo es una petición QUERY
Parece un POST, salvo que el nombre del método les dice a todos por el camino que es de solo lectura:
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}]La cabecera Location es opcional, y es una de las partes más útiles de la RFC. Da una URL que ejecuta la misma consulta con un GET normal, así que un cliente puede enviar el cuerpo una vez y usar un enlace corriente después. Content-Location significa algo un poco distinto: una URL que devuelve este resultado concreto. Un servidor también puede responder 303 See Other sin ningún resultado, lo que manda al cliente a obtenerlos con GET desde la URL de Location.
Lo que la RFC pide a los servidores
Casi todo consiste en ser estricto con el cuerpo, porque el cuerpo es la consulta:
Content-Typees obligatorio. Si falta, el servidor rechaza la petición con un 4xx, normalmente 400. El servidor no debe adivinar el tipo a partir del cuerpo.- Un formato que el recurso no acepta para consultas recibe
415. Un cuerpo que no encaja con su tipo declarado recibe400, y una consulta bien formada que el servidor no puede ejecutar recibe422. - Una nueva cabecera de respuesta
Accept-Querylista los formatos de consulta que acepta un recurso. También les dice a los clientes que QUERY está soportado:
HTTP/1.1 415 Unsupported Media Type
Accept-Query: application/json, "application/jsonpath"- Las cachés pueden guardar respuestas de QUERY, pero la clave de caché tiene que incluir el cuerpo de la petición. Dos consultas a la misma URL con filtros distintos son dos entradas de caché distintas.
- Tras una redirección 301 o 302, el cliente vuelve a enviar la QUERY a la nueva URL. Los clientes suelen convertir un POST en GET tras esas redirecciones, y la RFC lo prohíbe para QUERY.
Enviamos QUERY a cinco APIs de prueba públicas
Las APIs de prueba están hechas para devolver lo que les envíes, así que cabría esperar que fueran los servidores más tolerantes que hay. Este es el 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: -Son cinco servidores y cinco respuestas distintas:
| API | Respuesta | Lo que le dice a un cliente |
|---|---|---|
| httpbingo.org | 200 con el método y el cuerpo de vuelta | Funciona. httpbingo es una reescritura de httpbin en Go, y net/http de Go deja pasar cualquier método. |
| httpbin.org | 405 con una cabecera Allow | La forma correcta de decir que no: el método no está soportado, y aquí está la lista de los que sí. |
| jsonplaceholder.typicode.com | 404 | Engañoso. La URL existe, pero el router no encontró ruta, así que un cliente pensaría que se ha equivocado de ruta. |
| postman-echo.com | 400, 11 bytes de text/plain | Dice que la petición estaba mal formada, y no lo estaba. |
| api.testingapis.com | 400, 11 bytes de text/plain | Lo mismo, y esta es la nuestra. |
Así falla nuestra propia 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 RequestEsta respuesta nunca llegó a nuestra aplicación. Cada respuesta que envía nuestra app ASP.NET Core lleva las cabeceras server: Kestrel y x-powered-by: TestingAPIs, y esta no tiene ninguna. La API corre en Azure Container Apps, y el proxy de entrada que tiene delante rechazó el método antes de pasarlo. Postman Echo devuelve el mismo cuerpo de 11 bytes y una cabecera x-envoy-upstream-service-time, así que parece que allí pasa lo mismo, detrás de Envoy. Recibimos el mismo 400 con un método inventado como FOO, así que el proxy trata QUERY como un método desconocido, no como una petición incorrecta.
La lección está en dónde está el problema. Tu framework puede manejar QUERY sin problemas mientras un balanceador de carga, un ingress o un WAF delante de él rechaza la petición, y el error que recibes no da ninguna pista de ello. Así que prueba QUERY a través de cada salto que recorre un cliente real, no solo contra localhost.
Los frameworks ya lo soportan
El código de aplicación está mucho mejor. Probamos tres servidores en la máquina en la que escribimos esto, y cada uno manejó QUERY sin configuración adicional.
Node.js 24. El parser HTTP integrado conoce el método, y fetch lo envía:
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 incluye HttpMethods.Query en el lado del servidor y HttpMethod.Query para 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: QUERYFíjate en la segunda línea. Un GET a una ruta que solo maneja QUERY recibe un 405 con Allow: QUERY sin que escribas nada para ello. Es la misma negativa educada que nos dio httpbin.org.
Python. http.server pasa cada petición a un manejador do_<METHOD>, así que añadir QUERY cuesta un método. En el lado del cliente, urllib.request.Request acepta 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()Escríbelo en mayúsculas
Los nombres de los métodos HTTP distinguen mayúsculas y minúsculas. El estándar fetch pasa seis a mayúsculas por ti: DELETE, GET, HEAD, OPTIONS, POST y PUT. Todo lo demás sale tal como lo escribiste. Esto devolvió un servidor Node para tres formas de escribirlo:
"query" -> 400
"QUERY" -> 200 QUERY
"post" -> 200 POSTmethod: "query" sale como query en minúsculas, y el servidor responde 400. PATCH lleva años con la misma trampa, y Node muestra un aviso cuando escribes "patch". Con "query" no avisa. Si una petición QUERY falla y no ves por qué, revisa las mayúsculas.
Los navegadores envían antes un preflight
QUERY no está en la lista corta de métodos que los navegadores envían entre orígenes sin preguntar antes. Así que, antes de una QUERY entre orígenes, el navegador envía un preflight OPTIONS, y el servidor tiene que nombrar QUERY en Access-Control-Allow-Methods. httpbingo fue la única API que ejecutó nuestra consulta, y esto es lo que le dice a un 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-typeQUERY no está en esa lista, así que la misma petición que funcionó desde Node fallaría en un navegador con un error de CORS. Si tu configuración de CORS lista los métodos permitidos por nombre, añade QUERY también.
OpenAPI 3.2 puede describirlo
OpenAPI 3.2, publicado en septiembre de 2025, añadió una operación query a los path items, junto a get y 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 productsOtra cosa es si tu generador de documentación, tu generador de clientes o tu gateway de API leen ya ese campo. Compruébalo antes de confiar en ello, igual que con la red.
¿Deberías usar QUERY ya?
Para el tráfico entre tus propios servicios, donde controlas cada salto, sí. Prueba el camino completo una vez y tendrás reintentos y caché que POST /search no puede darte.
Para una API pública, lo añadiríamos sin quitar nada:
- Mantén
POST /searchfuncionando y añade QUERY en el mismo recurso. Los clientes y redes que no pueden enviar QUERY siguen teniendo una entrada. - Envía
Accept-Querypara que los clientes sepan que QUERY está soportado antes de probarlo. - Devuelve una cabecera
Location. Una vez que un cliente ha ejecutado una consulta, puede repetirla con un GET, que funciona a través de cualquier proxy. - En las rutas que no soportan QUERY, responde
405con una cabeceraAllow. No respondas 400 ni 404, que sugieren un problema en la petición del cliente.
Si estás comprobando cómo maneja tu cliente un servidor que rechaza un método, nuestra lección de métodos HTTP cubre 405 y Allow, y la guía de respuestas de error muestra cómo obtener respuestas 4xx y 5xx de nuestra API cuando quieras.
PATCH tardó años en funcionar de forma fiable entre clientes, proxies y frameworks, y QUERY probablemente también tarde. La diferencia esta vez es que el código de aplicación está listo desde el primer día. Donde sigue fallando es en los proxies.