El método HTTP QUERY, y quién lo soporta de verdad

Compartir

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:

GETPOSTQUERY
El cuerpo de la petición tiene un significado definidoNoSíSí
Seguro (no cambia nada)SíNoSí
Idempotente (se puede reintentar)SíNoSí
Respuestas cacheablesSíRara vezSí, con el cuerpo como clave
La consulta acaba en URLs y logsSíNoNo

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:

Petición
QUERY /products/search HTTP/1.1
Host: shop.example
Content-Type: application/json
Accept: application/json

{"category":"electronics","maxPrice":500,"inStock":true,"sort":"price"}
Respuesta
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-Type es 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 recibe 400, y una consulta bien formada que el servidor no puede ejecutar recibe 422.
  • Una nueva cabecera de respuesta Accept-Query lista los formatos de consulta que acepta un recurso. También les dice a los clientes que QUERY está soportado:
Respuesta
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:

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);
}
Salida (Node 24, 10 de octubre de 2026)
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:

APIRespuestaLo que le dice a un cliente
httpbingo.org200 con el método y el cuerpo de vueltaFunciona. httpbingo es una reescritura de httpbin en Go, y net/http de Go deja pasar cualquier método.
httpbin.org405 con una cabecera AllowLa forma correcta de decir que no: el método no está soportado, y aquí está la lista de los que sí.
jsonplaceholder.typicode.com404Engañoso. La URL existe, pero el router no encontró ruta, así que un cliente pensaría que se ha equivocado de ruta.
postman-echo.com400, 11 bytes de text/plainDice que la petición estaba mal formada, y no lo estaba.
api.testingapis.com400, 11 bytes de text/plainLo mismo, y esta es la nuestra.

Así falla nuestra propia 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

Esta 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:

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 incluye HttpMethods.Query en el lado del servidor y HttpMethod.Query para HttpClient:

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);
Salida (.NET 10)
QUERY: 200 {"method":"QUERY","filters":{"category":"electronics","maxPrice":500}}
GET:   405 Allow: QUERY

Fí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".

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()

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:

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

method: "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:

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 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.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

Otra 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 /search funcionando y añade QUERY en el mismo recurso. Los clientes y redes que no pueden enviar QUERY siguen teniendo una entrada.
  • Envía Accept-Query para 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 405 con una cabecera Allow. 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.