La méthode HTTP QUERY, et qui la prend vraiment en charge
En juin, l’IETF a publié la RFC 10008, qui définit une nouvelle méthode HTTP appelée QUERY. La dernière méthode standardisée était PATCH, en 2010. QUERY fonctionne comme un GET avec un corps : elle est sûre, idempotente et mise en cache possible, et la requête passe dans le corps au lieu de l’URL.
Cela ressemble à quelque chose que tout le monde devrait déjà prendre en charge, alors nous avons testé. Nous avons envoyé une requête QUERY à cinq API de test publiques, dont la nôtre, et obtenu cinq réponses différentes. Une seule a exécuté la requête. Nous avons aussi essayé Node, ASP.NET Core et Python, et les trois l’ont gérée sans problème. La méthode est prête côté code applicatif. Les proxys et serveurs intermédiaires, c’est une autre affaire.
Le problème que résout QUERY
Imaginez une recherche de produits avec une douzaine de filtres, une fourchette de prix imbriquée et un tri. Vous avez aujourd’hui deux options, et toutes deux ont des défauts.
- GET avec tout dans la query string. Il faut encoder les filtres imbriqués dans l’URL d’une façon ou d’une autre. Les URL longues butent sur les limites de longueur des serveurs et des proxys, et la requête entière finit dans les logs d’accès, l’historique du navigateur et les en-têtes
Referer. Mettre un corps sur un GET n’aide pas : la RFC 9110 dit que le contenu d’une requête GET n’a pas de sens défini, et beaucoup de proxys et de clients l’ignorent. - POST /search. C’est ce que font la plupart des API. Ça marche, mais POST annonce à chaque client et proxy que la requête pourrait modifier quelque chose. Rien ne la relance donc automatiquement après une coupure, et aucun cache ne garde le résultat.
QUERY vous donne un corps de requête que clients, proxys et caches peuvent toujours traiter comme un GET :
| GET | POST | QUERY | |
|---|---|---|---|
| Le corps de requête a un sens défini | Non | Oui | Oui |
| Sûre (ne modifie rien) | Oui | Non | Oui |
| Idempotente (on peut réessayer) | Oui | Non | Oui |
| Réponses mises en cache | Oui | Rarement | Oui, avec le corps dans la clé |
| La requête finit dans les URL et les logs | Oui | Non | Non |
À quoi ressemble une requête QUERY
On dirait un POST, sauf que le nom de la méthode indique à tous les intermédiaires qu’elle ne fait que lire :
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}]L’en-tête Location est facultatif, et c’est l’une des parties les plus utiles de la RFC. Il donne une URL qui exécute la même requête avec un simple GET : un client envoie le corps une fois, puis se sert d’un lien ordinaire. Content-Location veut dire autre chose : une URL qui renvoie ce résultat précis. Un serveur peut aussi répondre 303 See Other sans aucun résultat, ce qui envoie le client les récupérer en GET à l’URL de Location.
Ce que la RFC demande aux serveurs
Il s’agit surtout d’être strict sur le corps, puisque le corps est la requête :
Content-Typeest obligatoire. S’il manque, le serveur rejette la requête avec un 4xx, en général 400. Le serveur ne doit pas deviner le type d’après le corps.- Un format que la ressource n’accepte pas pour les requêtes reçoit
415. Un corps qui ne correspond pas à son type déclaré reçoit400, et une requête bien formée que le serveur ne peut pas exécuter reçoit422. - Un nouvel en-tête de réponse,
Accept-Query, liste les formats de requête qu’une ressource accepte. Il indique aussi aux clients que QUERY est pris en charge :
HTTP/1.1 415 Unsupported Media Type
Accept-Query: application/json, "application/jsonpath"- Les caches peuvent garder les réponses QUERY, mais la clé de cache doit inclure le corps de la requête. Deux requêtes vers la même URL avec des filtres différents font deux entrées de cache différentes.
- Après une redirection 301 ou 302, le client renvoie la QUERY vers la nouvelle URL. Les clients transforment souvent un POST en GET après ces redirections, et la RFC l’interdit pour QUERY.
Nous avons envoyé QUERY à cinq API de test publiques
Les API de test sont faites pour renvoyer ce qu’on leur envoie, on s’attendrait donc à ce qu’elles soient les serveurs les plus accommodants qui soient. Voici le 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: -Cinq serveurs, cinq réponses différentes :
| API | Réponse | Ce qu’elle dit à un client |
|---|---|---|
| httpbingo.org | 200 avec la méthode et le corps renvoyés | Ça marche. httpbingo est une réécriture d’httpbin en Go, et net/http de Go laisse passer n’importe quelle méthode. |
| httpbin.org | 405 avec un en-tête Allow | La bonne façon de dire non : la méthode n’est pas prise en charge, et voici la liste de celles qui le sont. |
| jsonplaceholder.typicode.com | 404 | Trompeur. L’URL existe, mais le routeur n’a rien trouvé, un client croirait donc s’être trompé de chemin. |
| postman-echo.com | 400, 11 octets de text/plain | Dit que la requête était mal formée, ce qu’elle n’était pas. |
| api.testingapis.com | 400, 11 octets de text/plain | La même chose, et celle-ci est la nôtre. |
Voici notre propre API en échec :
$ 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 RequestCette réponse n’a jamais atteint notre application. Chaque réponse de notre application ASP.NET Core porte les en-têtes server: Kestrel et x-powered-by: TestingAPIs, et celle-ci n’a ni l’un ni l’autre. L’API tourne sur Azure Container Apps, et le proxy d’entrée placé devant a rejeté la méthode avant de la transmettre. Postman Echo renvoie le même corps de 11 octets et un en-tête x-envoy-upstream-service-time, il semble donc qu’il se passe la même chose là-bas, derrière Envoy. Nous obtenons le même 400 pour une méthode inventée comme FOO : le proxy traite QUERY comme une méthode inconnue, pas comme une mauvaise requête.
La leçon porte sur l’endroit du problème. Votre framework peut très bien gérer QUERY pendant qu’un répartiteur de charge, un ingress ou un WAF devant lui refuse la requête, et l’erreur reçue n’en dit rien. Testez donc QUERY à travers chaque saut qu’emprunte un vrai client, pas seulement contre localhost.
Les frameworks la prennent déjà en charge
Côté code applicatif, c’est bien mieux. Nous avons essayé trois serveurs sur la machine où nous écrivons ceci, et chacun a géré QUERY sans configuration supplémentaire.
Node.js 24. Le parseur HTTP intégré connaît la méthode, et fetch l’envoie :
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 inclut HttpMethods.Query côté serveur et HttpMethod.Query pour 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: QUERYRegardez la deuxième ligne. Un GET vers une route qui ne gère que QUERY reçoit un 405 avec Allow: QUERY sans que vous ayez rien écrit pour cela. C’est le même refus poli que nous a donné httpbin.org.
Python. http.server passe chaque requête à un gestionnaire do_<METHOD>, ajouter QUERY ne demande donc qu’une méthode. Côté client, urllib.request.Request accepte 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()Écrivez-la en majuscules
Les noms de méthodes HTTP sont sensibles à la casse. Le standard fetch en met six en majuscules pour vous : DELETE, GET, HEAD, OPTIONS, POST et PUT. Tout le reste part exactement comme vous l’avez tapé. Voici ce qu’un serveur Node a renvoyé pour trois graphies :
"query" -> 400
"QUERY" -> 200 QUERY
"post" -> 200 POSTmethod: "query" part en minuscules, query, et le serveur répond 400. PATCH a le même piège depuis des années, et Node affiche un avertissement quand vous écrivez "patch". Pour "query", il ne dit rien. Si une requête QUERY échoue sans raison apparente, vérifiez les majuscules.
Les navigateurs envoient d’abord un preflight
QUERY ne figure pas dans la courte liste des méthodes que les navigateurs envoient en cross-origin sans demander d’abord. Avant une QUERY cross-origin, le navigateur envoie donc un preflight OPTIONS, et le serveur doit citer QUERY dans Access-Control-Allow-Methods. httpbingo était la seule API à exécuter notre requête, et voici ce qu’elle répond à un navigateur :
$ 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 n’est pas dans cette liste, donc la même requête qui marchait depuis Node échouerait dans un navigateur avec une erreur CORS. Si votre configuration CORS liste les méthodes autorisées par leur nom, ajoutez-y QUERY.
OpenAPI 3.2 sait la décrire
OpenAPI 3.2, sorti en septembre 2025, a ajouté une opération query aux path items, à côté de get et 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 productsReste à savoir si votre générateur de documentation, votre générateur de clients ou votre passerelle d’API lisent déjà ce champ. Vérifiez avant de compter dessus, comme pour le réseau.
Faut-il déjà utiliser QUERY ?
Pour le trafic entre vos propres services, où vous maîtrisez chaque saut, oui. Testez le chemin complet une fois et vous obtenez les nouvelles tentatives et le cache que POST /search ne peut pas offrir.
Pour une API publique, nous l’ajouterions sans rien retirer :
- Gardez
POST /searchen état de marche et ajoutez QUERY sur la même ressource. Les clients et réseaux qui ne savent pas envoyer QUERY gardent une porte d’entrée. - Envoyez
Accept-Querypour que les clients sachent que QUERY est pris en charge avant d’essayer. - Renvoyez un en-tête
Location. Une fois qu’un client a exécuté une requête, il peut la répéter avec un GET, qui passe par tous les proxys existants. - Sur les routes qui ne prennent pas QUERY en charge, répondez
405avec un en-têteAllow. Ne répondez pas 400 ou 404, qui suggèrent un problème dans la requête du client.
Si vous vérifiez comment votre client gère un serveur qui refuse une méthode, notre leçon sur les méthodes HTTP couvre 405 et Allow, et le guide des réponses d’erreur montre comment obtenir à la demande des réponses 4xx et 5xx de notre API.
PATCH a mis des années à fonctionner de façon fiable entre clients, proxys et frameworks, et QUERY prendra sans doute aussi un moment. La différence, cette fois, c’est que le code applicatif est prêt dès le premier jour. Ce sont les proxys qui coincent encore.